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/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()