@agentsdock/server 1.0.7-beta.5
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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
"""Fail-closed Team Hub bootstrap, invitation, and node-enrollment primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
import base64
|
|
8
|
+
import binascii
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
import sqlite3
|
|
14
|
+
import struct
|
|
15
|
+
import time
|
|
16
|
+
from typing import Iterator
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
INVITATION_MAX_TTL_SECONDS = 24 * 60 * 60
|
|
20
|
+
NODE_ENROLLMENT_MAX_TTL_SECONDS = 15 * 60
|
|
21
|
+
MIN_TTL_SECONDS = 30
|
|
22
|
+
MANAGED_SERVER_PRINCIPAL_ID = "service_managed_server"
|
|
23
|
+
MANAGED_SERVER_SERVICE_IDENTIFIER = "agentsdock.team-hub.managed-server"
|
|
24
|
+
EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
25
|
+
IDENTITY_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{7,239}$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthenticationError(RuntimeError):
|
|
29
|
+
"""Raised for an invalid, expired, revoked, or already-used credential."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthorizationError(RuntimeError):
|
|
33
|
+
"""Raised when a principal lacks the required active team role."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class IssuedSecret:
|
|
38
|
+
"""A one-time secret returned only at issuance; only its hash is persisted."""
|
|
39
|
+
|
|
40
|
+
id: str
|
|
41
|
+
token: str = field(repr=False, compare=False)
|
|
42
|
+
expires_at: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class BootstrapResult:
|
|
47
|
+
human_principal_id: str
|
|
48
|
+
team_id: str
|
|
49
|
+
membership_id: str
|
|
50
|
+
created: bool
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class EnrollmentResult:
|
|
55
|
+
team_id: str
|
|
56
|
+
node_id: str
|
|
57
|
+
principal_id: str
|
|
58
|
+
credential_id: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _id(prefix: str) -> str:
|
|
62
|
+
return f"{prefix}_{secrets.token_hex(16)}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _timestamp(now: int | None) -> int:
|
|
66
|
+
value = int(time.time()) if now is None else int(now)
|
|
67
|
+
if value < 0:
|
|
68
|
+
raise ValueError("timestamp must be non-negative")
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _bounded_text(value: str, field: str, minimum: int, maximum: int) -> str:
|
|
73
|
+
normalized = value.strip()
|
|
74
|
+
if not minimum <= len(normalized) <= maximum:
|
|
75
|
+
raise ValueError(f"{field} must be between {minimum} and {maximum} characters")
|
|
76
|
+
return normalized
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _email(value: str) -> str:
|
|
80
|
+
normalized = value.strip().lower()
|
|
81
|
+
if len(normalized) > 320 or EMAIL_PATTERN.fullmatch(normalized) is None:
|
|
82
|
+
raise ValueError("email is invalid")
|
|
83
|
+
return normalized
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _identity(value: str) -> str:
|
|
87
|
+
normalized = value.strip()
|
|
88
|
+
if IDENTITY_PATTERN.fullmatch(normalized) is None:
|
|
89
|
+
raise ValueError("server identity is invalid")
|
|
90
|
+
return normalized
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _ttl(value: int, maximum: int) -> int:
|
|
94
|
+
ttl = int(value)
|
|
95
|
+
if not MIN_TTL_SECONDS <= ttl <= maximum:
|
|
96
|
+
raise ValueError(f"ttl_seconds must be between {MIN_TTL_SECONDS} and {maximum}")
|
|
97
|
+
return ttl
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _token_digest(token: str) -> bytes:
|
|
101
|
+
if not 16 <= len(token) <= 512:
|
|
102
|
+
# Still hash a fixed value before failing so malformed tokens do not take a
|
|
103
|
+
# uniquely cheap path at the credential comparison boundary.
|
|
104
|
+
hashlib.sha256(b"invalid-team-hub-token").digest()
|
|
105
|
+
raise AuthenticationError("credential is invalid or unavailable")
|
|
106
|
+
return hashlib.sha256(token.encode("utf-8")).digest()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _new_secret() -> tuple[str, bytes]:
|
|
110
|
+
token = secrets.token_urlsafe(32)
|
|
111
|
+
return token, hashlib.sha256(token.encode("ascii")).digest()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _canonical_ed25519_public_key(public_material: str) -> tuple[str, bytes]:
|
|
115
|
+
"""Parse one OpenSSH Ed25519 public key and remove its optional comment."""
|
|
116
|
+
|
|
117
|
+
value = _bounded_text(public_material, "public_material", 32, 16384)
|
|
118
|
+
if "PRIVATE KEY" in value.upper():
|
|
119
|
+
raise ValueError("private key material is not accepted")
|
|
120
|
+
parts = value.split()
|
|
121
|
+
if len(parts) < 2 or parts[0] != "ssh-ed25519":
|
|
122
|
+
raise ValueError("ed25519 credential must be an OpenSSH public key")
|
|
123
|
+
try:
|
|
124
|
+
wire = base64.b64decode(parts[1].encode("ascii"), validate=True)
|
|
125
|
+
except (UnicodeEncodeError, binascii.Error) as exc:
|
|
126
|
+
raise ValueError("ed25519 public key encoding is invalid") from exc
|
|
127
|
+
|
|
128
|
+
def read_field(offset: int) -> tuple[bytes, int]:
|
|
129
|
+
if len(wire) - offset < 4:
|
|
130
|
+
raise ValueError("ed25519 public key encoding is truncated")
|
|
131
|
+
length = struct.unpack(">I", wire[offset : offset + 4])[0]
|
|
132
|
+
start = offset + 4
|
|
133
|
+
end = start + length
|
|
134
|
+
if end > len(wire):
|
|
135
|
+
raise ValueError("ed25519 public key encoding is truncated")
|
|
136
|
+
return wire[start:end], end
|
|
137
|
+
|
|
138
|
+
algorithm, offset = read_field(0)
|
|
139
|
+
key_bytes, offset = read_field(offset)
|
|
140
|
+
if algorithm != b"ssh-ed25519" or len(key_bytes) != 32 or offset != len(wire):
|
|
141
|
+
raise ValueError("ed25519 public key encoding is invalid")
|
|
142
|
+
encoded = base64.b64encode(wire).decode("ascii")
|
|
143
|
+
return f"ssh-ed25519 {encoded}", hashlib.sha256(wire).digest()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@contextmanager
|
|
147
|
+
def _write_transaction(connection: sqlite3.Connection) -> Iterator[None]:
|
|
148
|
+
"""Use an immediate transaction, nesting safely inside caller transactions."""
|
|
149
|
+
|
|
150
|
+
if connection.in_transaction:
|
|
151
|
+
savepoint = f"team_hub_{secrets.token_hex(8)}"
|
|
152
|
+
connection.execute(f"SAVEPOINT {savepoint}")
|
|
153
|
+
try:
|
|
154
|
+
yield
|
|
155
|
+
connection.execute(f"RELEASE SAVEPOINT {savepoint}")
|
|
156
|
+
except BaseException:
|
|
157
|
+
connection.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
|
|
158
|
+
connection.execute(f"RELEASE SAVEPOINT {savepoint}")
|
|
159
|
+
raise
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
163
|
+
try:
|
|
164
|
+
yield
|
|
165
|
+
connection.execute("COMMIT")
|
|
166
|
+
except BaseException:
|
|
167
|
+
connection.execute("ROLLBACK")
|
|
168
|
+
raise
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _require_team_role(
|
|
172
|
+
connection: sqlite3.Connection,
|
|
173
|
+
team_id: str,
|
|
174
|
+
principal_id: str,
|
|
175
|
+
allowed_roles: tuple[str, ...],
|
|
176
|
+
) -> sqlite3.Row:
|
|
177
|
+
row = connection.execute(
|
|
178
|
+
"""
|
|
179
|
+
SELECT m.id, m.role
|
|
180
|
+
FROM memberships AS m
|
|
181
|
+
JOIN principals AS p ON p.id = m.principal_id
|
|
182
|
+
WHERE m.team_id = ?
|
|
183
|
+
AND m.principal_id = ?
|
|
184
|
+
AND m.status = 'active'
|
|
185
|
+
AND p.status = 'active'
|
|
186
|
+
""",
|
|
187
|
+
(team_id, principal_id),
|
|
188
|
+
).fetchone()
|
|
189
|
+
if row is None or row["role"] not in allowed_roles:
|
|
190
|
+
raise AuthorizationError("active team role does not permit this operation")
|
|
191
|
+
return row
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def bootstrap_personal_team(
|
|
195
|
+
connection: sqlite3.Connection,
|
|
196
|
+
email: str,
|
|
197
|
+
display_name: str,
|
|
198
|
+
*,
|
|
199
|
+
now: int | None = None,
|
|
200
|
+
) -> BootstrapResult:
|
|
201
|
+
"""Idempotently create one human principal and one personal team."""
|
|
202
|
+
|
|
203
|
+
email_normalized = _email(email)
|
|
204
|
+
name = _bounded_text(display_name, "display_name", 1, 160)
|
|
205
|
+
timestamp = _timestamp(now)
|
|
206
|
+
with _write_transaction(connection):
|
|
207
|
+
existing = connection.execute(
|
|
208
|
+
"""
|
|
209
|
+
SELECT h.principal_id, t.id AS team_id, m.id AS membership_id
|
|
210
|
+
FROM human_accounts AS h
|
|
211
|
+
JOIN principals AS p ON p.id = h.principal_id
|
|
212
|
+
JOIN teams AS t ON t.personal_owner_principal_id = h.principal_id
|
|
213
|
+
JOIN memberships AS m
|
|
214
|
+
ON m.team_id = t.id AND m.principal_id = h.principal_id
|
|
215
|
+
WHERE h.email_normalized = ?
|
|
216
|
+
AND t.kind = 'personal'
|
|
217
|
+
AND m.role = 'owner'
|
|
218
|
+
AND m.status = 'active'
|
|
219
|
+
AND p.status = 'active'
|
|
220
|
+
""",
|
|
221
|
+
(email_normalized,),
|
|
222
|
+
).fetchone()
|
|
223
|
+
if existing is not None:
|
|
224
|
+
return BootstrapResult(
|
|
225
|
+
human_principal_id=existing["principal_id"],
|
|
226
|
+
team_id=existing["team_id"],
|
|
227
|
+
membership_id=existing["membership_id"],
|
|
228
|
+
created=False,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
orphan = connection.execute(
|
|
232
|
+
"SELECT principal_id FROM human_accounts WHERE email_normalized = ?",
|
|
233
|
+
(email_normalized,),
|
|
234
|
+
).fetchone()
|
|
235
|
+
if orphan is not None:
|
|
236
|
+
raise AuthenticationError("existing account has no valid personal team")
|
|
237
|
+
|
|
238
|
+
principal_id = _id("human")
|
|
239
|
+
team_id = _id("team")
|
|
240
|
+
membership_id = _id("membership")
|
|
241
|
+
team_display_name = f"{name[:153]}'s Team"
|
|
242
|
+
connection.execute(
|
|
243
|
+
"""
|
|
244
|
+
INSERT INTO principals(id, kind, display_name, created_at, updated_at)
|
|
245
|
+
VALUES (?, 'human', ?, ?, ?)
|
|
246
|
+
""",
|
|
247
|
+
(principal_id, name, timestamp, timestamp),
|
|
248
|
+
)
|
|
249
|
+
connection.execute(
|
|
250
|
+
"""
|
|
251
|
+
INSERT INTO human_accounts(principal_id, email_normalized, created_at)
|
|
252
|
+
VALUES (?, ?, ?)
|
|
253
|
+
""",
|
|
254
|
+
(principal_id, email_normalized, timestamp),
|
|
255
|
+
)
|
|
256
|
+
connection.execute(
|
|
257
|
+
"""
|
|
258
|
+
INSERT INTO teams(
|
|
259
|
+
id, kind, slug, display_name, personal_owner_principal_id,
|
|
260
|
+
created_by_principal_id, created_at, updated_at
|
|
261
|
+
) VALUES (?, 'personal', ?, ?, ?, ?, ?, ?)
|
|
262
|
+
""",
|
|
263
|
+
(
|
|
264
|
+
team_id,
|
|
265
|
+
f"personal-{team_id[-24:]}",
|
|
266
|
+
team_display_name,
|
|
267
|
+
principal_id,
|
|
268
|
+
principal_id,
|
|
269
|
+
timestamp,
|
|
270
|
+
timestamp,
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
connection.execute(
|
|
274
|
+
"""
|
|
275
|
+
INSERT INTO memberships(
|
|
276
|
+
id, team_id, principal_id, role, status, created_at, updated_at
|
|
277
|
+
) VALUES (?, ?, ?, 'owner', 'active', ?, ?)
|
|
278
|
+
""",
|
|
279
|
+
(membership_id, team_id, principal_id, timestamp, timestamp),
|
|
280
|
+
)
|
|
281
|
+
return BootstrapResult(principal_id, team_id, membership_id, True)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def record_legacy_server_binding(
|
|
285
|
+
connection: sqlite3.Connection,
|
|
286
|
+
team_id: str,
|
|
287
|
+
server_identity: str,
|
|
288
|
+
bound_by_principal_id: str,
|
|
289
|
+
*,
|
|
290
|
+
now: int | None = None,
|
|
291
|
+
) -> str:
|
|
292
|
+
"""Bind a stable legacy server identity without importing its bearer secret."""
|
|
293
|
+
|
|
294
|
+
identity = _identity(server_identity)
|
|
295
|
+
timestamp = _timestamp(now)
|
|
296
|
+
with _write_transaction(connection):
|
|
297
|
+
_require_team_role(
|
|
298
|
+
connection, team_id, bound_by_principal_id, ("owner", "admin")
|
|
299
|
+
)
|
|
300
|
+
existing = connection.execute(
|
|
301
|
+
"SELECT id, team_id FROM legacy_server_bindings WHERE server_identity = ?",
|
|
302
|
+
(identity,),
|
|
303
|
+
).fetchone()
|
|
304
|
+
if existing is not None:
|
|
305
|
+
if not hmac.compare_digest(str(existing["team_id"]), team_id):
|
|
306
|
+
raise AuthorizationError("server identity is already bound to another team")
|
|
307
|
+
return str(existing["id"])
|
|
308
|
+
enrolled_node = connection.execute(
|
|
309
|
+
"SELECT id, team_id FROM nodes WHERE server_identity = ?", (identity,)
|
|
310
|
+
).fetchone()
|
|
311
|
+
if enrolled_node is not None and not hmac.compare_digest(
|
|
312
|
+
str(enrolled_node["team_id"]), team_id
|
|
313
|
+
):
|
|
314
|
+
raise AuthorizationError("server identity is enrolled in another team")
|
|
315
|
+
binding_id = _id("legacy_binding")
|
|
316
|
+
connection.execute(
|
|
317
|
+
"""
|
|
318
|
+
INSERT INTO legacy_server_bindings(
|
|
319
|
+
id, team_id, server_identity, node_id, created_at
|
|
320
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
321
|
+
""",
|
|
322
|
+
(
|
|
323
|
+
binding_id,
|
|
324
|
+
team_id,
|
|
325
|
+
identity,
|
|
326
|
+
enrolled_node["id"] if enrolled_node is not None else None,
|
|
327
|
+
timestamp,
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
return binding_id
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _insert_invitation(
|
|
334
|
+
connection: sqlite3.Connection,
|
|
335
|
+
team_id: str,
|
|
336
|
+
issued_by_principal_id: str,
|
|
337
|
+
role: str,
|
|
338
|
+
*,
|
|
339
|
+
invitee_email: str,
|
|
340
|
+
ttl_seconds: int = 15 * 60,
|
|
341
|
+
now: int | None = None,
|
|
342
|
+
) -> IssuedSecret:
|
|
343
|
+
if role not in ("admin", "member", "guest"):
|
|
344
|
+
raise ValueError("invitation role must be admin, member, or guest")
|
|
345
|
+
email_normalized = _email(invitee_email)
|
|
346
|
+
timestamp = _timestamp(now)
|
|
347
|
+
expires_at = timestamp + _ttl(ttl_seconds, INVITATION_MAX_TTL_SECONDS)
|
|
348
|
+
token, token_hash = _new_secret()
|
|
349
|
+
invitation_id = _id("invite")
|
|
350
|
+
connection.execute(
|
|
351
|
+
"""
|
|
352
|
+
INSERT INTO invitations(
|
|
353
|
+
id, team_id, token_hash, invitee_email_normalized, role,
|
|
354
|
+
issued_by_principal_id, expires_at, created_at
|
|
355
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
356
|
+
""",
|
|
357
|
+
(
|
|
358
|
+
invitation_id,
|
|
359
|
+
team_id,
|
|
360
|
+
token_hash,
|
|
361
|
+
email_normalized,
|
|
362
|
+
role,
|
|
363
|
+
issued_by_principal_id,
|
|
364
|
+
expires_at,
|
|
365
|
+
timestamp,
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
return IssuedSecret(invitation_id, token, expires_at)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def issue_invitation(
|
|
372
|
+
connection: sqlite3.Connection,
|
|
373
|
+
team_id: str,
|
|
374
|
+
issued_by_principal_id: str,
|
|
375
|
+
role: str,
|
|
376
|
+
*,
|
|
377
|
+
invitee_email: str,
|
|
378
|
+
ttl_seconds: int = 15 * 60,
|
|
379
|
+
now: int | None = None,
|
|
380
|
+
) -> IssuedSecret:
|
|
381
|
+
"""Issue a one-time, short-lived invitation as an owner or admin."""
|
|
382
|
+
|
|
383
|
+
with _write_transaction(connection):
|
|
384
|
+
_require_team_role(
|
|
385
|
+
connection, team_id, issued_by_principal_id, ("owner", "admin")
|
|
386
|
+
)
|
|
387
|
+
return _insert_invitation(
|
|
388
|
+
connection,
|
|
389
|
+
team_id,
|
|
390
|
+
issued_by_principal_id,
|
|
391
|
+
role,
|
|
392
|
+
invitee_email=invitee_email,
|
|
393
|
+
ttl_seconds=ttl_seconds,
|
|
394
|
+
now=now,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def issue_managed_host_invitation(
|
|
399
|
+
connection: sqlite3.Connection,
|
|
400
|
+
team_id: str,
|
|
401
|
+
issued_by_principal_id: str,
|
|
402
|
+
role: str,
|
|
403
|
+
*,
|
|
404
|
+
invitee_email: str,
|
|
405
|
+
ttl_seconds: int = 15 * 60,
|
|
406
|
+
now: int | None = None,
|
|
407
|
+
) -> IssuedSecret:
|
|
408
|
+
"""Issue an invitation as this Hub's exact active managed host actor."""
|
|
409
|
+
|
|
410
|
+
with _write_transaction(connection):
|
|
411
|
+
if issued_by_principal_id != MANAGED_SERVER_PRINCIPAL_ID:
|
|
412
|
+
raise AuthorizationError("principal is not the managed host operator")
|
|
413
|
+
authorized = connection.execute(
|
|
414
|
+
"""
|
|
415
|
+
SELECT 1
|
|
416
|
+
FROM principals AS p
|
|
417
|
+
JOIN service_accounts AS s ON s.principal_id=p.id
|
|
418
|
+
JOIN memberships AS m ON m.principal_id=p.id AND m.team_id=?
|
|
419
|
+
JOIN nodes AS n ON n.team_id=m.team_id
|
|
420
|
+
JOIN managed_host_bindings AS managed
|
|
421
|
+
ON managed.singleton=1 AND managed.server_identity=n.server_identity
|
|
422
|
+
WHERE p.id=? AND p.kind='service' AND p.scope_team_id IS NULL
|
|
423
|
+
AND p.status='active' AND s.service_identifier=?
|
|
424
|
+
AND m.role='automation' AND m.status='active'
|
|
425
|
+
AND n.status='active'
|
|
426
|
+
""",
|
|
427
|
+
(
|
|
428
|
+
team_id,
|
|
429
|
+
MANAGED_SERVER_PRINCIPAL_ID,
|
|
430
|
+
MANAGED_SERVER_SERVICE_IDENTIFIER,
|
|
431
|
+
),
|
|
432
|
+
).fetchone()
|
|
433
|
+
if authorized is None:
|
|
434
|
+
raise AuthorizationError("managed host operator is unavailable")
|
|
435
|
+
return _insert_invitation(
|
|
436
|
+
connection,
|
|
437
|
+
team_id,
|
|
438
|
+
issued_by_principal_id,
|
|
439
|
+
role,
|
|
440
|
+
invitee_email=invitee_email,
|
|
441
|
+
ttl_seconds=ttl_seconds,
|
|
442
|
+
now=now,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def redeem_invitation(
|
|
447
|
+
connection: sqlite3.Connection,
|
|
448
|
+
token: str,
|
|
449
|
+
human_principal_id: str,
|
|
450
|
+
*,
|
|
451
|
+
now: int | None = None,
|
|
452
|
+
) -> str:
|
|
453
|
+
"""Atomically consume an invitation and create its exact membership."""
|
|
454
|
+
|
|
455
|
+
token_hash = _token_digest(token)
|
|
456
|
+
timestamp = _timestamp(now)
|
|
457
|
+
with _write_transaction(connection):
|
|
458
|
+
invitation = connection.execute(
|
|
459
|
+
"""
|
|
460
|
+
SELECT i.id, i.team_id, i.token_hash, i.invitee_email_normalized,
|
|
461
|
+
i.role, i.issued_by_principal_id, i.expires_at,
|
|
462
|
+
i.redeemed_at, i.revoked_at
|
|
463
|
+
FROM invitations AS i
|
|
464
|
+
JOIN memberships AS issuer
|
|
465
|
+
ON issuer.team_id = i.team_id
|
|
466
|
+
AND issuer.principal_id = i.issued_by_principal_id
|
|
467
|
+
AND issuer.status = 'active'
|
|
468
|
+
JOIN principals AS issuer_principal
|
|
469
|
+
ON issuer_principal.id = issuer.principal_id
|
|
470
|
+
AND issuer_principal.status = 'active'
|
|
471
|
+
LEFT JOIN service_accounts AS issuer_service
|
|
472
|
+
ON issuer_service.principal_id = issuer_principal.id
|
|
473
|
+
WHERE i.token_hash = ? AND (
|
|
474
|
+
issuer.role IN ('owner', 'admin')
|
|
475
|
+
OR (
|
|
476
|
+
issuer.role = 'automation'
|
|
477
|
+
AND issuer_principal.id = ?
|
|
478
|
+
AND issuer_principal.kind = 'service'
|
|
479
|
+
AND issuer_principal.scope_team_id IS NULL
|
|
480
|
+
AND issuer_service.service_identifier = ?
|
|
481
|
+
AND EXISTS (
|
|
482
|
+
SELECT 1
|
|
483
|
+
FROM nodes AS managed_node
|
|
484
|
+
JOIN managed_host_bindings AS managed
|
|
485
|
+
ON managed.singleton=1
|
|
486
|
+
AND managed.server_identity=managed_node.server_identity
|
|
487
|
+
WHERE managed_node.team_id=i.team_id
|
|
488
|
+
AND managed_node.status='active'
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
)
|
|
492
|
+
""",
|
|
493
|
+
(
|
|
494
|
+
token_hash,
|
|
495
|
+
MANAGED_SERVER_PRINCIPAL_ID,
|
|
496
|
+
MANAGED_SERVER_SERVICE_IDENTIFIER,
|
|
497
|
+
),
|
|
498
|
+
).fetchone()
|
|
499
|
+
if invitation is None or not hmac.compare_digest(invitation["token_hash"], token_hash):
|
|
500
|
+
raise AuthenticationError("credential is invalid or unavailable")
|
|
501
|
+
if (
|
|
502
|
+
invitation["redeemed_at"] is not None
|
|
503
|
+
or invitation["revoked_at"] is not None
|
|
504
|
+
or int(invitation["expires_at"]) <= timestamp
|
|
505
|
+
):
|
|
506
|
+
raise AuthenticationError("credential is invalid or unavailable")
|
|
507
|
+
human = connection.execute(
|
|
508
|
+
"""
|
|
509
|
+
SELECT h.email_normalized
|
|
510
|
+
FROM human_accounts AS h
|
|
511
|
+
JOIN principals AS p ON p.id = h.principal_id
|
|
512
|
+
WHERE h.principal_id = ? AND p.status = 'active'
|
|
513
|
+
""",
|
|
514
|
+
(human_principal_id,),
|
|
515
|
+
).fetchone()
|
|
516
|
+
if human is None:
|
|
517
|
+
raise AuthenticationError("human account is unavailable")
|
|
518
|
+
bound_email = invitation["invitee_email_normalized"]
|
|
519
|
+
if bound_email is None or str(bound_email) != str(human["email_normalized"]):
|
|
520
|
+
raise AuthenticationError("credential is invalid or unavailable")
|
|
521
|
+
|
|
522
|
+
existing = connection.execute(
|
|
523
|
+
"""
|
|
524
|
+
SELECT id, role, status FROM memberships
|
|
525
|
+
WHERE team_id = ? AND principal_id = ?
|
|
526
|
+
""",
|
|
527
|
+
(invitation["team_id"], human_principal_id),
|
|
528
|
+
).fetchone()
|
|
529
|
+
if existing is not None:
|
|
530
|
+
if existing["status"] != "active" or existing["role"] != invitation["role"]:
|
|
531
|
+
raise AuthorizationError("existing membership conflicts with invitation")
|
|
532
|
+
membership_id = str(existing["id"])
|
|
533
|
+
else:
|
|
534
|
+
membership_id = _id("membership")
|
|
535
|
+
connection.execute(
|
|
536
|
+
"""
|
|
537
|
+
INSERT INTO memberships(
|
|
538
|
+
id, team_id, principal_id, role, status,
|
|
539
|
+
invited_by_principal_id, created_at, updated_at
|
|
540
|
+
) VALUES (?, ?, ?, ?, 'active', ?, ?, ?)
|
|
541
|
+
""",
|
|
542
|
+
(
|
|
543
|
+
membership_id,
|
|
544
|
+
invitation["team_id"],
|
|
545
|
+
human_principal_id,
|
|
546
|
+
invitation["role"],
|
|
547
|
+
invitation["issued_by_principal_id"],
|
|
548
|
+
timestamp,
|
|
549
|
+
timestamp,
|
|
550
|
+
),
|
|
551
|
+
)
|
|
552
|
+
changed = connection.execute(
|
|
553
|
+
"""
|
|
554
|
+
UPDATE invitations
|
|
555
|
+
SET redeemed_at = ?, redeemed_by_principal_id = ?
|
|
556
|
+
WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL AND expires_at > ?
|
|
557
|
+
""",
|
|
558
|
+
(timestamp, human_principal_id, invitation["id"], timestamp),
|
|
559
|
+
).rowcount
|
|
560
|
+
if changed != 1:
|
|
561
|
+
raise AuthenticationError("credential is invalid or unavailable")
|
|
562
|
+
return membership_id
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def issue_node_enrollment(
|
|
566
|
+
connection: sqlite3.Connection,
|
|
567
|
+
team_id: str,
|
|
568
|
+
issued_by_principal_id: str,
|
|
569
|
+
*,
|
|
570
|
+
ttl_seconds: int = 5 * 60,
|
|
571
|
+
now: int | None = None,
|
|
572
|
+
) -> IssuedSecret:
|
|
573
|
+
"""Issue a distinct one-time node enrollment credential."""
|
|
574
|
+
|
|
575
|
+
timestamp = _timestamp(now)
|
|
576
|
+
expires_at = timestamp + _ttl(ttl_seconds, NODE_ENROLLMENT_MAX_TTL_SECONDS)
|
|
577
|
+
token, token_hash = _new_secret()
|
|
578
|
+
grant_id = _id("node_grant")
|
|
579
|
+
with _write_transaction(connection):
|
|
580
|
+
_require_team_role(
|
|
581
|
+
connection, team_id, issued_by_principal_id, ("owner", "admin")
|
|
582
|
+
)
|
|
583
|
+
connection.execute(
|
|
584
|
+
"""
|
|
585
|
+
INSERT INTO node_enrollment_grants(
|
|
586
|
+
id, team_id, token_hash, issued_by_principal_id,
|
|
587
|
+
created_at, expires_at
|
|
588
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
589
|
+
""",
|
|
590
|
+
(grant_id, team_id, token_hash, issued_by_principal_id, timestamp, expires_at),
|
|
591
|
+
)
|
|
592
|
+
return IssuedSecret(grant_id, token, expires_at)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def redeem_node_enrollment(
|
|
596
|
+
connection: sqlite3.Connection,
|
|
597
|
+
token: str,
|
|
598
|
+
server_identity: str,
|
|
599
|
+
display_name: str,
|
|
600
|
+
credential_kind: str,
|
|
601
|
+
public_material: str,
|
|
602
|
+
*,
|
|
603
|
+
now: int | None = None,
|
|
604
|
+
) -> EnrollmentResult:
|
|
605
|
+
"""Atomically consume a grant and enroll a public node credential."""
|
|
606
|
+
|
|
607
|
+
token_hash = _token_digest(token)
|
|
608
|
+
identity = _identity(server_identity)
|
|
609
|
+
name = _bounded_text(display_name, "display_name", 1, 160)
|
|
610
|
+
if credential_kind != "ed25519":
|
|
611
|
+
raise ValueError("credential_kind must be ed25519")
|
|
612
|
+
public_key, fingerprint = _canonical_ed25519_public_key(public_material)
|
|
613
|
+
timestamp = _timestamp(now)
|
|
614
|
+
|
|
615
|
+
with _write_transaction(connection):
|
|
616
|
+
grant = connection.execute(
|
|
617
|
+
"""
|
|
618
|
+
SELECT grant.id, grant.team_id, grant.token_hash, grant.expires_at,
|
|
619
|
+
grant.consumed_at, grant.revoked_at
|
|
620
|
+
FROM node_enrollment_grants AS grant
|
|
621
|
+
JOIN memberships AS issuer
|
|
622
|
+
ON issuer.team_id = grant.team_id
|
|
623
|
+
AND issuer.principal_id = grant.issued_by_principal_id
|
|
624
|
+
AND issuer.status = 'active'
|
|
625
|
+
AND issuer.role IN ('owner', 'admin')
|
|
626
|
+
JOIN principals AS issuer_principal
|
|
627
|
+
ON issuer_principal.id = issuer.principal_id
|
|
628
|
+
AND issuer_principal.status = 'active'
|
|
629
|
+
WHERE grant.token_hash = ?
|
|
630
|
+
""",
|
|
631
|
+
(token_hash,),
|
|
632
|
+
).fetchone()
|
|
633
|
+
if grant is None or not hmac.compare_digest(grant["token_hash"], token_hash):
|
|
634
|
+
raise AuthenticationError("credential is invalid or unavailable")
|
|
635
|
+
if (
|
|
636
|
+
grant["consumed_at"] is not None
|
|
637
|
+
or grant["revoked_at"] is not None
|
|
638
|
+
or int(grant["expires_at"]) <= timestamp
|
|
639
|
+
):
|
|
640
|
+
raise AuthenticationError("credential is invalid or unavailable")
|
|
641
|
+
|
|
642
|
+
principal_id = _id("node_principal")
|
|
643
|
+
node_id = _id("node")
|
|
644
|
+
credential_id = _id("node_credential")
|
|
645
|
+
team_id = str(grant["team_id"])
|
|
646
|
+
binding = connection.execute(
|
|
647
|
+
"""
|
|
648
|
+
SELECT team_id, node_id FROM legacy_server_bindings
|
|
649
|
+
WHERE server_identity = ?
|
|
650
|
+
""",
|
|
651
|
+
(identity,),
|
|
652
|
+
).fetchone()
|
|
653
|
+
if binding is not None and (
|
|
654
|
+
not hmac.compare_digest(str(binding["team_id"]), team_id)
|
|
655
|
+
or binding["node_id"] is not None
|
|
656
|
+
):
|
|
657
|
+
raise AuthorizationError("server identity is unavailable for enrollment")
|
|
658
|
+
connection.execute(
|
|
659
|
+
"""
|
|
660
|
+
INSERT INTO principals(
|
|
661
|
+
id, kind, scope_team_id, display_name, created_at, updated_at
|
|
662
|
+
) VALUES (?, 'node', ?, ?, ?, ?)
|
|
663
|
+
""",
|
|
664
|
+
(principal_id, team_id, name, timestamp, timestamp),
|
|
665
|
+
)
|
|
666
|
+
connection.execute(
|
|
667
|
+
"""
|
|
668
|
+
INSERT INTO nodes(
|
|
669
|
+
id, team_id, principal_id, server_identity,
|
|
670
|
+
display_name, enrolled_at
|
|
671
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
672
|
+
""",
|
|
673
|
+
(node_id, team_id, principal_id, identity, name, timestamp),
|
|
674
|
+
)
|
|
675
|
+
connection.execute(
|
|
676
|
+
"""
|
|
677
|
+
INSERT INTO node_credentials(
|
|
678
|
+
id, team_id, node_id, credential_kind,
|
|
679
|
+
public_material, fingerprint_sha256, created_at
|
|
680
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
681
|
+
""",
|
|
682
|
+
(
|
|
683
|
+
credential_id,
|
|
684
|
+
team_id,
|
|
685
|
+
node_id,
|
|
686
|
+
credential_kind,
|
|
687
|
+
public_key,
|
|
688
|
+
fingerprint,
|
|
689
|
+
timestamp,
|
|
690
|
+
),
|
|
691
|
+
)
|
|
692
|
+
if binding is not None:
|
|
693
|
+
connection.execute(
|
|
694
|
+
"""
|
|
695
|
+
UPDATE legacy_server_bindings SET node_id = ?
|
|
696
|
+
WHERE team_id = ? AND server_identity = ? AND node_id IS NULL
|
|
697
|
+
""",
|
|
698
|
+
(node_id, team_id, identity),
|
|
699
|
+
)
|
|
700
|
+
changed = connection.execute(
|
|
701
|
+
"""
|
|
702
|
+
UPDATE node_enrollment_grants
|
|
703
|
+
SET consumed_at = ?, consumed_by_node_id = ?
|
|
704
|
+
WHERE id = ? AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at > ?
|
|
705
|
+
""",
|
|
706
|
+
(timestamp, node_id, grant["id"], timestamp),
|
|
707
|
+
).rowcount
|
|
708
|
+
if changed != 1:
|
|
709
|
+
raise AuthenticationError("credential is invalid or unavailable")
|
|
710
|
+
return EnrollmentResult(team_id, node_id, principal_id, credential_id)
|