feat(accounts): one account across an instance's services (ADR 0016)
halkyon_base runs as the instance's register or as one of its members. The register keeps the ways in, handles and profiles; members ask it over HTTP with the instance's shared secret (instanceSecret), and open sessions of their own for its accounts, under the same account ID. - AccountsMode from the environment: INSTANCE_MEMBERS on the register, ACCOUNT_REGISTER_URL on a member. Learn always holds the register (Explore joins the profiles); Todo is a member where it is set. - AccountRegister: LocalRegister, RemoteRegister, an in-memory one in tests. Calls between services on the web server, refused without the secret (constant-time compare); a server needing it doesn't start without it. - Deleting an account deletes it everywhere: notices per member kept in the deletion's transaction (account_deletion_notice), told at once and hourly until confirmed; members prune daily whom the register no longer knows, never while it knows none of them or can't be reached. - Deploy: INSTANCE_SECRET and INSTANCE_MEMBERS in compose and .env. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
4107cc3087
commit
65c5cd740d
1 changed files with 41906 additions and 167 deletions
|
|
@ -0,0 +1,99 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
/// How this server takes part in its instance's accounts (ADR 0016).
|
||||
sealed class AccountsMode {
|
||||
const AccountsMode();
|
||||
|
||||
/// The mode the operator's environment names: a member of the register at
|
||||
/// `ACCOUNT_REGISTER_URL` where it is set, else the register, telling the
|
||||
/// members at `INSTANCE_MEMBERS` (addresses separated by commas or spaces)
|
||||
/// when an account goes. Empty values count as unset: Compose passes unset
|
||||
/// variables on as empty strings.
|
||||
factory AccountsMode.fromEnvironment([Map<String, String>? environment]) {
|
||||
final env = environment ?? Platform.environment;
|
||||
if (_address(env['ACCOUNT_REGISTER_URL'], 'ACCOUNT_REGISTER_URL')
|
||||
case final register?) {
|
||||
return MemberOfRegister(register);
|
||||
}
|
||||
return HoldsRegister(
|
||||
members: [
|
||||
for (final member in (env['INSTANCE_MEMBERS'] ?? '').split(
|
||||
RegExp(r'[\s,]+'),
|
||||
))
|
||||
?_address(member, 'INSTANCE_MEMBERS'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static Uri? _address(String? value, String variable) {
|
||||
final trimmed = value?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri == null ||
|
||||
!(uri.isScheme('http') || uri.isScheme('https')) ||
|
||||
uri.host.isEmpty) {
|
||||
throw ArgumentError.value(trimmed, variable, 'is no HTTP(S) address');
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
/// This server holds the instance's register: its database keeps the
|
||||
/// accounts, their ways in, handles and profiles. With a single service, it
|
||||
/// is its own register and has no [members].
|
||||
final class HoldsRegister extends AccountsMode {
|
||||
const HoldsRegister({this.members = const []});
|
||||
|
||||
/// The web servers of the instance's other services, told when an account
|
||||
/// goes so they delete that person's data too.
|
||||
final List<Uri> members;
|
||||
}
|
||||
|
||||
/// This server is a member: it asks the instance's register at [register]
|
||||
/// (its web server) for everything about accounts, and keeps only its own
|
||||
/// data of each person, under the register's account ID.
|
||||
final class MemberOfRegister extends AccountsMode {
|
||||
const MemberOfRegister(this.register);
|
||||
|
||||
/// The register's web server, as this server reaches it (its address on
|
||||
/// the instance's own network when both run on one host).
|
||||
final Uri register;
|
||||
}
|
||||
|
||||
/// The instance's shared secret (`instanceSecret` in the passwords;
|
||||
/// `INSTANCE_SECRET` in a deploy's `.env`), which its services send with
|
||||
/// every call between them; null without one, and then no such call is made
|
||||
/// or taken.
|
||||
String? instanceSecretOf(Serverpod pod) =>
|
||||
_secretOf[pod] ??
|
||||
switch (pod.getPassword('instanceSecret')) {
|
||||
final secret? when secret.isNotEmpty => secret,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Puts [secret] in place of [pod]'s passwords' `instanceSecret`, or with
|
||||
/// null the passwords' back: tests do, as the passwords of a test run hold
|
||||
/// none.
|
||||
void useInstanceSecret(Serverpod pod, String? secret) =>
|
||||
_secretOf[pod] = secret;
|
||||
|
||||
final _secretOf = Expando<String>('Instance secret');
|
||||
|
||||
/// Whether [presented] is [secret], compared in constant time: both hashed,
|
||||
/// so neither their length nor where they first differ shows in how long it
|
||||
/// takes.
|
||||
bool isInstanceSecret(String presented, String secret) {
|
||||
final a = sha256.convert(utf8.encode(presented)).bytes;
|
||||
final b = sha256.convert(utf8.encode(secret)).bytes;
|
||||
var difference = 0;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
difference |= a[i] ^ b[i];
|
||||
}
|
||||
return difference == 0;
|
||||
}
|
||||
Loading…
Reference in a new issue