feat(pds): Einladungscodes für createAccount
Die Instanz soll öffentlich erreichbar werden. createAccount hatte bis
jetzt keinerlei Schranke: kein Code, kein Rate-Limit, describeServer
meldete invite_code_required hartkodiert false. Jeder hätte beliebig viele
Konten anlegen können, jedes mit eigenem Repo, Blöcken und
Firehose-Events.
Zwei Tabellen: invite_codes trägt Zähler und Sperre, invite_code_uses
protokolliert, welcher DID welchen Code eingelöst hat — ein Code kann
mehrere Konten wert sein, also ist "wer hat ihn benutzt" eine Menge. Die
DID hat bewusst keinen Fremdschlüssel: das Protokoll soll das Konto
überleben.
Der Kern ist das Einlösen ohne Rennen. Geprüft wird nicht vorher, sondern
im Schreiben selbst:
UPDATE invite_codes SET used_count = used_count + 1
WHERE code = $1 AND NOT disabled AND used_count < max_uses
RETURNING used_count, max_uses
Der Verlierer zweier gleichzeitiger Registrierungen blockiert auf der
Zeilensperre, liest danach die committete Zeile neu, wertet die
WHERE-Klausel erneut aus, trifft nichts und bekommt 400. Ein
Lesen-dann-Schreiben hätte beide durchgelassen — ich habe genau das
probeweise eingebaut, woraufhin die Nebenläufigkeitstests umfielen
("a one-use code let 5 concurrent registrations through").
Das Einlösen ist die erste Anweisung in der bestehenden Transaktion von
create_account: die Zeilensperre hält über die Konto-Inserts, und ein
Rollback gibt den Code wieder frei. Wer ein Handle-Rennen verliert,
verliert nicht auch noch seine Einladung.
Alle Fehlerfälle — fehlend, leer, unbekannt, gesperrt, aufgebraucht —
liefern dieselbe Meldung, damit der unauthentifizierte Endpoint kein
Orakel zum Abtasten des Code-Raums wird.
Codes erzeugt das Binary selbst, statt dafür einen Endpoint zu öffnen:
`pds-server invite create --count 5 --uses 1`, dazu list und disable.
PDS_INVITE_REQUIRED steht auf false per Default — sonst brechen die
Integrationstests und jede Dev-Instanz. Der Server warnt beim Start
deutlich, solange es aus ist, und describeServer meldet jetzt den
tatsächlichen Wert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
co-authored by
Claude Opus 5
parent
b58cb75cfe
commit
f04d63dd7b
@@ -33,6 +33,36 @@ fn default_pds_firehose_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Default for `PDS_INVITE_REQUIRED`.
|
||||
///
|
||||
/// `false` — `com.atproto.server.createAccount` stays open unless the
|
||||
/// operator says otherwise. This is the one security switch in this file
|
||||
/// that fails *open*, and it does so for a concrete reason: dozens of
|
||||
/// integration tests across `pds-server` and `appview` create throwaway
|
||||
/// accounts against a locally running PDS, and every dev instance is
|
||||
/// bootstrapped the same way. Defaulting to `true` would break all of
|
||||
/// them on the next `cargo test`, and the usual reflex to a suite that
|
||||
/// suddenly fails is to switch the new thing off — which lands you at
|
||||
/// `false` anyway, only with the flag now looking like the thing that
|
||||
/// was in the way rather than the thing that protects the server.
|
||||
///
|
||||
/// The cost of that choice is that an operator who exposes the PDS
|
||||
/// publicly without setting the variable gets an open registration
|
||||
/// endpoint. That is paid for at startup: `pds-server` logs a loud
|
||||
/// warning on every boot where this is `false`, in the same spirit as
|
||||
/// `appview`'s `log_startup_posture`. A warning you have to read once
|
||||
/// per restart is the trade for a test suite that keeps working.
|
||||
///
|
||||
/// That warning also covers the other way this fails open:
|
||||
/// [`parse_bool_env`] reads anything it doesn't recognise as `false`, so
|
||||
/// `PDS_INVITE_REQUIRED=ture` leaves registration open. The operator
|
||||
/// who typed it sees the same startup warning as the operator who never
|
||||
/// set the variable at all, which is the only signal that distinguishes
|
||||
/// "I meant to leave it open" from "I thought I had closed it".
|
||||
fn default_pds_invite_required() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub pds_host: String,
|
||||
@@ -99,6 +129,24 @@ pub struct AppConfig {
|
||||
/// [`default_pds_firehose_enabled`] for why.
|
||||
#[serde(default = "default_pds_firehose_enabled")]
|
||||
pub pds_firehose_enabled: bool,
|
||||
/// Whether `com.atproto.server.createAccount` demands a valid invite
|
||||
/// code. Default `false` — see [`default_pds_invite_required`] for
|
||||
/// why this switch, alone among the security switches here, fails
|
||||
/// open.
|
||||
///
|
||||
/// When `true`, a request without an `invite_code` (or its
|
||||
/// camelCase `inviteCode` spelling), or with one that is unknown,
|
||||
/// disabled or already used up, is rejected with
|
||||
/// `400 InvalidInviteCode`. The value is also what
|
||||
/// `describeServer` reports as `invite_code_required`, so a client
|
||||
/// can find out before it asks the user for a handle.
|
||||
///
|
||||
/// Codes are minted out of band with `pds-server invite create`;
|
||||
/// there is no HTTP endpoint that creates them, on purpose — an
|
||||
/// open PDS's registration gate should not come with a second
|
||||
/// public surface that hands out keys to it.
|
||||
#[serde(default = "default_pds_invite_required")]
|
||||
pub pds_invite_required: bool,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
@@ -147,6 +195,10 @@ impl AppConfig {
|
||||
.ok()
|
||||
.map(|s| parse_bool_env(&s))
|
||||
.unwrap_or_else(default_pds_firehose_enabled),
|
||||
pds_invite_required: std::env::var("PDS_INVITE_REQUIRED")
|
||||
.ok()
|
||||
.map(|s| parse_bool_env(&s))
|
||||
.unwrap_or_else(default_pds_invite_required),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user