| 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. |
| 7 | package domains |
| 8 | |
| 9 | import ( |
| 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). |
| 25 | type 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. |
| 72 | func (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. |
| 78 | func 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. |
| 85 | func 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. |
| 91 | func 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. |
| 96 | const 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. |
| 101 | const 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. |
| 140 | func 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. |
| 189 | func 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. |
| 238 | func 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 | |