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
|
|
@ -46,7 +46,60 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
|||
`dataLocation`, `backupLocation`, people's profiles as the host's code reads them
|
||||
(`profileOf`, `profilesOf`, `personWithHandle`, `fixHandle`; `saveProfile` for its
|
||||
tests), `accountDomain`, `profilePageUrl`, `addProfilePageRoute`, the register
|
||||
(`AccountRegister`), and the models. A host never reads the profiles' table itself.
|
||||
(`AccountRegister`, `AccountsMode`, `addInstanceRoutes`, `deliverDeletionNotices`,
|
||||
`forgetPeopleTheRegisterForgot`, `issueSession`), and the models. A host never reads
|
||||
the profiles' table itself.
|
||||
|
||||
## The instance's register (ADR 0016)
|
||||
|
||||
One operator's services share one account per person: the same address, GitHub account
|
||||
and handle reach the same person in each, under one account ID (each service's
|
||||
`AuthUser` has it), and deleting the account deletes it everywhere. One service holds
|
||||
the register, its database keeping the ways in (`email_identity`, GitHub's IDs), the
|
||||
handles and profiles; the others are its members and ask it. Anonymous people stay each
|
||||
service's own until they add a way in, which makes them an account of the register under
|
||||
the same ID. Sessions stay each service's own: every app signs in itself.
|
||||
|
||||
- **Modes** (`AccountsMode`, `HalkyonBase.accounts`; a host reads it with
|
||||
`AccountsMode.fromEnvironment()`): `HoldsRegister` by default, also for an instance of
|
||||
one service, telling the members `INSTANCE_MEMBERS` names (web servers, separated by
|
||||
commas or spaces) when an account goes; `MemberOfRegister` where
|
||||
`ACCOUNT_REGISTER_URL` names the register's web server. Empty values count as unset.
|
||||
- **Trust:** the instance's shared secret (`instanceSecret` in the passwords), sent as a
|
||||
bearer token with every call between the services and compared in constant time. A
|
||||
server without one takes no such call and makes none (a member without one fails to
|
||||
ask anything). A member is trusted as the register's own code: whoever holds the secret
|
||||
can act for any account, so it never leaves the operator's servers.
|
||||
- **The interface:** `AccountRegister`, which every endpoint and host function asks:
|
||||
`LocalRegister` on the register (its own tables), `RemoteRegister` on a member (a POST
|
||||
of JSON to `/halkyon-base/register/<operation>` on the register's web server, 30 s;
|
||||
refusals come back as the exceptions the register threw, anything else as
|
||||
`RegisterUnavailableException`); tests put an in-memory one in its place
|
||||
(`AccountRegister.use`). The register answers on its web server (`addInstanceRoutes`),
|
||||
a member on `/halkyon-base/member/account-gone`; neither is reachable from outside (the
|
||||
host's Caddyfile never forwards them).
|
||||
- **Signing in on a member:** the app calls the same endpoints in both modes. The member
|
||||
asks the register to mail a code (the device's network passed on, as the register
|
||||
counts codes per network) and to check it, or to say whose GitHub ID it is, and opens a
|
||||
session of its own for that account (`issueSession`, which makes them a person there
|
||||
under the account's ID the first time). Linking a way in makes the person identified on
|
||||
the register and then on the member; a switch token signs in as the account the
|
||||
address is already. Every refusal is the register's, as on the register itself.
|
||||
- **Deleting:** an identified person's deletion on a member goes to the register first
|
||||
(refused while it can't be reached, deleting nothing), which deletes the account and
|
||||
keeps a notice for each member in the same transaction (`account_deletion_notice`),
|
||||
told at once and again hourly (`deliverDeletionNotices`, tried least first) until each
|
||||
confirms; notices to a member no longer named are dropped. An anonymous person goes
|
||||
from their service alone. Each member also deletes, daily, the identified people the
|
||||
register no longer knows (`forgetPeopleTheRegisterForgot`): it asks about everyone
|
||||
before deleting anyone, deletes nobody while the register knows none of them (a
|
||||
register misnamed or emptied), and fails, deleting nobody, while it can't reach it.
|
||||
After restoring the register's database from a backup, accounts made since are
|
||||
unknown to it: stop the members' pruning until those people are dealt with.
|
||||
- **Privacy:** the register learns when someone signs in to which service; the
|
||||
instance's privacy page has to say so.
|
||||
- **The register being down:** members can't sign anyone in, change profiles or delete
|
||||
accounts; their open sessions go on working.
|
||||
|
||||
## Taking it into a host
|
||||
|
||||
|
|
@ -85,6 +138,8 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
|||
german: 'Auf Halkyon Todo, einer Aufgabenliste:',
|
||||
link: Uri.parse('https://halkyon.app/todo'),
|
||||
),
|
||||
// The register, or a member of the one the operator names (ADR 0016).
|
||||
accounts: AccountsMode.fromEnvironment(),
|
||||
),
|
||||
);
|
||||
pod.initializeAuthServices(
|
||||
|
|
@ -93,12 +148,14 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
|||
);
|
||||
```
|
||||
|
||||
Serve the profile pages on its web server: `addProfilePageRoute(pod)`, after any
|
||||
Serve the calls between the instance's services on its web server:
|
||||
`addInstanceRoutes(pod)`; and the profile pages: `addProfilePageRoute(pod)`, after any
|
||||
literal routes of its own.
|
||||
|
||||
Then schedule the pruning in a future call of the host's own: daily
|
||||
`pruneAbandonedAnonymousPeople`, hourly `forgetOldSignUpAttempts` and
|
||||
`forgetExpiredEmailCodes`.
|
||||
`pruneAbandonedAnonymousPeople` and `forgetPeopleTheRegisterForgot` (a member's),
|
||||
hourly `forgetOldSignUpAttempts`, `forgetExpiredEmailCodes` and
|
||||
`deliverDeletionNotices` (the register's). Each does nothing in the other mode.
|
||||
|
||||
4. A host's deletion (`PersonDataDeletion`) runs inside `deletePerson`'s transaction,
|
||||
after the person's sessions ended and before their identities and the person go, in
|
||||
|
|
@ -109,12 +166,16 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
|||
5. Create the host's migration. A host taking the tables over from its own models (as
|
||||
Halkyon Learn does) finds no change once its copies are gone: Serverpod matches tables
|
||||
by name, and these are identical. `create-migration --empty` then records the module's
|
||||
version, with no SQL besides.
|
||||
version, with no SQL besides. Every service has the module's tables, whichever its
|
||||
mode: a member's stay empty but for its people's sessions.
|
||||
|
||||
The passwords it reads: `serverSideSessionKeyHashPepper`; for GitHub `githubClientId` and
|
||||
`githubClientSecret`; for email `emailSignInPepper` and `emailSmtpHost`,
|
||||
`emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`. Never change either
|
||||
pepper once people sign in: every session or address hash would be lost. The environment:
|
||||
`emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; between the
|
||||
instance's services `instanceSecret`. Never change either pepper once people sign in:
|
||||
every session or address hash would be lost. A member needs no email settings of its
|
||||
own (the register mails the codes), but GitHub's (its own OAuth app's, whose callback is
|
||||
its app's). The environment: `ACCOUNT_REGISTER_URL` and `INSTANCE_MEMBERS` (above);
|
||||
`ACCOUNT_DOMAIN`, the domain handles are on, which can never change once anyone follows
|
||||
one; `SOURCE_URL`, `DATA_LOCATION`, `BACKUP_LOCATION`: where the instance keeps people's data
|
||||
and its backups, each `EU`, a country's code (`DE`) or a subdivision's (ISO 3166-2,
|
||||
|
|
|
|||
Loading…
Reference in a new issue