vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Identify accounts by their registered name
62576fa8157d431dcf2d360f3ebe481de77bda03 · Keenan Nemetz · 14d ago · parent 642e5994
Identify accounts by their registered name Drop the internal account id and display name. Users, keys, passkeys, invites, sessions, ACL entries, approvals, locks and CI records now all use the registered name as the single identity. The roster migrates in place: old id-keyed tables are rebuilt around the name, and access-rule/lock references are rewritten through the same mapping. Display-name editing and whoami --id are removed.
forge/session/session.go +27−27
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | // | |
| 3 | 3 | // A session is an opaque random token held in a cookie. The database stores only | |
| 4 | 4 | // the token's hash, so a copy of users.db cannot be replayed as a login. Sessions | |
| 5 | // carry the account id and the passkey that created them: revoking that passkey | ||
| 5 | // carry the account name and the passkey that created them: revoking that passkey | ||
| 6 | 6 | // deletes exactly the sessions it authorized, and retiring the account deletes | |
| 7 | 7 | // all of them. | |
| 8 | 8 | // | |
| @@ -59,7 +59,7 @@ type Session struct { | |||
| 59 | 59 | // whenever the session does. It is safe to put in a form; the cookie stays | |
| 60 | 60 | // HttpOnly. | |
| 61 | 61 | CSRF string | |
| 62 | AccountID string | ||
| 62 | Name string | ||
| 63 | 63 | CredentialID []byte | |
| 64 | 64 | Created time.Time | |
| 65 | 65 | LastSeen time.Time | |
| @@ -108,20 +108,20 @@ func (s *Store) Path() string { return s.path } | |||
| 108 | 108 | // Create issues a fresh session token for an active account. credentialID may | |
| 109 | 109 | // be nil for a session that was not created by a passkey (a future SSH-assisted | |
| 110 | 110 | // login, for example); a non-nil id ties the session's life to that passkey. | |
| 111 | func (s *Store) Create(accountID string, credentialID []byte, userAgent string) (*Session, error) { | ||
| 112 | if accountID == "" { | ||
| 111 | func (s *Store) Create(name string, credentialID []byte, userAgent string) (*Session, error) { | ||
| 112 | if name == "" { | ||
| 113 | 113 | return nil, ErrNoAccount | |
| 114 | 114 | } | |
| 115 | 115 | if len(credentialID) > 0 { | |
| 116 | 116 | var owner string | |
| 117 | err := s.db.QueryRow(`SELECT user_id FROM user_credentials WHERE credential_id=?`, credentialIDDB(credentialID)).Scan(&owner) | ||
| 117 | err := s.db.QueryRow(`SELECT user_name FROM user_credentials WHERE credential_id=?`, credentialIDDB(credentialID)).Scan(&owner) | ||
| 118 | 118 | if errors.Is(err, sql.ErrNoRows) { | |
| 119 | 119 | return nil, ErrCredentialMismatch | |
| 120 | 120 | } | |
| 121 | 121 | if err != nil { | |
| 122 | 122 | return nil, fmt.Errorf("session: read passkey: %w", err) | |
| 123 | 123 | } | |
| 124 | if owner != accountID { | ||
| 124 | if owner != name { | ||
| 125 | 125 | return nil, ErrCredentialMismatch | |
| 126 | 126 | } | |
| 127 | 127 | } | |
| @@ -134,7 +134,7 @@ func (s *Store) Create(accountID string, credentialID []byte, userAgent string) | |||
| 134 | 134 | sess := &Session{ | |
| 135 | 135 | Token: token, | |
| 136 | 136 | CSRF: csrfToken(token), | |
| 137 | AccountID: accountID, | ||
| 137 | Name: name, | ||
| 138 | 138 | CredentialID: credentialID, | |
| 139 | 139 | Created: now, | |
| 140 | 140 | LastSeen: now, | |
| @@ -144,10 +144,10 @@ func (s *Store) Create(accountID string, credentialID []byte, userAgent string) | |||
| 144 | 144 | // INSERT ... SELECT checks the account's status in the same statement that | |
| 145 | 145 | // writes the row. A retire racing this create cannot leave a usable session | |
| 146 | 146 | // behind, and Lookup checks status again on every request anyway. | |
| 147 | res, err := s.db.Exec(`INSERT INTO sessions(token_hash, account_id, credential_id, created, last_seen, expires, user_agent) | ||
| 148 | SELECT ?, id, ?, ?, ?, ?, ? FROM users WHERE id=? AND status='active'`, | ||
| 147 | res, err := s.db.Exec(`INSERT INTO sessions(token_hash, user_name, credential_id, created, last_seen, expires, user_agent) | ||
| 148 | SELECT ?, name, ?, ?, ?, ?, ? FROM users WHERE name=? AND status='active'`, | ||
| 149 | 149 | hashToken(token), nullableCredentialID(credentialID), | |
| 150 | now.Unix(), now.Unix(), expires.Unix(), userAgent, accountID) | ||
| 150 | now.Unix(), now.Unix(), expires.Unix(), userAgent, name) | ||
| 151 | 151 | if err != nil { | |
| 152 | 152 | return nil, fmt.Errorf("session: create: %w", err) | |
| 153 | 153 | } | |
| @@ -159,7 +159,7 @@ SELECT ?, id, ?, ?, ?, ?, ? FROM users WHERE id=? AND status='active'`, | |||
| 159 | 159 | // Expired rows are cheap to remove and would otherwise accumulate forever on | |
| 160 | 160 | // an instance nobody logs out of. | |
| 161 | 161 | _, _ = s.DeleteExpired(now) | |
| 162 | s.audit("session.create", accountID, credentialDetail(credentialID)) | ||
| 162 | s.audit("session.create", name, credentialDetail(credentialID)) | ||
| 163 | 163 | return sess, nil | |
| 164 | 164 | } | |
| 165 | 165 | ||
| @@ -181,10 +181,10 @@ func (s *Store) Lookup(token string) (*Session, error) { | |||
| 181 | 181 | expires int64 | |
| 182 | 182 | ) | |
| 183 | 183 | err := s.db.QueryRow(` | |
| 184 | SELECT s.account_id, s.credential_id, s.created, s.last_seen, s.expires, s.user_agent, u.status | ||
| 185 | FROM sessions s JOIN users u ON u.id = s.account_id | ||
| 184 | SELECT s.user_name, s.credential_id, s.created, s.last_seen, s.expires, s.user_agent, u.status | ||
| 185 | FROM sessions s JOIN users u ON u.name = s.user_name | ||
| 186 | 186 | WHERE s.token_hash = ?`, hashToken(token)). | |
| 187 | Scan(&sess.AccountID, &credentialID, &created, &lastSeen, &expires, &sess.UserAgent, &status) | ||
| 187 | Scan(&sess.Name, &credentialID, &created, &lastSeen, &expires, &sess.UserAgent, &status) | ||
| 188 | 188 | if errors.Is(err, sql.ErrNoRows) { | |
| 189 | 189 | return nil, ErrNoSession | |
| 190 | 190 | } | |
| @@ -221,8 +221,8 @@ func (s *Store) Revoke(token string) error { | |||
| 221 | 221 | if token == "" { | |
| 222 | 222 | return nil | |
| 223 | 223 | } | |
| 224 | var accountID string | ||
| 225 | err := s.db.QueryRow(`SELECT account_id FROM sessions WHERE token_hash=?`, hashToken(token)).Scan(&accountID) | ||
| 224 | var name string | ||
| 225 | err := s.db.QueryRow(`SELECT user_name FROM sessions WHERE token_hash=?`, hashToken(token)).Scan(&name) | ||
| 226 | 226 | if errors.Is(err, sql.ErrNoRows) { | |
| 227 | 227 | return nil | |
| 228 | 228 | } | |
| @@ -232,20 +232,20 @@ func (s *Store) Revoke(token string) error { | |||
| 232 | 232 | if _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash=?`, hashToken(token)); err != nil { | |
| 233 | 233 | return fmt.Errorf("session: revoke: %w", err) | |
| 234 | 234 | } | |
| 235 | s.audit("session.revoke", accountID, "") | ||
| 235 | s.audit("session.revoke", name, "") | ||
| 236 | 236 | return nil | |
| 237 | 237 | } | |
| 238 | 238 | ||
| 239 | 239 | // RevokeAccount deletes every session for an account, for the operator command | |
| 240 | 240 | // and as a backstop after restoring an old database. | |
| 241 | func (s *Store) RevokeAccount(accountID string) (int64, error) { | ||
| 242 | res, err := s.db.Exec(`DELETE FROM sessions WHERE account_id=?`, accountID) | ||
| 241 | func (s *Store) RevokeAccount(name string) (int64, error) { | ||
| 242 | res, err := s.db.Exec(`DELETE FROM sessions WHERE user_name=?`, name) | ||
| 243 | 243 | if err != nil { | |
| 244 | 244 | return 0, fmt.Errorf("session: revoke account: %w", err) | |
| 245 | 245 | } | |
| 246 | 246 | n, _ := res.RowsAffected() | |
| 247 | 247 | if n > 0 { | |
| 248 | s.audit("session.revoke-account", accountID, fmt.Sprintf("%d session(s)", n)) | ||
| 248 | s.audit("session.revoke-account", name, fmt.Sprintf("%d session(s)", n)) | ||
| 249 | 249 | } | |
| 250 | 250 | return n, nil | |
| 251 | 251 | } | |
| @@ -271,25 +271,25 @@ func (s *Store) RevokeCredential(credentialID []byte) (int64, error) { | |||
| 271 | 271 | if len(credentialID) == 0 { | |
| 272 | 272 | return 0, nil | |
| 273 | 273 | } | |
| 274 | var accountID string | ||
| 275 | _ = s.db.QueryRow(`SELECT user_id FROM user_credentials WHERE credential_id=?`, credentialIDDB(credentialID)).Scan(&accountID) | ||
| 274 | var name string | ||
| 275 | _ = s.db.QueryRow(`SELECT user_name FROM user_credentials WHERE credential_id=?`, credentialIDDB(credentialID)).Scan(&name) | ||
| 276 | 276 | res, err := s.db.Exec(`DELETE FROM sessions WHERE credential_id=?`, credentialIDDB(credentialID)) | |
| 277 | 277 | if err != nil { | |
| 278 | 278 | return 0, fmt.Errorf("session: revoke passkey sessions: %w", err) | |
| 279 | 279 | } | |
| 280 | 280 | n, _ := res.RowsAffected() | |
| 281 | 281 | if n > 0 { | |
| 282 | s.audit("session.revoke-credential", accountID, credentialDetail(credentialID)) | ||
| 282 | s.audit("session.revoke-credential", name, credentialDetail(credentialID)) | ||
| 283 | 283 | } | |
| 284 | 284 | return n, nil | |
| 285 | 285 | } | |
| 286 | 286 | ||
| 287 | 287 | // List returns an account's sessions, newest first. Expired rows are removed by | |
| 288 | 288 | // Create and Lookup; this is an operator view, not an authorization check. | |
| 289 | func (s *Store) List(accountID string) ([]Session, error) { | ||
| 289 | func (s *Store) List(name string) ([]Session, error) { | ||
| 290 | 290 | rows, err := s.db.Query(` | |
| 291 | SELECT account_id, credential_id, created, last_seen, expires, user_agent | ||
| 292 | FROM sessions WHERE account_id=? ORDER BY created DESC`, accountID) | ||
| 291 | SELECT user_name, credential_id, created, last_seen, expires, user_agent | ||
| 292 | FROM sessions WHERE user_name=? ORDER BY created DESC`, name) | ||
| 293 | 293 | if err != nil { | |
| 294 | 294 | return nil, fmt.Errorf("session: list: %w", err) | |
| 295 | 295 | } | |
| @@ -303,7 +303,7 @@ FROM sessions WHERE account_id=? ORDER BY created DESC`, accountID) | |||
| 303 | 303 | lastSeen int64 | |
| 304 | 304 | expires int64 | |
| 305 | 305 | ) | |
| 306 | if err := rows.Scan(&sess.AccountID, &credentialID, &created, &lastSeen, &expires, &sess.UserAgent); err != nil { | ||
| 306 | if err := rows.Scan(&sess.Name, &credentialID, &created, &lastSeen, &expires, &sess.UserAgent); err != nil { | ||
| 307 | 307 | return nil, fmt.Errorf("session: list: %w", err) | |
| 308 | 308 | } | |
| 309 | 309 | sess.Created = time.Unix(created, 0).UTC() | |