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.
Add browser auth, contributor access, and gated CI
6c84d387e5f608360e30484a1a5b1cff41076850 · Keenan Nemetz · 16d ago · parent c36d5073
Add browser auth, contributor access, and gated CI Browser sign-in now uses passkeys. Enrollment links are one-time, sessions are hashed in users.db, and issue/comment/approval writes use the session account. HTTPS public_url is required for WebAuthn. Add the contributor role between read and maintain. Contributors can discuss and open change requests, and may push only refs/heads/contrib/<account-id>/. Contributor CI is held per commit until a maintainer allows the exact tip from the repository page. Rename the write role to maintain. write remains accepted as the historical spelling, and access.Write remains as a Go alias so existing callers and ACL rows keep working. Update the web, CLI, queue schema, and guides for the new flows, and cover the role, namespace, auth, and CI-gating paths in tests.
forge/identity/identity.go +111−29
| @@ -4,23 +4,30 @@ | |||
| 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 | ||
| 10 | // user_keys one row per authorized key, unique by fingerprint | ||
| 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 | ||
| 10 | // user_keys one row per authorized SSH key, unique by fingerprint | ||
| 11 | // user_credentials one row per passkey: the browser credential's public half | ||
| 12 | // user_invites one-time enrollment tokens, stored only as hashes | ||
| 13 | // sessions browser sessions, stored only as token hashes | ||
| 11 | 14 | // | |
| 12 | 15 | // An account has a stable id and a mutable name. The id is what ACL entries and | |
| 13 | 16 | // approvals refer to, so renaming somebody touches no repository state, and a | |
| 14 | 17 | // retired name is never handed to a second account — which is what stops a new | |
| 15 | 18 | // user from inheriting an old one's access and approvals. | |
| 16 | 19 | // | |
| 17 | // The keys anchor the account: the SSH handshake resolves a presented key to an | ||
| 18 | // account id, and every authorization decision downstream uses that id rather | ||
| 19 | // than a name somebody chose to type. | ||
| 20 | // The credentials anchor the account. An SSH key is resolved at the SSH | ||
| 21 | // handshake; a passkey is resolved in the browser login ceremony. Both yield the | ||
| 22 | // same account id, and every authorization decision downstream uses that id | ||
| 23 | // rather than a name somebody chose to type. | ||
| 20 | 24 | // | |
| 21 | // Nothing stored here is secret (public keys only), so the database file can be | ||
| 22 | // copied as a backup. Copy it with Backup rather than cp: the database runs in | ||
| 23 | // WAL mode, so the newest committed rows may still be in the -wal file. | ||
| 25 | // Nothing stored here is a usable secret: public keys and public credential | ||
| 26 | // halves, plus hashes of session and invite tokens. The database file can still | ||
| 27 | // be copied as a backup, but it carries authentication state, so restoring an | ||
| 28 | // old copy should be followed by `vole admin user revoke-sessions`. Copy it with | ||
| 29 | // Backup rather than cp: the database runs in WAL mode, so the newest committed | ||
| 30 | // rows may still be in the -wal file. | ||
| 24 | 31 | package identity | |
| 25 | 32 | ||
| 26 | 33 | import ( | |
| @@ -127,6 +134,42 @@ CREATE INDEX IF NOT EXISTS user_keys_owner ON user_keys(user_id); | |||
| 127 | 134 | CREATE TABLE IF NOT EXISTS instance_admins ( | |
| 128 | 135 | user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE | |
| 129 | 136 | ); | |
| 137 | CREATE TABLE IF NOT EXISTS user_credentials ( | ||
| 138 | credential_id TEXT PRIMARY KEY, | ||
| 139 | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 140 | public_key BLOB NOT NULL, | ||
| 141 | attestation_type TEXT NOT NULL DEFAULT '', | ||
| 142 | transports TEXT NOT NULL DEFAULT '[]', | ||
| 143 | flags INTEGER NOT NULL DEFAULT 0, | ||
| 144 | aaguid BLOB, | ||
| 145 | sign_count INTEGER NOT NULL DEFAULT 0, | ||
| 146 | clone_warning INTEGER NOT NULL DEFAULT 0, | ||
| 147 | attachment TEXT NOT NULL DEFAULT '', | ||
| 148 | label TEXT NOT NULL DEFAULT '', | ||
| 149 | created INTEGER NOT NULL, | ||
| 150 | last_used INTEGER NOT NULL DEFAULT 0 | ||
| 151 | ); | ||
| 152 | CREATE INDEX IF NOT EXISTS user_credentials_owner ON user_credentials(user_id); | ||
| 153 | CREATE TABLE IF NOT EXISTS user_invites ( | ||
| 154 | token_hash TEXT PRIMARY KEY, | ||
| 155 | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 156 | created INTEGER NOT NULL, | ||
| 157 | expires INTEGER NOT NULL, | ||
| 158 | used_at INTEGER, | ||
| 159 | created_by TEXT NOT NULL DEFAULT '' | ||
| 160 | ); | ||
| 161 | CREATE INDEX IF NOT EXISTS user_invites_owner ON user_invites(user_id); | ||
| 162 | CREATE TABLE IF NOT EXISTS sessions ( | ||
| 163 | token_hash TEXT PRIMARY KEY, | ||
| 164 | account_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 165 | credential_id TEXT REFERENCES user_credentials(credential_id) ON DELETE CASCADE, | ||
| 166 | created INTEGER NOT NULL, | ||
| 167 | last_seen INTEGER NOT NULL, | ||
| 168 | expires INTEGER NOT NULL, | ||
| 169 | user_agent TEXT NOT NULL DEFAULT '' | ||
| 170 | ); | ||
| 171 | CREATE INDEX IF NOT EXISTS sessions_account ON sessions(account_id); | ||
| 172 | CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires); | ||
| 130 | 173 | ` | |
| 131 | 174 | ||
| 132 | 175 | func openDB(path string) (*sql.DB, error) { | |
| @@ -496,9 +539,12 @@ func (s *Store) mutate(action, actor, account, detail string, fn func(tx *sql.Tx | |||
| 496 | 539 | return nil | |
| 497 | 540 | } | |
| 498 | 541 | ||
| 499 | // Create adds an account with a fresh id and its first key. The account, the | ||
| 500 | // name reservation, and the key are written in one transaction, so a name is | ||
| 501 | // never claimed by an account that cannot authenticate. | ||
| 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. | ||
| 545 | // | ||
| 546 | // An account that will use a passkey instead is created with CreateWithInvite; | ||
| 547 | // the invite is the credential-shaped way in. | ||
| 502 | 548 | func (s *Store) Create(name, authorizedKey, actor string) (*Account, error) { | |
| 503 | 549 | if err := ValidateName(name); err != nil { | |
| 504 | 550 | return nil, err | |
| @@ -513,33 +559,60 @@ func (s *Store) Create(name, authorizedKey, actor string) (*Account, error) { | |||
| 513 | 559 | return nil, err | |
| 514 | 560 | } | |
| 515 | 561 | err = s.mutate("identity.create", actor, accountID, name+" "+key.Fingerprint, func(tx *sql.Tx) error { | |
| 516 | if owner, err := reservedBy(tx, name); err != nil { | ||
| 517 | return err | ||
| 518 | } else if owner != "" { | ||
| 519 | 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) | ||
| 520 | } | ||
| 562 | return createAccount(tx, name, accountID, now, &key, nil) | ||
| 563 | }) | ||
| 564 | if err != nil { | ||
| 565 | return nil, err | ||
| 566 | } | ||
| 567 | return s.Find(accountID) | ||
| 568 | } | ||
| 569 | |||
| 570 | // pendingInvite is the row half of an enrollment link. The plaintext token is | ||
| 571 | // returned to the caller once and never stored. | ||
| 572 | type pendingInvite struct { | ||
| 573 | tokenHash string | ||
| 574 | expires time.Time | ||
| 575 | createdBy string | ||
| 576 | } | ||
| 577 | |||
| 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 { | ||
| 580 | return err | ||
| 581 | } 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) | ||
| 583 | } | ||
| 584 | if key != nil { | ||
| 521 | 585 | if owner, err := keyOwner(tx, key.Fingerprint); err != nil { | |
| 522 | 586 | return err | |
| 523 | 587 | } else if owner != "" { | |
| 524 | 588 | return fmt.Errorf("identity: that key is already registered to %s", owner) | |
| 525 | 589 | } | |
| 526 | if _, err := tx.Exec(`INSERT INTO users(id, name, status, created) VALUES(?,?, 'active', ?)`, | ||
| 527 | accountID, name, now.Unix()); err != nil { | ||
| 528 | return fmt.Errorf("identity: create %s: %w", name, err) | ||
| 529 | } | ||
| 530 | if _, err := tx.Exec(`INSERT INTO user_names(name, user_id) VALUES(?,?)`, name, accountID); err != nil { | ||
| 531 | return fmt.Errorf("identity: create %s: %w", name, err) | ||
| 532 | } | ||
| 590 | } | ||
| 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 { | ||
| 596 | return fmt.Errorf("identity: create %s: %w", name, err) | ||
| 597 | } | ||
| 598 | if key != nil { | ||
| 533 | 599 | if _, err := tx.Exec(`INSERT INTO user_keys(fingerprint, user_id, type, line, comment) VALUES(?,?,?,?,?)`, | |
| 534 | 600 | key.Fingerprint, accountID, key.Type, key.Line, key.Comment); err != nil { | |
| 535 | 601 | return fmt.Errorf("identity: create %s: %w", name, err) | |
| 536 | 602 | } | |
| 603 | } | ||
| 604 | return insertInvite(tx, accountID, invite, now) | ||
| 605 | } | ||
| 606 | |||
| 607 | func insertInvite(tx *sql.Tx, accountID string, invite *pendingInvite, now time.Time) error { | ||
| 608 | if invite == nil { | ||
| 537 | 609 | return nil | |
| 538 | }) | ||
| 539 | if err != nil { | ||
| 540 | return nil, err | ||
| 541 | 610 | } | |
| 542 | return s.Find(accountID) | ||
| 611 | if _, err := tx.Exec(`INSERT INTO user_invites(token_hash, user_id, created, expires, created_by) VALUES(?,?,?,?,?)`, | ||
| 612 | invite.tokenHash, accountID, now.Unix(), invite.expires.Unix(), invite.createdBy); err != nil { | ||
| 613 | return fmt.Errorf("identity: create invite: %w", err) | ||
| 614 | } | ||
| 615 | return nil | ||
| 543 | 616 | } | |
| 544 | 617 | ||
| 545 | 618 | // Rename changes the name on an account. The old name stays reserved forever, so | |
| @@ -604,6 +677,15 @@ func (s *Store) Retire(idOrName, actor string) (*Account, error) { | |||
| 604 | 677 | if _, err := tx.Exec(`UPDATE users SET status='retired' WHERE id=?`, a.ID); err != nil { | |
| 605 | 678 | return fmt.Errorf("identity: retire %s: %w", a.ID, err) | |
| 606 | 679 | } | |
| 680 | // Retiring is the one act that must end a browser session immediately. | ||
| 681 | // The session lookup checks status too, but deleting here also clears the | ||
| 682 | // rows and any outstanding enrollment links. | ||
| 683 | if _, err := tx.Exec(`DELETE FROM sessions WHERE account_id=?`, a.ID); err != nil { | ||
| 684 | return fmt.Errorf("identity: retire %s: %w", a.ID, err) | ||
| 685 | } | ||
| 686 | if _, err := tx.Exec(`DELETE FROM user_invites WHERE user_id=?`, a.ID); err != nil { | ||
| 687 | return fmt.Errorf("identity: retire %s: %w", a.ID, err) | ||
| 688 | } | ||
| 607 | 689 | accountID = a.ID | |
| 608 | 690 | return nil | |
| 609 | 691 | }) | |