vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Name branches after the account, and labels after the display
474a812d199a109ba894124cd6f26be12c73285d · Keenan Nemetz · 16d ago · parent ade6107e
Name branches after the account, and labels after the display
A contributor's folder was `refs/heads/contrib/<account-id>/`, which told a
reader nothing without decoding a ULID. It is now the account name,
refs/heads/contrib/<name>/<branch>
and the name is therefore fixed at creation: renaming an account would strand
every ref under its old folder, so `vole admin user rename` is gone and the
users row itself — kept after retirement — is what stops a name being reissued.
What people see is now a separate, mutable display name, validated by the same
rules wherever it is set (CLI, remote admin, registration, `/account`) and shown
on pages, approvals, listings and refusals. Nothing is authorized by it and
nothing on disk is keyed by it: metadata authors stay account names because the
push boundary resolves them for ownership, and approvals stay account ids.
`user_names`, `Snapshot.Reserved` and `Account.Former` are dropped: with a fixed
name there are no former names to remember, and the UNIQUE column already does
the reserving. Opening an older roster adds the `display` column and drops the
alias table in place; contributor refs written under the old id namespace are
left where they are, readable, and landable by a maintainer.
forge/identity/identity.go +142−104
| @@ -4,18 +4,22 @@ | |||
| 4 | 4 | // the CI queue (runs.db). A roster is small and written by a handful of operator | |
| 5 | 5 | // commands, so the whole thing is a handful of rows: | |
| 6 | 6 | // | |
| 7 | // users one row per account: stable id, current name, status, created | ||
| 8 | // user_names every name an account has ever held; the primary key is what | ||
| 9 | // stops a name being handed out twice | ||
| 7 | // users one row per account: stable id, fixed name, display, status, created | ||
| 10 | 8 | // user_keys one row per authorized SSH key, unique by fingerprint | |
| 11 | 9 | // user_credentials one row per passkey: the browser credential's public half | |
| 12 | 10 | // user_invites one-time enrollment tokens, stored only as hashes | |
| 13 | 11 | // sessions browser sessions, stored only as token hashes | |
| 14 | 12 | // | |
| 15 | // An account has a stable id and a mutable name. The id is what ACL entries and | ||
| 16 | // approvals refer to, so renaming somebody touches no repository state, and a | ||
| 17 | // retired name is never handed to a second account — which is what stops a new | ||
| 18 | // user from inheriting an old one's access and approvals. | ||
| 13 | // An account has a stable id and a name that is fixed the moment it is created. | ||
| 14 | // The name is what contributor branches and metadata authors are written in, so | ||
| 15 | // it can never move: a rename would strand every ref under the old folder. The | ||
| 16 | // id is what ACL entries and approval files are keyed by. Because the users row | ||
| 17 | // is kept after retirement, the UNIQUE constraint on the name stops a retired | ||
| 18 | // name being handed to a second account — which is what stops a new user from | ||
| 19 | // inheriting an old one's access and approvals. | ||
| 20 | // | ||
| 21 | // A display name is optional and mutable. It is a label for people, never an | ||
| 22 | // identity: nothing is authorized by it and nothing on disk is keyed by it. | ||
| 19 | 23 | // | |
| 20 | 24 | // The credentials anchor the account. An SSH key is resolved at the SSH | |
| 21 | 25 | // handshake; a passkey is resolved in the browser login ceremony. Both yield the | |
| @@ -41,6 +45,8 @@ import ( | |||
| 41 | 45 | "strings" | |
| 42 | 46 | "sync/atomic" | |
| 43 | 47 | "time" | |
| 48 | "unicode" | ||
| 49 | "unicode/utf8" | ||
| 44 | 50 | ||
| 45 | 51 | _ "modernc.org/sqlite" | |
| 46 | 52 | ||
| @@ -50,7 +56,9 @@ import ( | |||
| 50 | 56 | ||
| 51 | 57 | var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`) | |
| 52 | 58 | ||
| 53 | // ValidateName reports whether s may be an account name. | ||
| 59 | // ValidateName reports whether s may be an account name. Names are also branch | ||
| 60 | // path components (refs/heads/contrib/<name>/...), which is why they are limited | ||
| 61 | // to the characters git accepts in a ref component. | ||
| 54 | 62 | func ValidateName(s string) error { | |
| 55 | 63 | if !nameRE.MatchString(s) { | |
| 56 | 64 | return fmt.Errorf("name %q is not valid: 2-32 characters, lowercase letters, digits, - and _", s) | |
| @@ -58,6 +66,30 @@ func ValidateName(s string) error { | |||
| 58 | 66 | return nil | |
| 59 | 67 | } | |
| 60 | 68 | ||
| 69 | // maxDisplayRunes bounds a display name. It is a label, not an identity, so the | ||
| 70 | // limit only has to keep a page or a table readable. | ||
| 71 | const maxDisplayRunes = 64 | ||
| 72 | |||
| 73 | // ValidateDisplay reports whether s may be a display name. Anything printable is | ||
| 74 | // allowed, including spaces, capitals and non-Latin scripts; control characters | ||
| 75 | // are not, because a newline or tab would break the one-line listings a display | ||
| 76 | // name shows up in. | ||
| 77 | func ValidateDisplay(s string) error { | ||
| 78 | s = strings.TrimSpace(s) | ||
| 79 | if s == "" { | ||
| 80 | return nil | ||
| 81 | } | ||
| 82 | if n := utf8.RuneCountInString(s); n > maxDisplayRunes { | ||
| 83 | return fmt.Errorf("display name is %d characters; the limit is %d", n, maxDisplayRunes) | ||
| 84 | } | ||
| 85 | for _, r := range s { | ||
| 86 | if unicode.IsControl(r) { | ||
| 87 | return fmt.Errorf("display name %q contains a control character", s) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | return nil | ||
| 91 | } | ||
| 92 | |||
| 61 | 93 | type Key struct { | |
| 62 | 94 | Fingerprint string `json:"fingerprint"` | |
| 63 | 95 | Type string `json:"type"` | |
| @@ -67,9 +99,9 @@ type Key struct { | |||
| 67 | 99 | ||
| 68 | 100 | type Account struct { | |
| 69 | 101 | ID string | |
| 70 | Name string | ||
| 102 | Name string // fixed at creation; contributor branches are named after it | ||
| 103 | Display string // optional human-friendly label; may change | ||
| 71 | 104 | Status string // active | retired | |
| 72 | Former []string | ||
| 73 | 105 | Keys []Key | |
| 74 | 106 | Admin bool | |
| 75 | 107 | Created time.Time | |
| @@ -77,6 +109,18 @@ type Account struct { | |||
| 77 | 109 | ||
| 78 | 110 | func (a *Account) Active() bool { return a.Status == "active" } | |
| 79 | 111 | ||
| 112 | // Label is what a person is shown: the display name when one is set, the | ||
| 113 | // immutable name otherwise. | ||
| 114 | func (a *Account) Label() string { | ||
| 115 | if a == nil { | ||
| 116 | return "" | ||
| 117 | } | ||
| 118 | if a.Display != "" { | ||
| 119 | return a.Display | ||
| 120 | } | ||
| 121 | return a.Name | ||
| 122 | } | ||
| 123 | |||
| 80 | 124 | // Snapshot is an immutable view of the roster, swapped atomically so the SSH | |
| 81 | 125 | // auth callback stays a map lookup. | |
| 82 | 126 | type Snapshot struct { | |
| @@ -84,11 +128,9 @@ type Snapshot struct { | |||
| 84 | 128 | // ByKey maps a key fingerprint to an active account id. Retired accounts are | |
| 85 | 129 | // absent, so revoking is immediate. | |
| 86 | 130 | ByKey map[string]string | |
| 87 | // ByName maps an active name to its account. | ||
| 131 | // ByName maps a name to its account, including retired accounts: a name is | ||
| 132 | // fixed at creation and remains attached to the account that took it. | ||
| 88 | 133 | ByName map[string]*Account | |
| 89 | // Reserved holds every name an account has ever held, including former names | ||
| 90 | // and retired accounts. A reserved name is never issued twice. | ||
| 91 | Reserved map[string]string | ||
| 92 | 134 | ||
| 93 | 135 | // Admins holds the account ids that may use the remote administration API. | |
| 94 | 136 | Admins map[string]bool | |
| @@ -116,13 +158,10 @@ const schema = ` | |||
| 116 | 158 | CREATE TABLE IF NOT EXISTS users ( | |
| 117 | 159 | id TEXT PRIMARY KEY, | |
| 118 | 160 | name TEXT NOT NULL UNIQUE, | |
| 161 | display TEXT NOT NULL DEFAULT '', | ||
| 119 | 162 | status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired')), | |
| 120 | 163 | created INTEGER NOT NULL | |
| 121 | 164 | ); | |
| 122 | CREATE TABLE IF NOT EXISTS user_names ( | ||
| 123 | name TEXT PRIMARY KEY, | ||
| 124 | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE | ||
| 125 | ); | ||
| 126 | 165 | CREATE TABLE IF NOT EXISTS user_keys ( | |
| 127 | 166 | fingerprint TEXT PRIMARY KEY, | |
| 128 | 167 | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| @@ -198,9 +237,30 @@ func Init(path string) error { | |||
| 198 | 237 | if _, err := db.Exec(schema); err != nil { | |
| 199 | 238 | return fmt.Errorf("identity: create roster: %w", err) | |
| 200 | 239 | } | |
| 240 | if err := migrate(db); err != nil { | ||
| 241 | return fmt.Errorf("identity: migrate roster: %w", err) | ||
| 242 | } | ||
| 201 | 243 | return nil | |
| 202 | 244 | } | |
| 203 | 245 | ||
| 246 | // migrate upgrades a roster written by an older build in place. It is idempotent | ||
| 247 | // and runs on every open, so an operator who restores an old backup does not | ||
| 248 | // have to remember which version produced it. | ||
| 249 | func migrate(db *sql.DB) error { | ||
| 250 | // Display names were added after the first release. A roster without the | ||
| 251 | // column is upgraded rather than rejected; a roster that already has it keeps | ||
| 252 | // its values. | ||
| 253 | if _, err := db.Exec(`ALTER TABLE users ADD COLUMN display TEXT NOT NULL DEFAULT ''`); err != nil && !strings.Contains(err.Error(), "duplicate column name") { | ||
| 254 | return err | ||
| 255 | } | ||
| 256 | // Names used to be mutable, so every name an account had ever held was kept | ||
| 257 | // in a separate table to stop it being reissued. A name is now fixed at | ||
| 258 | // creation and the users row outlives retirement, so that table is redundant | ||
| 259 | // and its contents would otherwise be a list of aliases nothing resolves. | ||
| 260 | _, err := db.Exec(`DROP TABLE IF EXISTS user_names`) | ||
| 261 | return err | ||
| 262 | } | ||
| 263 | |||
| 204 | 264 | // Open loads the roster. An empty roster is valid: it means nobody can write yet. | |
| 205 | 265 | // A roster that does not parse is a hard error, because booting with a partial | |
| 206 | 266 | // roster would silently change who can authenticate. | |
| @@ -218,6 +278,10 @@ func Open(path string) (*Store, error) { | |||
| 218 | 278 | db.Close() | |
| 219 | 279 | return nil, fmt.Errorf("identity: %w", err) | |
| 220 | 280 | } | |
| 281 | if err := migrate(db); err != nil { | ||
| 282 | db.Close() | ||
| 283 | return nil, fmt.Errorf("identity: %w", err) | ||
| 284 | } | ||
| 221 | 285 | s := &Store{db: db, path: path} | |
| 222 | 286 | if err := s.Reload(); err != nil { | |
| 223 | 287 | db.Close() | |
| @@ -257,13 +321,12 @@ func (s *Store) Reload() error { | |||
| 257 | 321 | ||
| 258 | 322 | func (s *Store) load() (*Snapshot, error) { | |
| 259 | 323 | snap := &Snapshot{ | |
| 260 | ByID: map[string]*Account{}, | ||
| 261 | ByKey: map[string]string{}, | ||
| 262 | ByName: map[string]*Account{}, | ||
| 263 | Reserved: map[string]string{}, | ||
| 264 | Admins: map[string]bool{}, | ||
| 324 | ByID: map[string]*Account{}, | ||
| 325 | ByKey: map[string]string{}, | ||
| 326 | ByName: map[string]*Account{}, | ||
| 327 | Admins: map[string]bool{}, | ||
| 265 | 328 | } | |
| 266 | rows, err := s.db.Query(`SELECT id, name, status, created FROM users ORDER BY id`) | ||
| 329 | rows, err := s.db.Query(`SELECT id, name, display, status, created FROM users ORDER BY id`) | ||
| 267 | 330 | if err != nil { | |
| 268 | 331 | return nil, fmt.Errorf("identity: read roster: %w", err) | |
| 269 | 332 | } | |
| @@ -272,7 +335,7 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 272 | 335 | var a Account | |
| 273 | 336 | var status string | |
| 274 | 337 | var created int64 | |
| 275 | if err := rows.Scan(&a.ID, &a.Name, &status, &created); err != nil { | ||
| 338 | if err := rows.Scan(&a.ID, &a.Name, &a.Display, &status, &created); err != nil { | ||
| 276 | 339 | return nil, fmt.Errorf("identity: read roster: %w", err) | |
| 277 | 340 | } | |
| 278 | 341 | if !id.Valid(a.ID) { | |
| @@ -281,6 +344,9 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 281 | 344 | if err := ValidateName(a.Name); err != nil { | |
| 282 | 345 | return nil, fmt.Errorf("identity: account %s: %w", a.ID, err) | |
| 283 | 346 | } | |
| 347 | if err := ValidateDisplay(a.Display); err != nil { | ||
| 348 | return nil, fmt.Errorf("identity: account %s: %w", a.ID, err) | ||
| 349 | } | ||
| 284 | 350 | switch status { | |
| 285 | 351 | case "", "active": | |
| 286 | 352 | status = "active" | |
| @@ -296,33 +362,6 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 296 | 362 | return nil, err | |
| 297 | 363 | } | |
| 298 | 364 | ||
| 299 | // Every name ever held, so both the current and former names stay reserved. | ||
| 300 | names, err := s.db.Query(`SELECT name, user_id FROM user_names`) | ||
| 301 | if err != nil { | ||
| 302 | return nil, fmt.Errorf("identity: read roster: %w", err) | ||
| 303 | } | ||
| 304 | defer names.Close() | ||
| 305 | for names.Next() { | ||
| 306 | var name, owner string | ||
| 307 | if err := names.Scan(&name, &owner); err != nil { | ||
| 308 | return nil, fmt.Errorf("identity: read roster: %w", err) | ||
| 309 | } | ||
| 310 | a, ok := snap.ByID[owner] | ||
| 311 | if !ok { | ||
| 312 | return nil, fmt.Errorf("identity: the name %q points at unknown account %s", name, owner) | ||
| 313 | } | ||
| 314 | if other, taken := snap.Reserved[name]; taken && other != owner { | ||
| 315 | return nil, fmt.Errorf("identity: the name %q is claimed by both %s and %s", name, other, owner) | ||
| 316 | } | ||
| 317 | snap.Reserved[name] = owner | ||
| 318 | if name != a.Name { | ||
| 319 | a.Former = append(a.Former, name) | ||
| 320 | } | ||
| 321 | } | ||
| 322 | if err := names.Err(); err != nil { | ||
| 323 | return nil, err | ||
| 324 | } | ||
| 325 | |||
| 326 | 365 | keys, err := s.db.Query(`SELECT fingerprint, user_id, type, line, comment FROM user_keys ORDER BY fingerprint`) | |
| 327 | 366 | if err != nil { | |
| 328 | 367 | return nil, fmt.Errorf("identity: read roster: %w", err) | |
| @@ -371,18 +410,16 @@ func (s *Store) load() (*Snapshot, error) { | |||
| 371 | 410 | ||
| 372 | 411 | for _, a := range snap.ByID { | |
| 373 | 412 | a.Admin = snap.Admins[a.ID] | |
| 374 | sort.Strings(a.Former) | ||
| 375 | 413 | sort.Slice(a.Keys, func(i, j int) bool { return a.Keys[i].Fingerprint < a.Keys[j].Fingerprint }) | |
| 414 | // Every account, retired or not, keeps its name: the users row is the | ||
| 415 | // reservation, so a name can never be issued twice. | ||
| 416 | if other, ok := snap.ByName[a.Name]; ok { | ||
| 417 | return nil, fmt.Errorf("identity: two accounts are called %q (%s and %s)", a.Name, other.ID, a.ID) | ||
| 418 | } | ||
| 419 | snap.ByName[a.Name] = a | ||
| 376 | 420 | if a.Active() { | |
| 377 | if other, ok := snap.ByName[a.Name]; ok { | ||
| 378 | return nil, fmt.Errorf("identity: two active accounts are called %q (%s and %s)", a.Name, other.ID, a.ID) | ||
| 379 | } | ||
| 380 | snap.ByName[a.Name] = a | ||
| 381 | 421 | snap.Count++ | |
| 382 | 422 | } | |
| 383 | if _, ok := snap.Reserved[a.Name]; !ok { | ||
| 384 | return nil, fmt.Errorf("identity: account %s is called %q but reserves nothing", a.ID, a.Name) | ||
| 385 | } | ||
| 386 | 423 | } | |
| 387 | 424 | return snap, nil | |
| 388 | 425 | } | |
| @@ -450,17 +487,19 @@ func (s *Store) Lookup(fingerprint string) (accountID, name string, ok bool) { | |||
| 450 | 487 | return a.ID, a.Name, true | |
| 451 | 488 | } | |
| 452 | 489 | ||
| 453 | // ResolveID maps a name to an active account id. | ||
| 490 | // ResolveID maps an active name to its account id. A retired name resolves to | ||
| 491 | // nothing: the account is still known, but it is not a principal any more. | ||
| 454 | 492 | func (s *Store) ResolveID(name string) (string, bool) { | |
| 455 | 493 | a, ok := s.Snapshot().ByName[name] | |
| 456 | if !ok { | ||
| 494 | if !ok || !a.Active() { | ||
| 457 | 495 | return "", false | |
| 458 | 496 | } | |
| 459 | 497 | return a.ID, true | |
| 460 | 498 | } | |
| 461 | 499 | ||
| 462 | // Name returns the display name for an account id, or the id itself when the | ||
| 463 | // account is unknown. | ||
| 500 | // Name returns the immutable name for an account id, or the id itself when the | ||
| 501 | // account is unknown. This is the identifier that may be written into a ref or a | ||
| 502 | // metadata file. | ||
| 464 | 503 | func (s *Store) Name(accountID string) string { | |
| 465 | 504 | if a, ok := s.Snapshot().ByID[accountID]; ok { | |
| 466 | 505 | return a.Name | |
| @@ -468,7 +507,17 @@ func (s *Store) Name(accountID string) string { | |||
| 468 | 507 | return accountID | |
| 469 | 508 | } | |
| 470 | 509 | ||
| 471 | // Find resolves an id, an active name, a former name or a retired name. | ||
| 510 | // Label returns the human-friendly form for an account id: the display name when | ||
| 511 | // one is set, the immutable name otherwise, and the id when the account is | ||
| 512 | // unknown. Nothing is authorized by the result, so it is only used for output. | ||
| 513 | func (s *Store) Label(accountID string) string { | ||
| 514 | if a, ok := s.Snapshot().ByID[accountID]; ok { | ||
| 515 | return a.Label() | ||
| 516 | } | ||
| 517 | return accountID | ||
| 518 | } | ||
| 519 | |||
| 520 | // Find resolves an id or a name, including the name of a retired account. | ||
| 472 | 521 | func (s *Store) Find(idOrName string) (*Account, error) { | |
| 473 | 522 | snap := s.Snapshot() | |
| 474 | 523 | if a, ok := snap.ByID[idOrName]; ok { | |
| @@ -477,11 +526,6 @@ func (s *Store) Find(idOrName string) (*Account, error) { | |||
| 477 | 526 | if a, ok := snap.ByName[idOrName]; ok { | |
| 478 | 527 | return a, nil | |
| 479 | 528 | } | |
| 480 | if owner, ok := snap.Reserved[idOrName]; ok { | ||
| 481 | if a, ok := snap.ByID[owner]; ok { | ||
| 482 | return a, nil | ||
| 483 | } | ||
| 484 | } | ||
| 485 | 529 | return nil, fmt.Errorf("identity: no account %q", idOrName) | |
| 486 | 530 | } | |
| 487 | 531 | ||
| @@ -539,16 +583,20 @@ func (s *Store) mutate(action, actor, account, detail string, fn func(tx *sql.Tx | |||
| 539 | 583 | return nil | |
| 540 | 584 | } | |
| 541 | 585 | ||
| 542 | // Create adds an account with a fresh id and its first SSH key. The account, | ||
| 543 | // the name reservation, and the key are written in one transaction, so a name | ||
| 544 | // is never claimed by an account that cannot authenticate. | ||
| 586 | // Create adds an account with a fresh id, a fixed name, an optional display name | ||
| 587 | // and its first SSH key. The account, the name and the key are written in one | ||
| 588 | // transaction, so a name is never claimed by an account that cannot authenticate. | ||
| 545 | 589 | // | |
| 546 | 590 | // An account that will use a passkey instead is created with CreateWithInvite; | |
| 547 | 591 | // the invite is the credential-shaped way in. | |
| 548 | func (s *Store) Create(name, authorizedKey, actor string) (*Account, error) { | ||
| 592 | func (s *Store) Create(name, display, authorizedKey, actor string) (*Account, error) { | ||
| 549 | 593 | if err := ValidateName(name); err != nil { | |
| 550 | 594 | return nil, err | |
| 551 | 595 | } | |
| 596 | display = strings.TrimSpace(display) | ||
| 597 | if err := ValidateDisplay(display); err != nil { | ||
| 598 | return nil, err | ||
| 599 | } | ||
| 552 | 600 | key, err := parseKey(authorizedKey) | |
| 553 | 601 | if err != nil { | |
| 554 | 602 | return nil, err | |
| @@ -559,7 +607,7 @@ func (s *Store) Create(name, authorizedKey, actor string) (*Account, error) { | |||
| 559 | 607 | return nil, err | |
| 560 | 608 | } | |
| 561 | 609 | err = s.mutate("identity.create", actor, accountID, name+" "+key.Fingerprint, func(tx *sql.Tx) error { | |
| 562 | return createAccount(tx, name, accountID, now, &key, nil) | ||
| 610 | return createAccount(tx, name, display, accountID, now, &key, nil) | ||
| 563 | 611 | }) | |
| 564 | 612 | if err != nil { | |
| 565 | 613 | return nil, err | |
| @@ -575,11 +623,11 @@ type pendingInvite struct { | |||
| 575 | 623 | createdBy string | |
| 576 | 624 | } | |
| 577 | 625 | ||
| 578 | func createAccount(tx *sql.Tx, name, accountID string, now time.Time, key *Key, invite *pendingInvite) error { | ||
| 579 | if owner, err := reservedBy(tx, name); err != nil { | ||
| 626 | func createAccount(tx *sql.Tx, name, display, accountID string, now time.Time, key *Key, invite *pendingInvite) error { | ||
| 627 | if owner, err := nameOwner(tx, name); err != nil { | ||
| 580 | 628 | return err | |
| 581 | 629 | } else if owner != "" { | |
| 582 | return fmt.Errorf("identity: the name %q belongs to %s and cannot be reused; a retired or former name is never handed out twice", name, owner) | ||
| 630 | return fmt.Errorf("identity: the name %q belongs to %s and cannot be reused; a name is never handed out twice", name, owner) | ||
| 583 | 631 | } | |
| 584 | 632 | if key != nil { | |
| 585 | 633 | if owner, err := keyOwner(tx, key.Fingerprint); err != nil { | |
| @@ -588,11 +636,8 @@ func createAccount(tx *sql.Tx, name, accountID string, now time.Time, key *Key, | |||
| 588 | 636 | return fmt.Errorf("identity: that key is already registered to %s", owner) | |
| 589 | 637 | } | |
| 590 | 638 | } | |
| 591 | if _, err := tx.Exec(`INSERT INTO users(id, name, status, created) VALUES(?,?, 'active', ?)`, | ||
| 592 | accountID, name, now.Unix()); err != nil { | ||
| 593 | return fmt.Errorf("identity: create %s: %w", name, err) | ||
| 594 | } | ||
| 595 | if _, err := tx.Exec(`INSERT INTO user_names(name, user_id) VALUES(?,?)`, name, accountID); err != nil { | ||
| 639 | if _, err := tx.Exec(`INSERT INTO users(id, name, display, status, created) VALUES(?,?,?, 'active', ?)`, | ||
| 640 | accountID, name, display, now.Unix()); err != nil { | ||
| 596 | 641 | return fmt.Errorf("identity: create %s: %w", name, err) | |
| 597 | 642 | } | |
| 598 | 643 | if key != nil { | |
| @@ -615,31 +660,22 @@ func insertInvite(tx *sql.Tx, accountID string, invite *pendingInvite, now time. | |||
| 615 | 660 | return nil | |
| 616 | 661 | } | |
| 617 | 662 | ||
| 618 | // Rename changes the name on an account. The old name stays reserved forever, so | ||
| 619 | // nobody can inherit it later. | ||
| 620 | func (s *Store) Rename(idOrName, newName, actor string) (*Account, error) { | ||
| 621 | if err := ValidateName(newName); err != nil { | ||
| 663 | // SetDisplay replaces the human-friendly label shown next to an account's fixed | ||
| 664 | // name. An empty display clears it and the name is shown instead. The name is | ||
| 665 | // not touched: it is what refs and metadata files were written with. | ||
| 666 | func (s *Store) SetDisplay(idOrName, display, actor string) (*Account, error) { | ||
| 667 | display = strings.TrimSpace(display) | ||
| 668 | if err := ValidateDisplay(display); err != nil { | ||
| 622 | 669 | return nil, err | |
| 623 | 670 | } | |
| 624 | 671 | var accountID string | |
| 625 | err := s.mutate("identity.rename", actor, idOrName, newName, func(tx *sql.Tx) error { | ||
| 672 | err := s.mutate("identity.display", actor, idOrName, display, func(tx *sql.Tx) error { | ||
| 626 | 673 | a, err := findTx(tx, idOrName) | |
| 627 | 674 | if err != nil { | |
| 628 | 675 | return err | |
| 629 | 676 | } | |
| 630 | if a.Name == newName { | ||
| 631 | return fmt.Errorf("identity: %s is already called %q", a.ID, newName) | ||
| 632 | } | ||
| 633 | if owner, err := reservedBy(tx, newName); err != nil { | ||
| 634 | return err | ||
| 635 | } else if owner != "" && owner != a.ID { | ||
| 636 | return fmt.Errorf("identity: the name %q belongs to %s and cannot be reused", newName, owner) | ||
| 637 | } | ||
| 638 | if _, err := tx.Exec(`UPDATE users SET name=? WHERE id=?`, newName, a.ID); err != nil { | ||
| 639 | return fmt.Errorf("identity: rename %s: %w", a.ID, err) | ||
| 640 | } | ||
| 641 | if _, err := tx.Exec(`INSERT OR IGNORE INTO user_names(name, user_id) VALUES(?,?)`, newName, a.ID); err != nil { | ||
| 642 | return fmt.Errorf("identity: rename %s: %w", a.ID, err) | ||
| 677 | if _, err := tx.Exec(`UPDATE users SET display=? WHERE id=?`, display, a.ID); err != nil { | ||
| 678 | return fmt.Errorf("identity: display %s: %w", a.ID, err) | ||
| 643 | 679 | } | |
| 644 | 680 | accountID = a.ID | |
| 645 | 681 | return nil | |
| @@ -800,10 +836,12 @@ func (s *Store) RemoveKey(idOrName, fingerprint, actor string) error { | |||
| 800 | 836 | }) | |
| 801 | 837 | } | |
| 802 | 838 | ||
| 803 | // reservedBy returns the account id holding name, or "" when the name is unused. | ||
| 804 | func reservedBy(tx *sql.Tx, name string) (string, error) { | ||
| 839 | // nameOwner returns the account id holding name, or "" when the name is unused. | ||
| 840 | // Retired accounts count: their row is the reservation that stops a name being | ||
| 841 | // reissued. | ||
| 842 | func nameOwner(tx *sql.Tx, name string) (string, error) { | ||
| 805 | 843 | var owner string | |
| 806 | err := tx.QueryRow(`SELECT user_id FROM user_names WHERE name=?`, name).Scan(&owner) | ||
| 844 | err := tx.QueryRow(`SELECT id FROM users WHERE name=?`, name).Scan(&owner) | ||
| 807 | 845 | if errors.Is(err, sql.ErrNoRows) { | |
| 808 | 846 | return "", nil | |
| 809 | 847 | } | |