Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }