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.

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 })