Skip to content

Commit 8006c12

Browse files
authored
Merge pull request #221 from FreeshardBase/feature/clayde/verify-owner-email
Verify the owner email before asserting email_verified
2 parents 9978eeb + b8b6ae1 commit 8006c12

34 files changed

Lines changed: 1805 additions & 115 deletions

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ Shard Core logs in to that registry at startup, using the credentials checked in
123123

124124
If you would rather not depend on our infrastructure, note that this is what you would have to replace.
125125

126+
### The owner's email address
127+
128+
The owner's address lives on their user row and is verified by definition: a new address is only a candidate until somebody opens a confirmation link that was delivered to it. On a hosted shard the controller does that delivery, and it is also what mails the owner about disk space, billing and service notices.
129+
130+
A self-hosted shard has no controller and therefore no way to send mail at all, so it cannot run that round trip. Set `email.enabled = false` in `local_config.toml` and the address is taken as given, with no candidate step and no confirmation mail — acceptable because you control the machine. Leave it at its default of `true` and setting an address fails with HTTP 502 — the address is kept as a candidate, but the confirmation mail that would promote it cannot be delivered.
131+
132+
The address is what the built-in OIDC provider emits as the `email` claim, alongside `email_verified`. With no confirmed address it emits neither, and apps you log into through it will create fresh accounts rather than linking to an existing one by address.
133+
126134
### Localhost
127135

128136
In order to test freeshard, you might want to launch it on localhost first.

agents.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ shard_core/
2929
memory_pressure.py PSI parsing (/host/pressure/memory), cgroup v2 memory.reclaim page-out
3030
pause_metrics.py In-memory pause-tier telemetry accumulators (transitions, latencies, PSI snapshots)
3131
pairing.py Terminal pairing (JWT creation, code generation)
32+
owner_email.py Owner address: candidate, confirmation token, promotion
3233
backup.py Azure Blob Storage backup via rclone
3334
peer.py Peer shard management
3435
crypto.py RSA-4096 key generation, signing, verification (PSS padding)
@@ -41,7 +42,9 @@ shard_core/
4142
database/ → PostgreSQL access layer (per-entity modules, conn-first-arg pattern)
4243
data_model/ → Pydantic v2 models
4344
app_meta.py App metadata, Status enum, VMSize enum
44-
identity.py Shard identity (keys, domain, short_id)
45+
identity.py Shard identity (keys, domain, short_id) — the shard's
46+
public profile, published unauthenticated; holds no address
47+
user.py People on the shard (owner, later members) and their address
4548
terminal.py Paired device models
4649
peer.py Peer shard models
4750
backend/ Models copied from freeshard-controller (via `just get-types`)
@@ -71,7 +74,7 @@ async with db_conn() as conn:
7174
await db_installed_apps.update_status(conn, "myapp", Status.RUNNING)
7275
```
7376

74-
Tables: `identities`, `terminals`, `installed_apps`, `peers`, `backups`, `tours`, `app_usage_tracks`, `kv_store`.
77+
Tables: `identities`, `users`, `terminals`, `installed_apps`, `peers`, `backups`, `tours`, `app_usage_tracks`, `kv_store`, `oidc_clients`, `oidc_codes`, `oidc_tokens`.
7578

7679
Postgres data is not part of the rclone backup set (which only syncs `core/`/`user_data/`). To keep it, `database/db_snapshot.py` dumps all application tables to `core/db_snapshot.json` before each backup, and `init_database()` restores it on a fresh shard (before the default identity is generated, so the restored identity survives). Pre-0.38 backups are restored from TinyDB by `tinydb_migration.py` instead.
7780

@@ -90,6 +93,14 @@ Started at app lifespan startup, stopped at shutdown:
9093
- `CronTask(docker_prune_images, daily)` — image cleanup
9194
- Various telemetry and peer key refresh tasks
9295

96+
### The Owner's Email Address
97+
`users.email` is the single home for a person's address and is **verified by definition**; `users.pending_email` is an unverified candidate, at most one in flight per user. That invariant is the only reason the OIDC provider may assert `email_verified: true` — with no verified address it emits neither claim, and there is deliberately no synthetic fallback.
98+
99+
- `service/owner_email.py` owns every transition. Anything that writes `pending_email` must retire the token with it, or the token promotes an address it was never sent to.
100+
- On confirmation, in this order: notify the old address, promote, mirror to `shards.owner_email`, notify the new one. The first step must precede the mirror — the controller's relay only ever reaches the address it currently has on file.
101+
- `POST /public/users/confirm-email` is unauthenticated by design and the token is its only credential. There is no `GET`: mail scanners and link prefetchers would burn a single-use token.
102+
- `email.enabled` (default `true`) says whether the shard can send mail at all, and is the explicit self-hosted signal — no controller means no mail, so the address is set directly. Never infer it from a failed delivery; a controller outage would silently downgrade a security control.
103+
93104
### Signals (Event System)
94105
Blinker-based async signals defined in `util/signals.py`. DB-writing handlers are async and called via `await signal.send_async()`:
95106
- `on_apps_update` — app state changed

config.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ send_interval_seconds = 300
7676
[oidc]
7777
enabled = false
7878

79+
[email]
80+
enabled = true
81+
7982
[management]
8083
api_url = "https://ptlfunctionapp.azurewebsites.net/api/management"
8184

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
-- shard-core-0005-owner-email-verification
2+
-- depends: shard-core-0004-oidc
3+
4+
-- users.email becomes the single home for a person's address, and it is
5+
-- verified by definition. pending_email holds an unverified candidate, at most
6+
-- one per user, together with the digest and expiry of its confirmation token.
7+
ALTER TABLE users ADD COLUMN pending_email TEXT;
8+
ALTER TABLE users ADD COLUMN email_token_hash TEXT;
9+
ALTER TABLE users ADD COLUMN email_token_expires TIMESTAMPTZ;
10+
11+
-- the two token columns are only ever written together
12+
ALTER TABLE users ADD CONSTRAINT users_email_token_paired
13+
CHECK ((email_token_hash IS NULL) = (email_token_expires IS NULL));
14+
15+
-- identities.email was editable on the Public page and never verified, so it
16+
-- carries over as a candidate rather than as the owner's verified address.
17+
-- NULLIF because the dropped column defaulted to '' rather than NULL on the
18+
-- Public page, and an empty candidate is one nobody can ever confirm.
19+
UPDATE users
20+
SET pending_email = NULLIF(
21+
(SELECT email FROM identities WHERE is_default = TRUE LIMIT 1), '')
22+
WHERE role = 'owner';
23+
24+
-- Every users.email written so far is either the synthetic owner@<domain> or an
25+
-- unverified copy of identities.email. Neither may be asserted as verified, so
26+
-- every shard lands with no verified address.
27+
UPDATE users SET email = NULL WHERE role = 'owner';
28+
29+
-- An identity is the shard's public profile, published unauthenticated by
30+
-- GET /public/meta/whoareyou; a personal address has no business there.
31+
ALTER TABLE identities DROP COLUMN email;

shard_core/data_model/identity.py

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from typing import Optional
22

3-
from email_validator import validate_email, EmailNotValidError
4-
from pydantic import field_validator, BaseModel, computed_field
3+
from pydantic import BaseModel, computed_field
54

65
from shard_core.service import crypto
76
from shard_core.settings import settings
@@ -10,34 +9,20 @@
109
class Identity(BaseModel):
1110
id: str
1211
name: str
13-
email: Optional[str] = None
1412
description: Optional[str] = None
1513
private_key: str
1614
is_default: bool = False
1715

1816
def __str__(self):
1917
return f"Identity[{self.short_id}, {self.name}]"
2018

21-
@field_validator("email")
2219
@classmethod
23-
def validate_email(cls, v):
24-
if v:
25-
try:
26-
validate_email(v)
27-
except EmailNotValidError as e:
28-
raise ValueError(f"invalid email: {e}") from e
29-
return v
30-
31-
@classmethod
32-
def create(
33-
cls, name: str, description: str = None, email: str = None
34-
) -> "Identity":
20+
def create(cls, name: str, description: str = None) -> "Identity":
3521
private_key = crypto.PrivateKey()
3622
return Identity(
3723
id=private_key.get_public_key().to_hash_id(),
3824
name=name,
3925
description=description,
40-
email=email,
4126
private_key=private_key.to_bytes().decode(),
4227
)
4328

@@ -86,7 +71,6 @@ def from_identity(cls, identity: Identity):
8671
class OutputIdentity(BaseModel):
8772
id: str
8873
name: str
89-
email: Optional[str] = None
9074
description: Optional[str] = None
9175
is_default: bool
9276
public_key_pem: str
@@ -96,15 +80,4 @@ class OutputIdentity(BaseModel):
9680
class InputIdentity(BaseModel):
9781
id: Optional[str] = None
9882
name: Optional[str] = ""
99-
email: Optional[str] = ""
10083
description: Optional[str] = ""
101-
102-
@field_validator("email")
103-
@classmethod
104-
def validate_email(cls, v):
105-
if v:
106-
try:
107-
validate_email(v)
108-
except EmailNotValidError as e:
109-
raise ValueError(f"invalid email: {e}") from e
110-
return v

shard_core/data_model/user.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
from enum import Enum
33
from typing import Optional
44

5-
from pydantic import BaseModel
5+
from email_validator import validate_email, EmailNotValidError
6+
from pydantic import BaseModel, Field, field_validator
67

78

89
class Role(str, Enum):
@@ -16,9 +17,52 @@ class User(BaseModel):
1617
username: str
1718
display_name: str
1819
email: Optional[str] = None
20+
pending_email: Optional[str] = None
21+
email_token_hash: Optional[str] = None
22+
email_token_expires: Optional[datetime] = None
1923
role: Role = Role.MEMBER
2024
disabled: bool = False
2125
created: Optional[datetime] = None
2226

2327
def __str__(self):
2428
return f"User[{self.id}, {self.username}]"
29+
30+
31+
class OutputUser(BaseModel):
32+
id: int
33+
username: str
34+
display_name: str
35+
email: Optional[str] = None
36+
pending_email: Optional[str] = None
37+
role: Role
38+
39+
@classmethod
40+
def from_user(cls, user: User) -> "OutputUser":
41+
return cls(**user.model_dump())
42+
43+
44+
class InputUser(BaseModel):
45+
display_name: Optional[str] = Field(default=None, max_length=200)
46+
email: Optional[str] = Field(default=None, max_length=254)
47+
48+
@field_validator("email")
49+
@classmethod
50+
def validate_email(cls, v):
51+
"""Reject anything that is not an address; `null` is how you clear one.
52+
53+
An empty string is a rejection, not a clear — a form that blanks its
54+
field must not take the notify-then-clear path by accident.
55+
"""
56+
if v is None:
57+
return None
58+
try:
59+
# No deliverability check: it is a blocking DNS query on the event
60+
# loop, and an MX record says nothing about who reads the mailbox.
61+
validated = validate_email(
62+
v, check_deliverability=False, allow_smtputf8=False
63+
)
64+
except EmailNotValidError as e:
65+
raise ValueError(f"invalid email: {e}") from e
66+
# the normalized form is what gets asserted as verified to third
67+
# parties, so store that rather than whatever casing was typed
68+
return validated.normalized

shard_core/database/db_snapshot.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,41 @@ async def restore_db_snapshot():
101101
if not rows:
102102
continue
103103
col_types = await _column_types(conn, table)
104+
_warn_about_dropped_columns(table, rows[0], col_types)
104105
for row in rows:
105106
await _insert_row(conn, table, row, col_types)
106107
await _reset_sequences(conn, table, list(col_types))
107108
restored += len(rows)
109+
await _demote_unverified_owner_email(conn, snapshot)
108110
log.info(f"restored {restored} rows from DB snapshot")
109111

110112

113+
async def _demote_unverified_owner_email(conn: AsyncConnection, snapshot: dict):
114+
"""Apply the 0005 migration's rule to a snapshot that predates it.
115+
116+
Before 0005 the owner's users.email was the synthetic owner@<domain> or an
117+
unverified copy of identities.email, and restoring it as-is would hand the
118+
OIDC provider an address it would assert as verified. A snapshot from that
119+
era is recognisable by its identity rows still carrying an email column.
120+
"""
121+
identities = snapshot.get("identities") or []
122+
if not any("email" in row for row in identities):
123+
return
124+
candidate = next(
125+
(
126+
row["email"]
127+
for row in identities
128+
if row.get("is_default") and row.get("email")
129+
),
130+
None,
131+
)
132+
await conn.execute(
133+
"UPDATE users SET email = NULL, pending_email = %s WHERE role = 'owner'",
134+
(candidate,),
135+
)
136+
log.info("restored owner address from a pre-0005 snapshot as unverified")
137+
138+
111139
async def _list_data_tables(conn: AsyncConnection) -> list[str]:
112140
async with conn.cursor() as cur:
113141
await cur.execute(
@@ -172,7 +200,9 @@ async def _column_types(conn: AsyncConnection, table: str) -> dict[str, str]:
172200
async def _insert_row(
173201
conn: AsyncConnection, table: str, row: dict, col_types: dict[str, str]
174202
):
175-
values = {c: _adapt_value(v, col_types.get(c)) for c, v in row.items()}
203+
values = {
204+
c: _adapt_value(v, col_types[c]) for c, v in row.items() if c in col_types
205+
}
176206
columns = list(values)
177207
query = sql.SQL(
178208
"INSERT INTO {table} ({columns}) VALUES ({placeholders}) "
@@ -185,6 +215,15 @@ async def _insert_row(
185215
await conn.execute(query, values)
186216

187217

218+
def _warn_about_dropped_columns(table: str, row: dict, col_types: dict[str, str]):
219+
"""A snapshot written by an older version carries columns this schema has
220+
since dropped. _insert_row skips them — inserting them would abort the whole
221+
restore — but a silent skip on a disaster-recovery path deserves a line."""
222+
dropped = [c for c in row if c not in col_types]
223+
if dropped:
224+
log.warning(f"ignoring columns no longer in {table}: {', '.join(dropped)}")
225+
226+
188227
def _adapt_value(value, data_type: str | None):
189228
if value is None:
190229
return None

shard_core/database/identities.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from psycopg import AsyncConnection
44
from psycopg.rows import dict_row
55

6-
_UPDATABLE_COLUMNS = {"name", "email", "description", "private_key", "is_default"}
6+
_UPDATABLE_COLUMNS = {"name", "description", "private_key", "is_default"}
77

88

99
async def get_all(conn: AsyncConnection) -> list[dict]:
@@ -35,8 +35,8 @@ async def search_by_name(conn: AsyncConnection, name: str) -> list[dict]:
3535

3636

3737
async def insert(conn: AsyncConnection, identity: dict) -> dict:
38-
sql: LiteralString = """INSERT INTO identities (id, name, email, description, private_key, is_default)
39-
VALUES (%(id)s, %(name)s, %(email)s, %(description)s, %(private_key)s, %(is_default)s)
38+
sql: LiteralString = """INSERT INTO identities (id, name, description, private_key, is_default)
39+
VALUES (%(id)s, %(name)s, %(description)s, %(private_key)s, %(is_default)s)
4040
RETURNING *"""
4141
async with conn.cursor(row_factory=dict_row) as cur:
4242
await cur.execute(sql, identity)

shard_core/database/tinydb_migration.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def _jsonb(value):
2929
_TINYDATE_PREFIX = "{TinyDate}:"
3030

3131
# Columns that exist in the DB for each table — used to filter out computed fields
32-
_IDENTITY_COLUMNS = {"id", "name", "email", "description", "private_key", "is_default"}
32+
_IDENTITY_COLUMNS = {"id", "name", "description", "private_key", "is_default"}
3333
_INSTALLED_APP_COLUMNS = {"name", "installation_reason", "status", "last_access"}
3434
_TERMINAL_COLUMNS = {"id", "name", "icon", "last_connection"}
3535
_PEER_COLUMNS = {"id", "name", "public_bytes_b64", "is_reachable"}
@@ -71,8 +71,8 @@ async def migrate_tinydb_data():
7171

7272
async with db_conn() as conn:
7373
await _migrate_kv_store(conn, data.get("_default", {}))
74-
await _migrate_identities(conn, data.get("identities", {}))
75-
owner_id = await _ensure_owner_user(conn)
74+
pending_email = await _migrate_identities(conn, data.get("identities", {}))
75+
owner_id = await _ensure_owner_user(conn, pending_email)
7676
await _migrate_installed_apps(conn, data.get("installed_apps", {}))
7777
await _migrate_terminals(conn, data.get("terminals", {}), owner_id)
7878
await _migrate_peers(conn, data.get("peers", {}))
@@ -96,16 +96,25 @@ async def _migrate_kv_store(conn: AsyncConnection, records: dict):
9696
log.info(f"migrated {len(records)} kv_store entries")
9797

9898

99-
async def _migrate_identities(conn: AsyncConnection, records: dict):
99+
async def _migrate_identities(conn: AsyncConnection, records: dict) -> str | None:
100+
"""Insert the identities and return the default one's TinyDB-era address.
101+
102+
That address was never verified, so it is handed to the owner user as a
103+
candidate rather than written to the identity, which no longer holds one.
104+
"""
105+
default_email = None
100106
for record in records.values():
107+
if record.get("is_default") and record.get("email"):
108+
default_email = record["email"]
101109
filtered = _filter_keys(record, _IDENTITY_COLUMNS)
102110
await conn.execute(
103-
"""INSERT INTO identities (id, name, email, description, private_key, is_default)
104-
VALUES (%(id)s, %(name)s, %(email)s, %(description)s, %(private_key)s, %(is_default)s)
111+
"""INSERT INTO identities (id, name, description, private_key, is_default)
112+
VALUES (%(id)s, %(name)s, %(description)s, %(private_key)s, %(is_default)s)
105113
ON CONFLICT (id) DO NOTHING""",
106114
filtered,
107115
)
108116
log.info(f"migrated {len(records)} identities")
117+
return default_email
109118

110119

111120
async def _migrate_installed_apps(conn: AsyncConnection, records: dict):
@@ -122,17 +131,22 @@ async def _migrate_installed_apps(conn: AsyncConnection, records: dict):
122131
log.info(f"migrated {len(records)} installed apps")
123132

124133

125-
async def _ensure_owner_user(conn: AsyncConnection) -> int | None:
134+
async def _ensure_owner_user(
135+
conn: AsyncConnection, pending_email: str | None
136+
) -> int | None:
126137
"""Terminals require a user (NOT NULL); create the owner from the just-
127138
migrated default identity, mirroring the 0002 migration's backfill."""
128139
cur = await conn.execute("SELECT id FROM users WHERE role = 'owner'")
129140
row = await cur.fetchone()
130141
if row:
131142
return row[0]
132-
cur = await conn.execute("""INSERT INTO users (username, display_name, email, role)
133-
SELECT 'owner', COALESCE(name, 'Shard Owner'), email, 'owner'
143+
cur = await conn.execute(
144+
"""INSERT INTO users (username, display_name, pending_email, role)
145+
SELECT 'owner', COALESCE(name, 'Shard Owner'), %s, 'owner'
134146
FROM identities WHERE is_default = TRUE
135-
RETURNING id""")
147+
RETURNING id""",
148+
(pending_email,),
149+
)
136150
row = await cur.fetchone()
137151
return row[0] if row else None
138152

0 commit comments

Comments
 (0)