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/mail.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""Mail: the mailboxes a letter goes out through, the box's brand, and SMTP."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import smtplib
|
|
7
|
+
import ssl
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from email.message import EmailMessage
|
|
12
|
+
from email.utils import parseaddr
|
|
13
|
+
from typing import Literal
|
|
14
|
+
from urllib.parse import unquote, urlsplit
|
|
15
|
+
|
|
16
|
+
from cryptography.fernet import MultiFernet
|
|
17
|
+
from psycopg.types.json import Jsonb
|
|
18
|
+
|
|
19
|
+
from pinecall.domain.errors import DeclarationRefused, UpstreamFailed
|
|
20
|
+
from pinecall.domain.names import AN_ADDRESS, Json
|
|
21
|
+
from pinecall.postgres.pool import Pool
|
|
22
|
+
from pinecall.process import resolver
|
|
23
|
+
from pinecall.process.connections import Connections
|
|
24
|
+
from pinecall.tenancy.letters import Letter
|
|
25
|
+
from pinecall.tenancy.vault import opened, sealed
|
|
26
|
+
|
|
27
|
+
# starttls on 587, implicit TLS on 465; none only for a relay on this machine or a private network.
|
|
28
|
+
type Security = Literal["starttls", "tls", "none"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Where a mailbox was read from: the org's own, the one the operator stored, the environment's.
|
|
32
|
+
type Source = Literal["org", "stored", "environment"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# No spaces or slashes: it catches `smtp.example.com/`.
|
|
39
|
+
A_HOST = re.compile(r"^[A-Za-z0-9.\-_\[\]:]+$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
HIGHEST_PORT = 65535
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
TIMEOUT_S = 10.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
NOT_A_MAIL_URL = "a mail URL starts smtp:// (STARTTLS) or smtps:// (implicit TLS)"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
NO_SERVER = "the mail URL names no mail server after its `@`"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
NO_ADDRESS = "{written!r} carries no email address"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Never the URL itself in a refusal: it holds the password.
|
|
58
|
+
REFUSED = "{host}:{port} refused it: {said}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
UNREACHABLE = "{host}:{port} did not answer: {said}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
TIMED_OUT = "{host}:{port} did not answer in {seconds:g}s"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
NOT_WRITTEN = "the letter could not be written: {said}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# The password travels in clear on a plain connection: never across the internet.
|
|
71
|
+
NOT_PRIVATE = (
|
|
72
|
+
"{host} is not on this machine or a private network: a mailbox with no TLS sends its "
|
|
73
|
+
"password in clear, so it reaches only a relay there; use starttls or tls"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# An org's server is on the internet: one inside the box's network is the box's, not the org's.
|
|
78
|
+
NOT_PUBLIC = (
|
|
79
|
+
"{host} resolves to no public address, or to one that is not: an org's mailbox is a mail "
|
|
80
|
+
"server on the internet"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
NOT_PLAIN = (
|
|
85
|
+
"an org's mailbox is reached over TLS, starttls or tls: none is the box's own relay alone"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
BOX_MAIL = "mail"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
ORG_MAIL = """
|
|
93
|
+
SELECT host, port, security, username, sender, ciphertext, verified_at, last_error
|
|
94
|
+
FROM org_mail WHERE org = %(org)s
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
PUT_ORG_MAIL = """
|
|
99
|
+
INSERT INTO org_mail (org, host, port, security, username, ciphertext, sender)
|
|
100
|
+
VALUES (%(org)s, %(host)s, %(port)s, %(security)s, %(username)s, %(ciphertext)s, %(sender)s)
|
|
101
|
+
ON CONFLICT (org) DO UPDATE SET
|
|
102
|
+
host = excluded.host, port = excluded.port, security = excluded.security,
|
|
103
|
+
username = excluded.username, ciphertext = excluded.ciphertext, sender = excluded.sender,
|
|
104
|
+
verified_at = NULL, last_error = NULL, set_at = now()
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
DROP_ORG_MAIL = "DELETE FROM org_mail WHERE org = %(org)s RETURNING org"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
ORG_MAIL_WENT = """
|
|
112
|
+
UPDATE org_mail SET last_error = %(error)s,
|
|
113
|
+
verified_at = CASE WHEN %(error)s::text IS NULL THEN now() ELSE verified_at END
|
|
114
|
+
WHERE org = %(org)s
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
SETTING = "SELECT value, ciphertext FROM box_settings WHERE name = %(name)s"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
PUT_SETTING = """
|
|
122
|
+
INSERT INTO box_settings (name, value, ciphertext) VALUES (%(name)s, %(value)s, %(ciphertext)s)
|
|
123
|
+
ON CONFLICT (name) DO UPDATE SET value = excluded.value, ciphertext = excluded.ciphertext,
|
|
124
|
+
set_at = now()
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
DROP_SETTING = "DELETE FROM box_settings WHERE name = %(name)s RETURNING name"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# The outcome is kept inside the box's row, beside what it names.
|
|
132
|
+
BOX_MAIL_WENT = """
|
|
133
|
+
UPDATE box_settings SET value = value || %(went)s WHERE name = %(name)s
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class Mailbox:
|
|
139
|
+
"""An SMTP account: the server, the credentials, and who the letters are from."""
|
|
140
|
+
|
|
141
|
+
host: str
|
|
142
|
+
port: int
|
|
143
|
+
security: Security
|
|
144
|
+
# Empty for a relay that asks for nothing. The password is kept sealed, never shown.
|
|
145
|
+
username: str
|
|
146
|
+
password: str
|
|
147
|
+
sender: str
|
|
148
|
+
|
|
149
|
+
def __post_init__(self) -> None:
|
|
150
|
+
if not A_HOST.match(self.host):
|
|
151
|
+
raise DeclarationRefused(f"{self.host!r} is not a host: a mail server is named bare")
|
|
152
|
+
if not 1 <= self.port <= HIGHEST_PORT:
|
|
153
|
+
raise DeclarationRefused(f"a port is 1 to {HIGHEST_PORT}, not {self.port}")
|
|
154
|
+
address_of(self.sender)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class MailboxStatus:
|
|
159
|
+
"""A mailbox, how its last letter went, and where it was read from."""
|
|
160
|
+
|
|
161
|
+
mailbox: Mailbox
|
|
162
|
+
source: Source
|
|
163
|
+
verified_at: datetime | None = None
|
|
164
|
+
last_error: str | None = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Outbox:
|
|
168
|
+
"""Every letter the gateway sends, through the org's mailbox or the box's, in the background."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, connections: Connections, environment: Mailbox | None) -> None:
|
|
171
|
+
"""An outbox with nothing in flight."""
|
|
172
|
+
self.connections = connections
|
|
173
|
+
self.environment = environment
|
|
174
|
+
# Held so the loop cannot collect a letter halfway out.
|
|
175
|
+
self.in_flight: set[asyncio.Task[None]] = set()
|
|
176
|
+
|
|
177
|
+
async def mailbox_for(self, org: str | None) -> MailboxStatus | None:
|
|
178
|
+
"""The org's mailbox, else the box's; with no org, only the box's."""
|
|
179
|
+
own = (
|
|
180
|
+
None
|
|
181
|
+
if org is None
|
|
182
|
+
else await mail_of(self.connections.pool, self.connections.vault, org)
|
|
183
|
+
)
|
|
184
|
+
return own or await box_mail_of(
|
|
185
|
+
self.connections.pool, self.connections.vault, self.environment
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Queued, not delivered: a request never waits on somebody's mail server.
|
|
189
|
+
async def post(self, org: str | None, letter: Letter) -> bool:
|
|
190
|
+
"""Send the letter in the background; False when nothing here can send it."""
|
|
191
|
+
if await self.mailbox_for(org) is None:
|
|
192
|
+
return False
|
|
193
|
+
task = asyncio.create_task(self._posted(org, letter))
|
|
194
|
+
self.in_flight.add(task)
|
|
195
|
+
task.add_done_callback(self.in_flight.discard)
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
async def sent(self, org: str | None, letter: Letter) -> str | None:
|
|
199
|
+
"""Send and wait, as a test letter does: None when it went, else the server's reply."""
|
|
200
|
+
existing = await self.mailbox_for(org)
|
|
201
|
+
if existing is None:
|
|
202
|
+
return None
|
|
203
|
+
try:
|
|
204
|
+
if existing.source == "org":
|
|
205
|
+
await asyncio.to_thread(check_public, existing.mailbox)
|
|
206
|
+
await post(existing.mailbox, letter)
|
|
207
|
+
except UpstreamFailed as refused:
|
|
208
|
+
await self._went(org, existing.source, str(refused))
|
|
209
|
+
return str(refused)
|
|
210
|
+
await self._went(org, existing.source, None)
|
|
211
|
+
logger.info("mailed %s through %s", letter.subject, existing.mailbox.host)
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
async def drained(self) -> None:
|
|
215
|
+
"""Wait for every letter in flight."""
|
|
216
|
+
while self.in_flight:
|
|
217
|
+
await asyncio.gather(*self.in_flight, return_exceptions=True)
|
|
218
|
+
|
|
219
|
+
# A failure is said here: asyncio would say it only when the task is collected.
|
|
220
|
+
async def _posted(self, org: str | None, letter: Letter) -> None:
|
|
221
|
+
data = await self.sent(org, letter)
|
|
222
|
+
if data is not None:
|
|
223
|
+
logger.warning("could not mail %s: %s", letter.subject, data)
|
|
224
|
+
|
|
225
|
+
# Kept on the mailbox the letter went through, never on another.
|
|
226
|
+
async def _went(self, org: str | None, source: Source, error: str | None) -> None:
|
|
227
|
+
# independent: one of the two runs, on the mailbox the letter went through
|
|
228
|
+
async with self.connections.pool.connection() as connection:
|
|
229
|
+
if source == "org":
|
|
230
|
+
await connection.execute(ORG_MAIL_WENT, {"org": org, "error": error})
|
|
231
|
+
elif source == "stored":
|
|
232
|
+
went: dict[str, Json] = {"last_error": error}
|
|
233
|
+
if error is None:
|
|
234
|
+
went["verified_at"] = datetime.now(UTC).isoformat()
|
|
235
|
+
await connection.execute(BOX_MAIL_WENT, {"name": BOX_MAIL, "went": Jsonb(went)})
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
SCHEMES: Mapping[str, tuple[int, Security]] = {"smtp": (587, "starttls"), "smtps": (465, "tls")}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def address_of(written: str) -> str:
|
|
242
|
+
"""The address in `Name <a@b.c>` or a bare one; refused when there is none."""
|
|
243
|
+
_, address = parseaddr(written)
|
|
244
|
+
if not AN_ADDRESS.match(address):
|
|
245
|
+
raise DeclarationRefused(NO_ADDRESS.format(written=written))
|
|
246
|
+
return address
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# Split at the LAST `@`: an SES password is base64 and may hold a raw `/`, which would end a URL's
|
|
250
|
+
# authority for urlsplit.
|
|
251
|
+
def parse_mailbox_url(url: str, sender: str) -> Mailbox:
|
|
252
|
+
"""`smtp://user:pass@host:587` or `smtps://…`, and who letters are from, as a mailbox."""
|
|
253
|
+
scheme, separated, rest = url.strip().partition("://")
|
|
254
|
+
if not separated or scheme not in SCHEMES:
|
|
255
|
+
raise DeclarationRefused(NOT_A_MAIL_URL)
|
|
256
|
+
port, security = SCHEMES[scheme]
|
|
257
|
+
userinfo, _, where = rest.rpartition("@")
|
|
258
|
+
parts = urlsplit(f"{scheme}://{where.split('/', 1)[0]}")
|
|
259
|
+
if not parts.hostname:
|
|
260
|
+
raise DeclarationRefused(NO_SERVER)
|
|
261
|
+
try:
|
|
262
|
+
named = parts.port
|
|
263
|
+
except ValueError:
|
|
264
|
+
raise DeclarationRefused("the mail URL's port is not a number") from None
|
|
265
|
+
username, _, password = userinfo.partition(":")
|
|
266
|
+
return Mailbox(
|
|
267
|
+
host=parts.hostname,
|
|
268
|
+
port=named or port,
|
|
269
|
+
security=security,
|
|
270
|
+
username=unquote(username),
|
|
271
|
+
password=unquote(password),
|
|
272
|
+
sender=sender,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def mail_of(pool: Pool, vault: MultiFernet, org: str) -> MailboxStatus | None:
|
|
277
|
+
"""The org's own mailbox and how its last letter went."""
|
|
278
|
+
async with pool.connection() as connection:
|
|
279
|
+
row = await (await connection.execute(ORG_MAIL, {"org": org})).fetchone()
|
|
280
|
+
if row is None:
|
|
281
|
+
return None
|
|
282
|
+
return _status_of(row, opened(vault, row["ciphertext"]), "org")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def put_mail(pool: Pool, vault: MultiFernet, org: str, mailbox: Mailbox) -> None:
|
|
286
|
+
"""Keep the org's own mailbox, the password sealed; how it went is forgotten."""
|
|
287
|
+
if mailbox.security == "none":
|
|
288
|
+
raise DeclarationRefused(NOT_PLAIN)
|
|
289
|
+
values = {**_fields(mailbox), "org": org, "ciphertext": sealed(vault, mailbox.password)}
|
|
290
|
+
async with pool.connection() as connection:
|
|
291
|
+
await connection.execute(PUT_ORG_MAIL, values)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async def drop_mail(pool: Pool, org: str) -> bool:
|
|
295
|
+
"""Forget the org's own mailbox; whether it had one."""
|
|
296
|
+
async with pool.connection() as connection:
|
|
297
|
+
return await (await connection.execute(DROP_ORG_MAIL, {"org": org})).fetchone() is not None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def box_mail_of(
|
|
301
|
+
pool: Pool, vault: MultiFernet, environment: Mailbox | None
|
|
302
|
+
) -> MailboxStatus | None:
|
|
303
|
+
"""The box's mailbox: the one the operator stored, else the environment's."""
|
|
304
|
+
async with pool.connection() as connection:
|
|
305
|
+
row = await (await connection.execute(SETTING, {"name": BOX_MAIL})).fetchone()
|
|
306
|
+
stored = None if row is None or row["ciphertext"] is None else row
|
|
307
|
+
if stored is not None:
|
|
308
|
+
found = _status_of(stored["value"], opened(vault, stored["ciphertext"]), "stored")
|
|
309
|
+
if found is not None:
|
|
310
|
+
return found
|
|
311
|
+
return None if environment is None else MailboxStatus(environment, "environment")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def put_box_mail(pool: Pool, vault: MultiFernet, mailbox: Mailbox) -> None:
|
|
315
|
+
"""Keep the mailbox the box sends through, over the environment's; how it went is forgotten."""
|
|
316
|
+
values = {
|
|
317
|
+
"name": BOX_MAIL,
|
|
318
|
+
"value": Jsonb(_fields(mailbox)),
|
|
319
|
+
"ciphertext": sealed(vault, mailbox.password),
|
|
320
|
+
}
|
|
321
|
+
async with pool.connection() as connection:
|
|
322
|
+
await connection.execute(PUT_SETTING, values)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
async def drop_box_mail(pool: Pool) -> bool:
|
|
326
|
+
"""Forget the box's stored mailbox, back to the environment's; whether one was stored."""
|
|
327
|
+
async with pool.connection() as connection:
|
|
328
|
+
dropped = await connection.execute(DROP_SETTING, {"name": BOX_MAIL})
|
|
329
|
+
return await dropped.fetchone() is not None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# smtplib gets a timeout of its own: cancelling the thread's future does not stop the thread.
|
|
333
|
+
async def post(mailbox: Mailbox, letter: Letter, *, within_s: float = TIMEOUT_S) -> None:
|
|
334
|
+
"""Hand the letter to the mail server; its own reply when it refuses."""
|
|
335
|
+
try:
|
|
336
|
+
message = _message(mailbox, letter)
|
|
337
|
+
except ValueError as unwritable:
|
|
338
|
+
raise UpstreamFailed(NOT_WRITTEN.format(said=unwritable)) from unwritable
|
|
339
|
+
try:
|
|
340
|
+
await asyncio.wait_for(asyncio.to_thread(_handed, mailbox, message, within_s), within_s)
|
|
341
|
+
except TimeoutError:
|
|
342
|
+
text = TIMED_OUT.format(host=mailbox.host, port=mailbox.port, seconds=within_s)
|
|
343
|
+
raise UpstreamFailed(text) from None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# Every address the name resolves to is checked, and a name that does not resolve is refused: the
|
|
347
|
+
# server reached is on the internet, never a service inside the box's network.
|
|
348
|
+
def check_public(mailbox: Mailbox) -> None:
|
|
349
|
+
"""Refuse an org's mailbox that is plain, or that its name puts anywhere but the internet."""
|
|
350
|
+
if mailbox.security == "none":
|
|
351
|
+
raise UpstreamFailed(NOT_PLAIN)
|
|
352
|
+
try:
|
|
353
|
+
addresses = resolver.addresses_of(mailbox.host)
|
|
354
|
+
except OSError:
|
|
355
|
+
raise UpstreamFailed(NOT_PUBLIC.format(host=mailbox.host)) from None
|
|
356
|
+
if not resolver.all_public(addresses):
|
|
357
|
+
raise UpstreamFailed(NOT_PUBLIC.format(host=mailbox.host))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def parse_security(word: str) -> Security:
|
|
361
|
+
"""The word as how a mailbox is secured; refused when it is none of the three."""
|
|
362
|
+
match word:
|
|
363
|
+
case "starttls" | "tls" | "none":
|
|
364
|
+
return word
|
|
365
|
+
case _:
|
|
366
|
+
raise DeclarationRefused(f"a mailbox is secured by starttls, tls or none, not {word!r}")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _status_of(
|
|
370
|
+
fields: Mapping[str, object], password: Json, source: Source
|
|
371
|
+
) -> MailboxStatus | None:
|
|
372
|
+
if not isinstance(password, str):
|
|
373
|
+
return None
|
|
374
|
+
try:
|
|
375
|
+
mailbox = Mailbox(
|
|
376
|
+
host=str(fields["host"]),
|
|
377
|
+
port=int(str(fields["port"])),
|
|
378
|
+
security=parse_security(str(fields["security"])),
|
|
379
|
+
username=str(fields["username"]),
|
|
380
|
+
password=password,
|
|
381
|
+
sender=str(fields["sender"]),
|
|
382
|
+
)
|
|
383
|
+
except (KeyError, ValueError, DeclarationRefused):
|
|
384
|
+
logger.warning("a stored mailbox does not read: it is left unused")
|
|
385
|
+
return None
|
|
386
|
+
verified = fields.get("verified_at")
|
|
387
|
+
if isinstance(verified, str):
|
|
388
|
+
verified = datetime.fromisoformat(verified)
|
|
389
|
+
error = fields.get("last_error")
|
|
390
|
+
return MailboxStatus(
|
|
391
|
+
mailbox=mailbox,
|
|
392
|
+
source=source,
|
|
393
|
+
verified_at=verified if isinstance(verified, datetime) else None,
|
|
394
|
+
last_error=error if isinstance(error, str) else None,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _fields(mailbox: Mailbox) -> dict[str, Json]:
|
|
399
|
+
return {
|
|
400
|
+
"host": mailbox.host,
|
|
401
|
+
"port": mailbox.port,
|
|
402
|
+
"security": mailbox.security,
|
|
403
|
+
"username": mailbox.username,
|
|
404
|
+
"sender": mailbox.sender,
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _handed(mailbox: Mailbox, message: EmailMessage, timeout: float) -> None:
|
|
409
|
+
try:
|
|
410
|
+
with _connected(mailbox, timeout) as server:
|
|
411
|
+
if mailbox.security == "starttls":
|
|
412
|
+
server.starttls(context=ssl.create_default_context())
|
|
413
|
+
if mailbox.username:
|
|
414
|
+
server.login(mailbox.username, mailbox.password)
|
|
415
|
+
server.send_message(message)
|
|
416
|
+
except smtplib.SMTPResponseException as text:
|
|
417
|
+
spoken = (
|
|
418
|
+
text.smtp_error.decode("utf-8", "replace")
|
|
419
|
+
if isinstance(text.smtp_error, bytes)
|
|
420
|
+
else str(text.smtp_error)
|
|
421
|
+
)
|
|
422
|
+
reply = f"{text.smtp_code} {spoken.strip()}"
|
|
423
|
+
raise UpstreamFailed(
|
|
424
|
+
REFUSED.format(host=mailbox.host, port=mailbox.port, said=reply)
|
|
425
|
+
) from text
|
|
426
|
+
except (OSError, smtplib.SMTPException) as down:
|
|
427
|
+
text = f"{type(down).__name__}: {down}"
|
|
428
|
+
raise UpstreamFailed(
|
|
429
|
+
UNREACHABLE.format(host=mailbox.host, port=mailbox.port, said=text)
|
|
430
|
+
) from down
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _connected(mailbox: Mailbox, timeout: float) -> smtplib.SMTP:
|
|
434
|
+
if mailbox.security == "tls":
|
|
435
|
+
context = ssl.create_default_context()
|
|
436
|
+
return smtplib.SMTP_SSL(mailbox.host, mailbox.port, timeout=timeout, context=context)
|
|
437
|
+
if mailbox.security == "none":
|
|
438
|
+
return smtplib.SMTP(_private_address(mailbox.host), mailbox.port, timeout=timeout)
|
|
439
|
+
return smtplib.SMTP(mailbox.host, mailbox.port, timeout=timeout)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# Resolved once and connected to by the address checked: a name cannot point elsewhere between.
|
|
443
|
+
def _private_address(host: str) -> str:
|
|
444
|
+
addresses = resolver.addresses_of(host)
|
|
445
|
+
if not addresses or not all(item.is_private or item.is_loopback for item in addresses):
|
|
446
|
+
raise UpstreamFailed(NOT_PRIVATE.format(host=host))
|
|
447
|
+
return str(addresses[0])
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# The text first: a client that shows one part shows it, and every link is written out in it.
|
|
451
|
+
def _message(mailbox: Mailbox, letter: Letter) -> EmailMessage:
|
|
452
|
+
message = EmailMessage()
|
|
453
|
+
message["From"] = mailbox.sender
|
|
454
|
+
message["To"] = letter.to
|
|
455
|
+
message["Subject"] = letter.subject
|
|
456
|
+
message.set_content(letter.text)
|
|
457
|
+
message.add_alternative(letter.html, subtype="html")
|
|
458
|
+
return message
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""An org's secrets in a world: the values its hosted apps are started with, kept sealed."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from cryptography.fernet import MultiFernet
|
|
8
|
+
|
|
9
|
+
from pinecall.domain.errors import DeclarationRefused, NotFound
|
|
10
|
+
from pinecall.domain.names import Env
|
|
11
|
+
from pinecall.postgres.pool import Pool
|
|
12
|
+
from pinecall.tenancy.vault import opened, sealed
|
|
13
|
+
|
|
14
|
+
# An environment variable's name, as a shell takes it.
|
|
15
|
+
A_NAME = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# What the box sets itself on every process it starts.
|
|
19
|
+
RESERVED = "PINECALL_"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
LONGEST_VALUE = 16 * 1024
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
NOT_A_NAME = "{name!r} is not a secret's name: capitals, digits and underscores, like CRM_TOKEN"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
IS_THE_BOXS = "{name} is the box's to set: a name starting with PINECALL_ is never an org's secret"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
TOO_LONG = "a secret is {limit} KB at most"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
NOT_SET = "the org has no secret called {name} in {env}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
SECRETS = """
|
|
38
|
+
SELECT name, set_by, set_at FROM org_secrets
|
|
39
|
+
WHERE org = %(org)s AND env = %(env)s ORDER BY name
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
SEALED = "SELECT name, sealed FROM org_secrets WHERE org = %(org)s AND env = %(env)s ORDER BY name"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
WRITE_ONE = """
|
|
47
|
+
INSERT INTO org_secrets (org, env, name, sealed, set_by)
|
|
48
|
+
VALUES (%(org)s, %(env)s, %(name)s, %(sealed)s, %(set_by)s)
|
|
49
|
+
ON CONFLICT (org, env, name) DO UPDATE SET
|
|
50
|
+
sealed = excluded.sealed, set_by = excluded.set_by, set_at = now()
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
DROP_ONE = """
|
|
55
|
+
DELETE FROM org_secrets WHERE org = %(org)s AND env = %(env)s AND name = %(name)s RETURNING name
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ListedSecret:
|
|
61
|
+
"""A secret as the org's list shows it: its name, who set it and when; never its value."""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
set_by: str
|
|
65
|
+
set_at: datetime
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Secret:
|
|
70
|
+
"""One secret written: the world it is for, its name and its value."""
|
|
71
|
+
|
|
72
|
+
env: Env
|
|
73
|
+
name: str
|
|
74
|
+
value: str
|
|
75
|
+
|
|
76
|
+
def __post_init__(self) -> None:
|
|
77
|
+
if not A_NAME.match(self.name):
|
|
78
|
+
raise DeclarationRefused(NOT_A_NAME.format(name=self.name))
|
|
79
|
+
if self.name.startswith(RESERVED):
|
|
80
|
+
raise DeclarationRefused(IS_THE_BOXS.format(name=self.name))
|
|
81
|
+
if len(self.value.encode()) > LONGEST_VALUE:
|
|
82
|
+
raise DeclarationRefused(TOO_LONG.format(limit=LONGEST_VALUE >> 10))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def put_secret(
|
|
86
|
+
pool: Pool, vault: MultiFernet, org: str, secret: Secret, *, set_by: str
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Keep the org's secret, sealed, replacing the value it had."""
|
|
89
|
+
values = {
|
|
90
|
+
"org": org,
|
|
91
|
+
"env": secret.env,
|
|
92
|
+
"name": secret.name,
|
|
93
|
+
"sealed": sealed(vault, secret.value),
|
|
94
|
+
"set_by": set_by,
|
|
95
|
+
}
|
|
96
|
+
async with pool.connection() as connection:
|
|
97
|
+
await connection.execute(WRITE_ONE, values)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def secrets_of(pool: Pool, org: str, env: Env) -> list[ListedSecret]:
|
|
101
|
+
"""The org's secrets in the world, by name: who set each and when, never a value."""
|
|
102
|
+
async with pool.connection() as connection:
|
|
103
|
+
rows = await (await connection.execute(SECRETS, {"org": org, "env": env})).fetchall()
|
|
104
|
+
return [
|
|
105
|
+
ListedSecret(name=row["name"], set_by=row["set_by"], set_at=row["set_at"]) for row in rows
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# A value no listed vault key opens is left out: the process starts without it and says so itself.
|
|
110
|
+
async def environment_of(pool: Pool, vault: MultiFernet, org: str, env: Env) -> dict[str, str]:
|
|
111
|
+
"""The org's secrets in the world, opened, by name: what a hosted app is started with."""
|
|
112
|
+
async with pool.connection() as connection:
|
|
113
|
+
rows = await (await connection.execute(SEALED, {"org": org, "env": env})).fetchall()
|
|
114
|
+
values = {row["name"]: opened(vault, row["sealed"]) for row in rows}
|
|
115
|
+
return {name: value for name, value in values.items() if isinstance(value, str)}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def drop_secret(pool: Pool, org: str, env: Env, name: str) -> None:
|
|
119
|
+
"""Forget one of the org's secrets; NotFound for a name nobody set."""
|
|
120
|
+
async with pool.connection() as connection:
|
|
121
|
+
dropped = await connection.execute(DROP_ONE, {"org": org, "env": env, "name": name})
|
|
122
|
+
if await dropped.fetchone() is None:
|
|
123
|
+
raise NotFound(NOT_SET.format(name=name, env=env))
|
pinecall/tenancy/orgs.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Orgs: their names, the quotas they are born with, and whether their calls are judged."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
|
|
5
|
+
from psycopg.rows import DictRow
|
|
6
|
+
|
|
7
|
+
from pinecall.domain.errors import Conflict
|
|
8
|
+
from pinecall.domain.org import Org
|
|
9
|
+
from pinecall.postgres.pool import Pool
|
|
10
|
+
from pinecall.tenancy.admission import give_first_quotas
|
|
11
|
+
|
|
12
|
+
CREATE = """
|
|
13
|
+
INSERT INTO orgs (id, slug, name) VALUES (%(id)s, %(slug)s, %(name)s)
|
|
14
|
+
ON CONFLICT (slug) DO NOTHING RETURNING id
|
|
15
|
+
"""
|
|
16
|
+
LISTED = "SELECT id, slug, name FROM orgs ORDER BY created_at, id"
|
|
17
|
+
FIND = "SELECT id, slug, name FROM orgs WHERE id = %(named)s OR slug = %(named)s LIMIT 1"
|
|
18
|
+
# The quotas, keys and secrets of the org go with it by the foreign keys' cascade.
|
|
19
|
+
REMOVE = "DELETE FROM orgs WHERE id = %(org)s RETURNING id"
|
|
20
|
+
# A column nobody set is on: judging is what an org turns off, never what it turns on.
|
|
21
|
+
JUDGED = "SELECT judging IS NOT FALSE AS judged FROM orgs WHERE id = %(org)s"
|
|
22
|
+
SET_JUDGING = "UPDATE orgs SET judging = %(on)s WHERE id = %(org)s"
|
|
23
|
+
|
|
24
|
+
# An id is minted, never the slug, so a slug can be renamed without touching another row.
|
|
25
|
+
ID_PREFIX = "org_"
|
|
26
|
+
ID_BYTES = 6
|
|
27
|
+
|
|
28
|
+
SLUG_TAKEN = "{slug} is taken: pick another name for the org"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def create(pool: Pool, slug: str, name: str, *, already: int = 0) -> Org:
|
|
32
|
+
"""Make an org, with the limits the box's admission gives it in each world."""
|
|
33
|
+
org = Org(id=f"{ID_PREFIX}{secrets.token_hex(ID_BYTES)}", slug=slug, name=name)
|
|
34
|
+
async with pool.connection() as connection, connection.transaction():
|
|
35
|
+
row = await (
|
|
36
|
+
await connection.execute(CREATE, {"id": org.id, "slug": slug, "name": name})
|
|
37
|
+
).fetchone()
|
|
38
|
+
if row is None:
|
|
39
|
+
raise Conflict(SLUG_TAKEN.format(slug=slug))
|
|
40
|
+
await give_first_quotas(connection, org.id, already=already)
|
|
41
|
+
return org
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def listed(pool: Pool) -> list[Org]:
|
|
45
|
+
"""Every org, oldest first."""
|
|
46
|
+
async with pool.connection() as connection:
|
|
47
|
+
rows = await (await connection.execute(LISTED)).fetchall()
|
|
48
|
+
return [_org(row) for row in rows]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def find(pool: Pool, named: str) -> Org | None:
|
|
52
|
+
"""The org by its id or its slug."""
|
|
53
|
+
async with pool.connection() as connection:
|
|
54
|
+
row = await (await connection.execute(FIND, {"named": named})).fetchone()
|
|
55
|
+
return None if row is None else _org(row)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def remove(pool: Pool, org: str) -> bool:
|
|
59
|
+
"""Forget the org and everything it holds; whether there was one."""
|
|
60
|
+
async with pool.connection() as connection:
|
|
61
|
+
return await (await connection.execute(REMOVE, {"org": org})).fetchone() is not None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def judged(pool: Pool, org: str) -> bool:
|
|
65
|
+
"""Whether the org's calls are judged at hang-up."""
|
|
66
|
+
async with pool.connection() as connection:
|
|
67
|
+
row = await (await connection.execute(JUDGED, {"org": org})).fetchone()
|
|
68
|
+
return row is None or bool(row["judged"])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def set_judging(pool: Pool, org: str, *, on: bool) -> None:
|
|
72
|
+
"""Turn the org's judging on or off."""
|
|
73
|
+
async with pool.connection() as connection:
|
|
74
|
+
await connection.execute(SET_JUDGING, {"org": org, "on": on})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _org(row: DictRow) -> Org:
|
|
78
|
+
return Org(id=row["id"], slug=row["slug"], name=row["name"])
|