carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Account identity is minted HERE, into the bucket, because the bucket is the
2// control plane: the console mints from the web surface, `carlos accounts
3// create` mints from a box with no console at all, and both must land on the
4// same allocator. The registry and the console DB only ever materialize what
5// the bucket says (Reconcile §1, console.MaterializeAccounts) — neither of
6// them assigns.
7package domains
8
9import (
10 "context"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "strings"
15 "time"
16
17 "github.com/carlosframework/platform/internal/release"
18)
19
20// AccountManifest is the wire shape at accounts/<sqid>/account.json — the one
21// place account identity crosses between the console, the CLI and every box.
22// The sqid is in the PATH as well as the body so a reader can list accounts
23// without opening every object; the two must always agree, and both must equal
24// Sqid(id).
25type AccountManifest struct {
26 ID int64 `json:"id"`
27 Sqid string `json:"sqid"`
28 Name string `json:"name"`
29 CreatedAt time.Time `json:"created_at"`
30 // Owner is the keymail address that should hold the first owner membership
31 // of this account. ADDITIVE and omitempty: every manifest written before
32 // this field existed has no owner and stays ownerless, exactly as it
33 // materialized before.
34 //
35 // It is here — in the bucket, not in a console table — because the bucket is
36 // the control plane, and this field exists to fix a PRODUCTION failure.
37 // `carlos accounts create` recorded an owner nowhere at all, so after kass
38 // and keymail were migrated into fresh accounts the operator could not reach
39 // either of them in the console: the account existed, had no members, and
40 // membership IS authorization. The only way in was hand-running `console
41 // member -add … -role owner`, which nothing told anybody to do.
42 //
43 // Putting it in the manifest rather than teaching the carlos binary to open
44 // the console database keeps a boundary that was deliberately drawn and
45 // enforced in review: console state is the console's authority. Every box
46 // that materializes this object converges on the same owner, so a
47 // CLI-created account and a console-created one end up in the same state
48 // instead of two.
49 //
50 // Always NORMALIZED (NormalizeAddress) before it is written: the console's
51 // memberships primary key is literal text, so an unnormalized address here
52 // would materialize a membership the real session can never match.
53 Owner string `json:"owner,omitempty"`
54
55 // DeletedAt marks a retired account. The manifest is TOMBSTONED rather than
56 // removed, and the difference is the whole point: deleting the object would
57 // free the id for reuse (NextAccountID takes one past the highest id any
58 // manifest claims), and a reused id is a reused sqid — a hostname that used
59 // to be somebody's, handed to somebody else, with the old name still in
60 // Certificate Transparency and in whatever DNS caches and bookmarks the
61 // world kept. An account id is spent once.
62 //
63 // Every consumer that MATERIALIZES this manifest must skip a tombstoned one
64 // (console.MaterializeAccounts, domains.Reconcile); every consumer that
65 // ALLOCATES against it must still count it (NextAccountID). That split is
66 // the invariant — get it backwards in either direction and you either
67 // resurrect a deleted account or hand its identity to a new one.
68 DeletedAt time.Time `json:"deleted_at,omitempty"`
69}
70
71// Deleted reports whether this manifest is a tombstone.
72func (m AccountManifest) Deleted() bool { return !m.DeletedAt.IsZero() }
73
74// NormalizeAddress is the one normalization every keymail address gets before
75// it is stored or compared: trimmed and lowercased. Defined here, next to the
76// manifest field that carries one, so the CLI can apply the console's rule
77// without importing — or opening — console state.
78func NormalizeAddress(a string) string { return strings.ToLower(strings.TrimSpace(a)) }
79
80// ValidAddress is the platform's one shape check on a keymail address:
81// <local>@<host>, no whitespace. console.ValidAddress delegates to it, so the
82// sign-in form, `console member -add` and `carlos accounts create -owner`
83// cannot drift into accepting different strings — an address one of them takes
84// and another refuses is a membership somebody can create and nobody can use.
85func ValidAddress(a string) bool {
86 i := strings.LastIndex(a, "@")
87 return i > 0 && i < len(a)-1 && !strings.ContainsAny(a, " \t\r\n")
88}
89
90// AccountManifestKey is where an account's identity lives in the bucket.
91func AccountManifestKey(sqid string) string { return "accounts/" + sqid + "/account.json" }
92
93// ReservedAccountID is the built-in ops account, pinned on every deployment
94// (console or not) so every app always has an account. Nothing else may ever
95// hold it, which is why minting starts at 2.
96const ReservedAccountID = 1
97
98// mintAttempts bounds the create-only retry loop. A handful of losses means
99// another minter is genuinely racing us; a store that conflicts forever is a
100// broken deployment, and spinning on it hides that.
101const mintAttempts = 5
102
103// NextAccountID is the id a new account should take: one past the highest any
104// manifest in the bucket claims, never 0 and never 1 (ops).
105//
106// Sequential, not random, and the reason is the sqid. The encoder is base-23
107// over a 3-character floor, so a 40-bit random id encodes to NINE characters —
108// kass.nyfbskdye.oncarlos.com — a hostname nobody can read out over a phone,
109// type without a typo, or recognise as theirs. Sequential ids give bab, bac,
110// bad…, and length grows one character per 23x accounts, so a deployment is
111// years of growth away from a four-character sqid.
112//
113// This costs nothing in durability, which is the property the multi-account
114// spec exists for. The bug that made randomness look load-bearing was the
115// REGISTRY assigning ids in bucket-listing order, so a rebuild — or a second
116// box listing in a different order — silently re-homed every platform domain.
117// Durability comes from "minted once, recorded in the bucket manifest,
118// materialized-never-assigned", and that is unchanged: sequential ids are
119// exactly as durable as random ones.
120//
121// It buys nothing in secrecy either, and the old comments claiming otherwise
122// were wrong. Sqids appear in platform hostnames, and every certificate issued
123// for one is published in Certificate Transparency logs within seconds —
124// crt.sh will list them for anybody who asks. A sqid was never secret, so
125// making it unguessable protected nothing. Account existence is hidden where
126// it can actually be hidden: the console answers a non-member with the same
127// 404 a nonexistent page gets.
128//
129// A manifest that disagrees with itself still CONSUMES its id here (unlike
130// materialization, which skips it): handing that id to somebody else would put
131// two objects on one identity, which is the single thing this scheme exists to
132// prevent.
133//
134// An id whose sqid is reserved (IsReservedSqid, #285) is stepped over: the
135// answer is the first id past the highest claimed one whose sqid is free. No
136// manifest or tombstone is written for the skipped id, and none is needed.
137// The skip is a pure function of the id, so every minter computes the same
138// answer, a restart changes nothing, and once a later id is minted the
139// skipped one sits below the highest claim, where nothing allocates.
140func NextAccountID(ctx context.Context, s release.Store) (int64, error) {
141 keys, err := s.List(ctx, "accounts/")
142 if err != nil {
143 return 0, fmt.Errorf("domains: list accounts: %w", err)
144 }
145 max := int64(ReservedAccountID)
146 for _, key := range keys {
147 if _, ok := AccountManifestSqid(key); !ok {
148 continue
149 }
150 body, _, err := s.Get(ctx, key)
151 if err != nil {
152 // Unreadable is not "free": an id we cannot see is an id we must
153 // not reuse, so a read failure fails the mint rather than risking
154 // a collision.
155 return 0, fmt.Errorf("domains: read %s: %w", key, err)
156 }
157 var m AccountManifest
158 if err := json.Unmarshal(body, &m); err != nil {
159 continue // unparseable: claims no id we can honour
160 }
161 if m.ID > max {
162 max = m.ID
163 }
164 }
165 return skipReservedIDs(max + 1), nil
166}
167
168// MintAccount allocates the next free account id and publishes its manifest,
169// returning the id and its frozen sqid.
170//
171// The listing NextAccountID does is not a lock, so the write is CREATE-ONLY
172// (ifMatch ""): two boxes minting at the same moment both pick the same
173// integer, and exactly one Put wins. The loser re-lists and takes the next id
174// rather than overwriting — an overwrite would erase a live account's identity
175// and re-home every platform domain it owns.
176//
177// The manifest lands BEFORE any local row exists, everywhere this is called,
178// and that ordering is deliberate: a local row whose manifest never landed is
179// an account that vanishes on the next rebuild, while a manifest with no local
180// row is materialized back on the next pass.
181//
182// owner is the address that should hold the account's first owner membership.
183// "" mints an ownerless account, which is what every pre-owner manifest is and
184// stays; anything else must be a usable keymail address, checked HERE rather
185// than at each caller because the manifest is written once and read forever —
186// a malformed owner would be permanent, and the id it consumed is not
187// reusable. The check happens before the first listing, so a refused mint
188// writes nothing at all.
189func MintAccount(ctx context.Context, s release.Store, name, owner string, created time.Time) (int64, string, error) {
190 owner = NormalizeAddress(owner)
191 if owner != "" && !ValidAddress(owner) {
192 return 0, "", fmt.Errorf("domains: %q is not a keymail address — expected <user>@<host>, e.g. paul@keymail.dev", owner)
193 }
194 for attempt := 0; attempt < mintAttempts; attempt++ {
195 id, err := NextAccountID(ctx, s)
196 if err != nil {
197 return 0, "", err
198 }
199 sqid := Sqid(id)
200 body, err := json.Marshal(AccountManifest{
201 ID: id, Sqid: sqid, Name: name, CreatedAt: created.UTC(), Owner: owner,
202 })
203 if err != nil {
204 return 0, "", err
205 }
206 _, err = s.Put(ctx, AccountManifestKey(sqid), body, "")
207 if err == nil {
208 return id, sqid, nil
209 }
210 if !errors.Is(err, release.ErrCASConflict) {
211 return 0, "", fmt.Errorf("domains: write account manifest: %w", err)
212 }
213 // Somebody else took this id between our listing and our write. Re-list
214 // and try the next one.
215 }
216 return 0, "", fmt.Errorf("domains: could not mint an account id in %d attempts — "+
217 "another minter keeps taking the next free id, or the deployment bucket is rejecting "+
218 "create-only writes", mintAttempts)
219}
220
221// TombstoneAccount retires an account in the bucket — the control plane, and
222// therefore the only place a deletion is real.
223//
224// A console that deleted only its own rows would watch the account come back:
225// every console start materializes from these manifests, so the next deploy
226// would re-create the account AND re-adopt the manifest's owner, handing it
227// back to whoever held it at creation even if ownership had since been
228// transferred away. Deleting has to happen where identity lives.
229//
230// The manifest is REWRITTEN, not removed — see AccountManifest.DeletedAt for
231// why the id must stay spent. Name and CreatedAt are kept so the record still
232// says what this account was; Owner is cleared, because a tombstone is not a
233// grant and a consumer too old to know about DeletedAt should at worst
234// materialize something nobody owns rather than something it hands to a
235// former owner.
236//
237// CAS overwrite with one retry, the same shape as WriteAccountManifest.
238func TombstoneAccount(ctx context.Context, s release.Store, sqid string, now time.Time) error {
239 if sqid == Sqid(ReservedAccountID) {
240 return fmt.Errorf("domains: the ops account cannot be deleted")
241 }
242 key := AccountManifestKey(sqid)
243 tombstone := func() error {
244 body, etag, err := s.Get(ctx, key)
245 if err != nil {
246 return fmt.Errorf("domains: read %s: %w", key, err)
247 }
248 var m AccountManifest
249 if err := json.Unmarshal(body, &m); err != nil {
250 return fmt.Errorf("domains: parse %s: %w", key, err)
251 }
252 if m.Deleted() {
253 return nil // already retired; nothing to do and nothing to undo
254 }
255 m.Owner = ""
256 m.DeletedAt = now.UTC()
257 out, err := json.Marshal(m)
258 if err != nil {
259 return err
260 }
261 _, err = s.Put(ctx, key, out, etag)
262 return err
263 }
264 if err := tombstone(); errors.Is(err, release.ErrCASConflict) {
265 return tombstone() // one retry on a lost race; a second loss surfaces
266 } else if err != nil {
267 return err
268 }
269 return nil
270}
271