OXYGENHUB
# f87a74fffc87 oxygenhub / crates / hub / src / db / accounts.rs
37.7 KiB · Rust · last changed Keys say when each was last used; Help explains AI identities (closes #286, closes #287) 15 days ago source blame history raw
1
//! Accounts, organisations, teams, collaborators, identities, tokens,
2
//! invitations, settings and the audit log (hub spec §5).
3
4
use super::{now_ms, Pool};
5
use sqlx::FromRow;
6
7
#[derive(Clone, Debug, FromRow)]
8
pub struct Account {
9
    pub id: i64,
10
    pub username: String,
11
    pub display_name: String,
12
    pub email: String,
13
    pub email_verified: bool,
14
    pub password_hash: String,
15
    pub session_salt: String,
16
    pub totp_secret: Option<String>,
17
    pub recovery_codes: String,
18
    pub is_admin: bool,
19
    pub banned: bool,
20
    /// Disabled by an admin: refused like a ban, and enabled again (0031).
21
    #[sqlx(default)]
22
    pub disabled: bool,
23
    pub created_ms: i64,
24
    pub last_seen_ms: i64,
25
    pub email_notifications: bool,
26
    pub unsubscribe_key: String,
27
    /// Shown on the profile page (migration 0008).
28
    #[sqlx(default)]
29
    pub bio: String,
30
    #[sqlx(default)]
31
    pub website: String,
32
    #[sqlx(default)]
33
    pub location: String,
34
    /// `png:<hash>` or `jpeg:<hash>` when a picture was uploaded.
35
    pub avatar: String,
36
    /// Accounts elsewhere (migration 0039, #105): a Bluesky handle, a
37
    /// Mastodon `user@instance`, a GitHub username; empty when not set.
38
    #[sqlx(default)]
39
    pub bluesky: String,
40
    #[sqlx(default)]
41
    pub mastodon: String,
42
    #[sqlx(default)]
43
    pub github: String,
44
}
45
46
impl Account {
47
    /// Whether the account may not sign in or act: banned or disabled.
48
    pub fn blocked(&self) -> bool {
49
        self.banned || self.disabled
50
    }
51
52
    pub fn name(&self) -> &str {
53
        if self.display_name.is_empty() {
54
            &self.username
55
        } else {
56
            &self.display_name
57
        }
58
    }
59
60
    pub fn totp_enabled(&self) -> bool {
61
        self.totp_secret.is_some()
62
    }
63
}
64
65
#[derive(Clone, Debug, FromRow)]
66
pub struct Organisation {
67
    pub id: i64,
68
    pub name: String,
69
    pub display_name: String,
70
    pub description: String,
71
    pub default_role: String,
72
    pub created_ms: i64,
73
    /// `png:<hash>` or `jpeg:<hash>` when a picture was uploaded.
74
    pub avatar: String,
75
}
76
77
#[derive(Clone, Debug, FromRow)]
78
pub struct Team {
79
    pub id: i64,
80
    pub org_id: i64,
81
    pub name: String,
82
}
83
84
#[derive(Clone, Debug, FromRow)]
85
pub struct Token {
86
    pub id: i64,
87
    pub account_id: i64,
88
    pub name: String,
89
    pub hash: String,
90
    pub scopes: String,
91
    pub repos: String,
92
    pub created_ms: i64,
93
    pub expires_ms: Option<i64>,
94
    pub last_used_ms: Option<i64>,
95
    /// A renewing token's lifetime: each use extends it to now plus this.
96
    pub renew_ms: Option<i64>,
97
    /// An agent uses it: what is done with it is told to its owner
98
    /// (migration 0065, oxygenhub#183).
99
    #[sqlx(default)]
100
    pub agent: bool,
101
}
102
103
impl Token {
104
    pub fn scope_list(&self) -> Vec<&str> {
105
        self.scopes.split(',').filter(|s| !s.is_empty()).collect()
106
    }
107
108
    pub fn has_scope(&self, scope: &str) -> bool {
109
        self.scope_list().contains(&scope)
110
    }
111
112
    /// Whether the token may touch `owner/name`: every repository when the
113
    /// list is empty.
114
    pub fn covers(&self, slug: &str) -> bool {
115
        self.repos.is_empty() || self.repos.split(',').any(|r| r.trim().eq_ignore_ascii_case(slug))
116
    }
117
118
    pub fn expired(&self, now: i64) -> bool {
119
        self.expires_ms.is_some_and(|e| e < now)
120
    }
121
}
122
123
#[derive(Clone, Debug, FromRow)]
124
pub struct LinkedIdentity {
125
    pub key: String,
126
    pub account_id: i64,
127
    pub name: String,
128
    pub email: String,
129
    pub object: Vec<u8>,
130
    pub linked_ms: i64,
131
    /// When the hub last received something this key signed (#286).
132
    #[sqlx(default)]
133
    pub last_used_ms: Option<i64>,
134
}
135
136
impl LinkedIdentity {
137
    /// When the identity itself expires, if it says.
138
    pub fn expires_ms(&self) -> Option<i64> {
139
        oxy_core::identity::Identity::decode(&self.object).ok()?.expires.map(|e| e as i64)
140
    }
141
}
142
143
#[derive(Clone, Debug, FromRow)]
144
pub struct AuditRow {
145
    pub id: i64,
146
    pub at_ms: i64,
147
    pub actor: String,
148
    pub action: String,
149
    pub target: String,
150
    pub detail: String,
151
}
152
153
// ---------------------------------------------------------------- accounts
154
155
pub async fn account_by_name(pool: &Pool, username: &str) -> sqlx::Result<Option<Account>> {
156
    sqlx::query_as("SELECT * FROM accounts WHERE username = $1").bind(username).fetch_optional(pool).await
157
}
158
159
pub async fn account_by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<Account>> {
160
    sqlx::query_as("SELECT * FROM accounts WHERE id = $1").bind(id).fetch_optional(pool).await
161
}
162
163
pub async fn accounts(pool: &Pool) -> sqlx::Result<Vec<Account>> {
164
    sqlx::query_as("SELECT * FROM accounts ORDER BY username").fetch_all(pool).await
165
}
166
167
pub async fn account_count(pool: &Pool) -> sqlx::Result<i64> {
168
    sqlx::query_scalar("SELECT COUNT(*) FROM accounts").fetch_one(pool).await
169
}
170
171
pub async fn create_account(
172
    pool: &Pool,
173
    username: &str,
174
    email: &str,
175
    password_hash: &str,
176
    session_salt: &str,
177
    is_admin: bool,
178
) -> sqlx::Result<Account> {
179
    sqlx::query_as(
180
        "INSERT INTO accounts (username, email, password_hash, session_salt, is_admin, created_ms) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *",
181
    )
182
    .bind(username)
183
    .bind(email)
184
    .bind(password_hash)
185
    .bind(session_salt)
186
    .bind(is_admin)
187
    .bind(now_ms())
188
    .fetch_one(pool)
189
    .await
190
}
191
192
pub async fn set_profile(pool: &Pool, id: i64, display_name: &str, email: &str) -> sqlx::Result<()> {
193
    // A changed address is unverified until its own link is used.
194
    sqlx::query(
195
        "UPDATE accounts SET display_name = $1, email_verified = CASE WHEN email = $2 THEN email_verified ELSE FALSE END, email = $3 WHERE id = $4",
196
    )
197
    .bind(display_name)
198
    .bind(email)
199
    .bind(email)
200
    .bind(id)
201
    .execute(pool)
202
    .await?;
203
    Ok(())
204
}
205
206
/// The public profile fields.
207
/// The accounts elsewhere a profile shows (#105), already normalised.
208
pub async fn set_links(pool: &Pool, id: i64, bluesky: &str, mastodon: &str, github: &str) -> sqlx::Result<()> {
209
    sqlx::query("UPDATE accounts SET bluesky = $1, mastodon = $2, github = $3 WHERE id = $4")
210
        .bind(bluesky)
211
        .bind(mastodon)
212
        .bind(github)
213
        .bind(id)
214
        .execute(pool)
215
        .await?;
216
    Ok(())
217
}
218
219
pub async fn set_bio(pool: &Pool, id: i64, bio: &str, website: &str, location: &str) -> sqlx::Result<()> {
220
    sqlx::query("UPDATE accounts SET bio = $1, website = $2, location = $3 WHERE id = $4")
221
        .bind(bio)
222
        .bind(website)
223
        .bind(location)
224
        .bind(id)
225
        .execute(pool)
226
        .await?;
227
    Ok(())
228
}
229
230
pub async fn set_password(pool: &Pool, id: i64, password_hash: &str, session_salt: &str) -> sqlx::Result<()> {
231
    sqlx::query("UPDATE accounts SET password_hash = $1, session_salt = $2 WHERE id = $3")
232
        .bind(password_hash)
233
        .bind(session_salt)
234
        .bind(id)
235
        .execute(pool)
236
        .await?;
237
    Ok(())
238
}
239
240
pub async fn set_session_salt(pool: &Pool, id: i64, session_salt: &str) -> sqlx::Result<()> {
241
    sqlx::query("UPDATE accounts SET session_salt = $1 WHERE id = $2").bind(session_salt).bind(id).execute(pool).await?;
242
    Ok(())
243
}
244
245
pub async fn set_totp(pool: &Pool, id: i64, secret: Option<&str>, recovery_codes: &str) -> sqlx::Result<()> {
246
    sqlx::query("UPDATE accounts SET totp_secret = $1, recovery_codes = $2 WHERE id = $3")
247
        .bind(secret)
248
        .bind(recovery_codes)
249
        .bind(id)
250
        .execute(pool)
251
        .await?;
252
    Ok(())
253
}
254
255
pub async fn set_admin(pool: &Pool, id: i64, is_admin: bool) -> sqlx::Result<()> {
256
    sqlx::query("UPDATE accounts SET is_admin = $1 WHERE id = $2").bind(is_admin).bind(id).execute(pool).await?;
257
    Ok(())
258
}
259
260
pub async fn set_banned(pool: &Pool, id: i64, banned: bool) -> sqlx::Result<()> {
261
    sqlx::query("UPDATE accounts SET banned = $1 WHERE id = $2").bind(banned).bind(id).execute(pool).await?;
262
    Ok(())
263
}
264
265
pub async fn set_disabled(pool: &Pool, id: i64, disabled: bool) -> sqlx::Result<()> {
266
    sqlx::query("UPDATE accounts SET disabled = $1 WHERE id = $2").bind(disabled).bind(id).execute(pool).await?;
267
    Ok(())
268
}
269
270
/// Delete an account (oxygenhub#81): its row goes, and with it (by the
271
/// schema's cascades) its tokens, keys, sessions' salt, memberships, stars
272
/// and notifications; what it wrote keeps its author's name. The username
273
/// is retired so nobody can register it and pass for the account.
274
pub async fn delete_account(pool: &Pool, id: i64, username: &str) -> sqlx::Result<()> {
275
    let mut tx = super::begin_write(pool).await?;
276
    sqlx::query("INSERT INTO retired_usernames (username, retired_ms) VALUES ($1, $2) ON CONFLICT DO NOTHING")
277
        .bind(username)
278
        .bind(now_ms())
279
        .execute(&mut *tx)
280
        .await?;
281
    sqlx::query("DELETE FROM accounts WHERE id = $1").bind(id).execute(&mut *tx).await?;
282
    tx.commit().await
283
}
284
285
/// The organisations whose only owner is this account: deleting it would
286
/// leave them with nobody to manage them.
287
pub async fn orgs_owned_only_by(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<String>> {
288
    sqlx::query_scalar(
289
        "SELECT o.name FROM organisations o JOIN org_members m ON m.org_id = o.id \
290
         WHERE m.account_id = $1 AND m.role = 'owner' \
291
           AND (SELECT COUNT(*) FROM org_members x WHERE x.org_id = o.id AND x.role = 'owner') = 1 \
292
         ORDER BY o.name",
293
    )
294
    .bind(account_id)
295
    .fetch_all(pool)
296
    .await
297
}
298
299
/// Whether `name` belonged to a deleted account.
300
pub async fn username_retired(pool: &Pool, name: &str) -> sqlx::Result<bool> {
301
    Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM retired_usernames WHERE username = $1").bind(name).fetch_one(pool).await? > 0)
302
}
303
304
pub async fn touch_seen(pool: &Pool, id: i64) -> sqlx::Result<()> {
305
    sqlx::query("UPDATE accounts SET last_seen_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
306
    Ok(())
307
}
308
309
// ----------------------------------------------------------- organisations
310
311
pub async fn org_by_name(pool: &Pool, name: &str) -> sqlx::Result<Option<Organisation>> {
312
    sqlx::query_as("SELECT * FROM organisations WHERE name = $1").bind(name).fetch_optional(pool).await
313
}
314
315
pub async fn orgs(pool: &Pool) -> sqlx::Result<Vec<Organisation>> {
316
    sqlx::query_as("SELECT * FROM organisations ORDER BY name").fetch_all(pool).await
317
}
318
319
/// An organisation with the account's role in it.
320
#[derive(FromRow)]
321
struct Membership {
322
    #[sqlx(flatten)]
323
    org: Organisation,
324
    role: String,
325
}
326
327
pub async fn orgs_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<(Organisation, String)>> {
328
    let rows: Vec<Membership> = sqlx::query_as(
329
        "SELECT o.*, m.role FROM organisations o JOIN org_members m ON m.org_id = o.id WHERE m.account_id = $1 ORDER BY o.name",
330
    )
331
    .bind(account_id)
332
    .fetch_all(pool)
333
    .await?;
334
    Ok(rows.into_iter().map(|m| (m.org, m.role)).collect())
335
}
336
337
pub async fn create_org(pool: &Pool, name: &str, display_name: &str, owner: i64) -> sqlx::Result<Organisation> {
338
    let mut tx = super::begin_write(pool).await?;
339
    let org: Organisation = sqlx::query_as("INSERT INTO organisations (name, display_name, created_ms) VALUES ($1, $2, $3) RETURNING *")
340
        .bind(name)
341
        .bind(display_name)
342
        .bind(now_ms())
343
        .fetch_one(&mut *tx)
344
        .await?;
345
    sqlx::query("INSERT INTO org_members (org_id, account_id, role) VALUES ($1, $2, 'owner')")
346
        .bind(org.id)
347
        .bind(owner)
348
        .execute(&mut *tx)
349
        .await?;
350
    tx.commit().await?;
351
    Ok(org)
352
}
353
354
pub async fn set_org(pool: &Pool, id: i64, display_name: &str, description: &str, default_role: &str) -> sqlx::Result<()> {
355
    sqlx::query("UPDATE organisations SET display_name = $1, description = $2, default_role = $3 WHERE id = $4")
356
        .bind(display_name)
357
        .bind(description)
358
        .bind(default_role)
359
        .bind(id)
360
        .execute(pool)
361
        .await?;
362
    Ok(())
363
}
364
365
/// `(account, role)` for every member.
366
pub async fn org_members(pool: &Pool, org_id: i64) -> sqlx::Result<Vec<(Account, String)>> {
367
    let rows: Vec<(i64, String)> =
368
        sqlx::query_as("SELECT account_id, role FROM org_members WHERE org_id = $1").bind(org_id).fetch_all(pool).await?;
369
    let mut out = Vec::new();
370
    for (id, role) in rows {
371
        if let Some(a) = account_by_id(pool, id).await? {
372
            out.push((a, role));
373
        }
374
    }
375
    out.sort_by(|a, b| a.0.username.cmp(&b.0.username));
376
    Ok(out)
377
}
378
379
pub async fn org_role(pool: &Pool, org_id: i64, account_id: i64) -> sqlx::Result<Option<String>> {
380
    sqlx::query_scalar("SELECT role FROM org_members WHERE org_id = $1 AND account_id = $2")
381
        .bind(org_id)
382
        .bind(account_id)
383
        .fetch_optional(pool)
384
        .await
385
}
386
387
pub async fn set_org_member(pool: &Pool, org_id: i64, account_id: i64, role: &str) -> sqlx::Result<()> {
388
    sqlx::query("INSERT INTO org_members (org_id, account_id, role) VALUES ($1, $2, $3) ON CONFLICT (org_id, account_id) DO UPDATE SET role = excluded.role")
389
        .bind(org_id)
390
        .bind(account_id)
391
        .bind(role)
392
        .execute(pool)
393
        .await?;
394
    Ok(())
395
}
396
397
pub async fn remove_org_member(pool: &Pool, org_id: i64, account_id: i64) -> sqlx::Result<()> {
398
    sqlx::query("DELETE FROM org_members WHERE org_id = $1 AND account_id = $2").bind(org_id).bind(account_id).execute(pool).await?;
399
    Ok(())
400
}
401
402
pub async fn teams(pool: &Pool, org_id: i64) -> sqlx::Result<Vec<Team>> {
403
    sqlx::query_as("SELECT * FROM teams WHERE org_id = $1 ORDER BY name").bind(org_id).fetch_all(pool).await
404
}
405
406
pub async fn team_by_name(pool: &Pool, org_id: i64, name: &str) -> sqlx::Result<Option<Team>> {
407
    sqlx::query_as("SELECT * FROM teams WHERE org_id = $1 AND name = $2").bind(org_id).bind(name).fetch_optional(pool).await
408
}
409
410
pub async fn create_team(pool: &Pool, org_id: i64, name: &str) -> sqlx::Result<Team> {
411
    sqlx::query_as("INSERT INTO teams (org_id, name) VALUES ($1, $2) RETURNING *").bind(org_id).bind(name).fetch_one(pool).await
412
}
413
414
pub async fn delete_team(pool: &Pool, id: i64) -> sqlx::Result<()> {
415
    sqlx::query("DELETE FROM teams WHERE id = $1").bind(id).execute(pool).await?;
416
    Ok(())
417
}
418
419
pub async fn team_members(pool: &Pool, team_id: i64) -> sqlx::Result<Vec<Account>> {
420
    sqlx::query_as("SELECT a.* FROM accounts a JOIN team_members t ON t.account_id = a.id WHERE t.team_id = $1 ORDER BY a.username")
421
        .bind(team_id)
422
        .fetch_all(pool)
423
        .await
424
}
425
426
pub async fn add_team_member(pool: &Pool, team_id: i64, account_id: i64) -> sqlx::Result<()> {
427
    sqlx::query("INSERT INTO team_members (team_id, account_id) VALUES ($1, $2) ON CONFLICT DO NOTHING")
428
        .bind(team_id)
429
        .bind(account_id)
430
        .execute(pool)
431
        .await?;
432
    Ok(())
433
}
434
435
pub async fn remove_team_member(pool: &Pool, team_id: i64, account_id: i64) -> sqlx::Result<()> {
436
    sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND account_id = $2").bind(team_id).bind(account_id).execute(pool).await?;
437
    Ok(())
438
}
439
440
// ----------------------------------------------------------- collaborators
441
442
/// Who an issue of this repository can be assigned to, by username: its
443
/// owner (an account), its collaborators (directly or through a team) and,
444
/// when an organisation owns it, the organisation's members. Blocked
445
/// accounts are left out.
446
pub async fn assignable(pool: &Pool, repo_id: i64, owner: &str) -> sqlx::Result<Vec<String>> {
447
    sqlx::query_scalar(
448
        "SELECT username FROM accounts WHERE banned = FALSE AND disabled = FALSE AND id IN (\
449
           SELECT id FROM accounts WHERE username = $1 \
450
           UNION SELECT account_id FROM collaborators WHERE repo_id = $2 AND account_id IS NOT NULL \
451
           UNION SELECT tm.account_id FROM team_members tm JOIN collaborators c ON c.team_id = tm.team_id WHERE c.repo_id = $2 \
452
           UNION SELECT m.account_id FROM org_members m JOIN organisations o ON o.id = m.org_id WHERE o.name = $1) \
453
         ORDER BY lower(username), username",
454
    )
455
    .bind(owner)
456
    .bind(repo_id)
457
    .fetch_all(pool)
458
    .await
459
}
460
461
#[derive(Clone, Debug)]
462
pub struct Collaborator {
463
    pub account: Option<Account>,
464
    pub team: Option<Team>,
465
    pub role: String,
466
}
467
468
pub async fn collaborators(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<Collaborator>> {
469
    let rows: Vec<(Option<i64>, Option<i64>, String)> =
470
        sqlx::query_as("SELECT account_id, team_id, role FROM collaborators WHERE repo_id = $1").bind(repo_id).fetch_all(pool).await?;
471
    let mut out = Vec::new();
472
    for (account_id, team_id, role) in rows {
473
        let account = match account_id {
474
            Some(id) => account_by_id(pool, id).await?,
475
            None => None,
476
        };
477
        let team = match team_id {
478
            Some(id) => sqlx::query_as("SELECT * FROM teams WHERE id = $1").bind(id).fetch_optional(pool).await?,
479
            None => None,
480
        };
481
        out.push(Collaborator { account, team, role });
482
    }
483
    Ok(out)
484
}
485
486
pub async fn set_collaborator(pool: &Pool, repo_id: i64, account_id: Option<i64>, team_id: Option<i64>, role: &str) -> sqlx::Result<()> {
487
    if let Some(a) = account_id {
488
        sqlx::query("INSERT INTO collaborators (repo_id, account_id, role) VALUES ($1, $2, $3) ON CONFLICT (repo_id, account_id) WHERE account_id IS NOT NULL DO UPDATE SET role = excluded.role")
489
            .bind(repo_id)
490
            .bind(a)
491
            .bind(role)
492
            .execute(pool)
493
            .await?;
494
    } else if let Some(t) = team_id {
495
        sqlx::query("INSERT INTO collaborators (repo_id, team_id, role) VALUES ($1, $2, $3) ON CONFLICT (repo_id, team_id) WHERE team_id IS NOT NULL DO UPDATE SET role = excluded.role")
496
            .bind(repo_id)
497
            .bind(t)
498
            .bind(role)
499
            .execute(pool)
500
            .await?;
501
    }
502
    Ok(())
503
}
504
505
pub async fn remove_collaborator(pool: &Pool, repo_id: i64, account_id: Option<i64>, team_id: Option<i64>) -> sqlx::Result<()> {
506
    sqlx::query("DELETE FROM collaborators WHERE repo_id = $1 AND ((account_id IS NOT NULL AND account_id = $2) OR (team_id IS NOT NULL AND team_id = $3))")
507
        .bind(repo_id)
508
        .bind(account_id)
509
        .bind(team_id)
510
        .execute(pool)
511
        .await?;
512
    Ok(())
513
}
514
515
/// The direct or team role an account holds on a repository, the highest.
516
pub async fn collaborator_role(pool: &Pool, repo_id: i64, account_id: i64) -> sqlx::Result<Option<String>> {
517
    let rows: Vec<String> = sqlx::query_scalar(
518
        "SELECT role FROM collaborators WHERE repo_id = $1 AND (account_id = $2 OR team_id IN (SELECT team_id FROM team_members WHERE account_id = $3))",
519
    )
520
    .bind(repo_id)
521
    .bind(account_id)
522
    .bind(account_id)
523
    .fetch_all(pool)
524
    .await?;
525
    Ok(rows.into_iter().max_by_key(|r| crate::auth::Role::parse(r).map(|r| r as u8).unwrap_or(0)))
526
}
527
528
// ------------------------------------------------------------------ tokens
529
530
pub async fn tokens_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<Token>> {
531
    sqlx::query_as("SELECT * FROM tokens WHERE account_id = $1 ORDER BY created_ms DESC").bind(account_id).fetch_all(pool).await
532
}
533
534
/// Mark one of `account_id`'s tokens as an agent's, or not.
535
pub async fn set_token_agent(pool: &Pool, account_id: i64, id: i64, agent: bool) -> sqlx::Result<bool> {
536
    let r = sqlx::query("UPDATE tokens SET agent = $1 WHERE id = $2 AND account_id = $3")
537
        .bind(agent)
538
        .bind(id)
539
        .bind(account_id)
540
        .execute(pool)
541
        .await?;
542
    Ok(r.rows_affected() > 0)
543
}
544
545
pub async fn token_by_hash(pool: &Pool, hash: &str) -> sqlx::Result<Option<Token>> {
546
    sqlx::query_as("SELECT * FROM tokens WHERE hash = $1").bind(hash).fetch_optional(pool).await
547
}
548
549
pub async fn create_token(
550
    pool: &Pool,
551
    account_id: i64,
552
    name: &str,
553
    hash: &str,
554
    scopes: &str,
555
    repos: &str,
556
    expires_ms: Option<i64>,
557
) -> sqlx::Result<Token> {
558
    sqlx::query_as(
559
        "INSERT INTO tokens (account_id, name, hash, scopes, repos, created_ms, expires_ms) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
560
    )
561
    .bind(account_id)
562
    .bind(name)
563
    .bind(hash)
564
    .bind(scopes)
565
    .bind(repos)
566
    .bind(now_ms())
567
    .bind(expires_ms)
568
    .fetch_one(pool)
569
    .await
570
}
571
572
pub async fn delete_token(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<()> {
573
    sqlx::query("DELETE FROM tokens WHERE id = $1 AND account_id = $2").bind(id).bind(account_id).execute(pool).await?;
574
    Ok(())
575
}
576
577
/// Record a use; a renewing token's expiry moves to now plus its lifetime.
578
pub async fn touch_token(pool: &Pool, id: i64) -> sqlx::Result<()> {
579
    sqlx::query(
580
        "UPDATE tokens SET last_used_ms = $1, \
581
         expires_ms = CASE WHEN renew_ms IS NULL THEN expires_ms WHEN COALESCE(expires_ms, 0) > $1 + renew_ms THEN expires_ms ELSE $1 + renew_ms END \
582
         WHERE id = $2",
583
    )
584
    .bind(now_ms())
585
    .bind(id)
586
    .execute(pool)
587
    .await?;
588
    Ok(())
589
}
590
591
/// Make a token renewing: it lasts `renew_ms` past its last use.
592
pub async fn set_token_renewal(pool: &Pool, id: i64, renew_ms: i64) -> sqlx::Result<()> {
593
    sqlx::query("UPDATE tokens SET renew_ms = $1 WHERE id = $2").bind(renew_ms).bind(id).execute(pool).await?;
594
    Ok(())
595
}
596
597
// ------------------------------------------------------- device sign-in
598
599
/// A device code an app asked for (oxygenhub#67): pending until the user
600
/// approves or denies it on the web.
601
#[derive(Clone, Debug, FromRow)]
602
pub struct DeviceCode {
603
    pub id: i64,
604
    pub device_hash: String,
605
    pub user_code: String,
606
    pub client: String,
607
    pub scopes: String,
608
    pub created_ms: i64,
609
    pub expires_ms: i64,
610
    pub last_poll_ms: i64,
611
    pub account_id: Option<i64>,
612
    pub approved_scopes: String,
613
    pub denied: bool,
614
    /// The address that asked for the code, as the hub saw it.
615
    pub requester: String,
616
}
617
618
impl DeviceCode {
619
    pub fn scope_list(&self) -> Vec<&str> {
620
        self.scopes.split(',').filter(|s| !s.is_empty()).collect()
621
    }
622
}
623
624
pub async fn create_device_code(
625
    pool: &Pool,
626
    device_hash: &str,
627
    user_code: &str,
628
    client: &str,
629
    scopes: &str,
630
    expires_ms: i64,
631
    requester: &str,
632
) -> sqlx::Result<DeviceCode> {
633
    // Expired codes go whenever a new one is made.
634
    sqlx::query("DELETE FROM device_codes WHERE expires_ms < $1").bind(now_ms()).execute(pool).await?;
635
    sqlx::query_as(
636
        "INSERT INTO device_codes (device_hash, user_code, client, scopes, created_ms, expires_ms, requester) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
637
    )
638
    .bind(device_hash)
639
    .bind(user_code)
640
    .bind(client)
641
    .bind(scopes)
642
    .bind(now_ms())
643
    .bind(expires_ms)
644
    .bind(requester)
645
    .fetch_one(pool)
646
    .await
647
}
648
649
pub async fn device_by_user_code(pool: &Pool, user_code: &str) -> sqlx::Result<Option<DeviceCode>> {
650
    sqlx::query_as("SELECT * FROM device_codes WHERE user_code = $1").bind(user_code).fetch_optional(pool).await
651
}
652
653
pub async fn device_by_hash(pool: &Pool, device_hash: &str) -> sqlx::Result<Option<DeviceCode>> {
654
    sqlx::query_as("SELECT * FROM device_codes WHERE device_hash = $1").bind(device_hash).fetch_optional(pool).await
655
}
656
657
/// Approve a pending code for an account with these scopes; false when it
658
/// was no longer pending.
659
pub async fn approve_device(pool: &Pool, id: i64, account_id: i64, scopes: &str) -> sqlx::Result<bool> {
660
    let r = sqlx::query(
661
        "UPDATE device_codes SET account_id = $1, approved_scopes = $2 WHERE id = $3 AND account_id IS NULL AND denied = FALSE AND expires_ms >= $4",
662
    )
663
    .bind(account_id)
664
    .bind(scopes)
665
    .bind(id)
666
    .bind(now_ms())
667
    .execute(pool)
668
    .await?;
669
    Ok(r.rows_affected() == 1)
670
}
671
672
pub async fn deny_device(pool: &Pool, id: i64) -> sqlx::Result<()> {
673
    sqlx::query("UPDATE device_codes SET denied = TRUE WHERE id = $1 AND account_id IS NULL").bind(id).execute(pool).await?;
674
    Ok(())
675
}
676
677
pub async fn device_polled(pool: &Pool, id: i64) -> sqlx::Result<()> {
678
    sqlx::query("UPDATE device_codes SET last_poll_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
679
    Ok(())
680
}
681
682
/// Remove a code; true when this call removed it (so one poll gets the
683
/// token, however many race).
684
pub async fn delete_device(pool: &Pool, id: i64) -> sqlx::Result<bool> {
685
    let r = sqlx::query("DELETE FROM device_codes WHERE id = $1").bind(id).execute(pool).await?;
686
    Ok(r.rows_affected() == 1)
687
}
688
689
// -------------------------------------------------------------- identities
690
691
pub async fn identities_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<LinkedIdentity>> {
692
    sqlx::query_as("SELECT * FROM identities WHERE account_id = $1 ORDER BY linked_ms DESC").bind(account_id).fetch_all(pool).await
693
}
694
695
/// Note that the hub received something signed by each of `keys` (#286):
696
/// linked keys only, at most once a minute each.
697
pub async fn identities_used(pool: &Pool, keys: &[String]) -> sqlx::Result<()> {
698
    let now = now_ms();
699
    for k in keys {
700
        sqlx::query("UPDATE identities SET last_used_ms = $1 WHERE key = $2 AND (last_used_ms IS NULL OR last_used_ms < $3)")
701
            .bind(now)
702
            .bind(k)
703
            .bind(now - 60_000)
704
            .execute(pool)
705
            .await?;
706
    }
707
    Ok(())
708
}
709
710
pub async fn identity_by_key(pool: &Pool, key: &str) -> sqlx::Result<Option<LinkedIdentity>> {
711
    sqlx::query_as("SELECT * FROM identities WHERE key = $1").bind(key).fetch_optional(pool).await
712
}
713
714
/// Every linked key with its account's username, for attribution.
715
pub async fn identity_accounts(pool: &Pool) -> sqlx::Result<Vec<(String, String)>> {
716
    sqlx::query_as("SELECT i.key, a.username FROM identities i JOIN accounts a ON a.id = i.account_id").fetch_all(pool).await
717
}
718
719
pub async fn link_identity(pool: &Pool, account_id: i64, key: &str, name: &str, email: &str, object: &[u8]) -> sqlx::Result<()> {
720
    sqlx::query("INSERT INTO identities (key, account_id, name, email, object, linked_ms) VALUES ($1, $2, $3, $4, $5, $6)")
721
        .bind(key)
722
        .bind(account_id)
723
        .bind(name)
724
        .bind(email)
725
        .bind(object)
726
        .bind(now_ms())
727
        .execute(pool)
728
        .await?;
729
    Ok(())
730
}
731
732
pub async fn unlink_identity(pool: &Pool, account_id: i64, key: &str) -> sqlx::Result<()> {
733
    sqlx::query("DELETE FROM identities WHERE key = $1 AND account_id = $2").bind(key).bind(account_id).execute(pool).await?;
734
    Ok(())
735
}
736
737
pub async fn create_challenge(pool: &Pool, account_id: i64, bytes_hex: &str) -> sqlx::Result<i64> {
738
    sqlx::query("DELETE FROM challenges WHERE created_ms < $1").bind(now_ms() - 10 * 60 * 1000).execute(pool).await?;
739
    let id: i64 = sqlx::query_scalar("INSERT INTO challenges (account_id, bytes, created_ms) VALUES ($1, $2, $3) RETURNING id")
740
        .bind(account_id)
741
        .bind(bytes_hex)
742
        .bind(now_ms())
743
        .fetch_one(pool)
744
        .await?;
745
    Ok(id)
746
}
747
748
/// Take (and delete) a pending challenge of this account.
749
pub async fn take_challenge(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<Option<String>> {
750
    let bytes: Option<String> = sqlx::query_scalar("SELECT bytes FROM challenges WHERE id = $1 AND account_id = $2 AND created_ms >= $3")
751
        .bind(id)
752
        .bind(account_id)
753
        .bind(now_ms() - 10 * 60 * 1000)
754
        .fetch_optional(pool)
755
        .await?;
756
    sqlx::query("DELETE FROM challenges WHERE id = $1").bind(id).execute(pool).await?;
757
    Ok(bytes)
758
}
759
760
// ------------------------------------------------------------- invitations
761
762
pub async fn create_invitation(pool: &Pool, code: &str, created_by: Option<i64>) -> sqlx::Result<()> {
763
    sqlx::query("INSERT INTO invitations (code, created_by, created_ms) VALUES ($1, $2, $3)")
764
        .bind(code)
765
        .bind(created_by)
766
        .bind(now_ms())
767
        .execute(pool)
768
        .await?;
769
    Ok(())
770
}
771
772
pub async fn invitation_unused(pool: &Pool, code: &str) -> sqlx::Result<bool> {
773
    let n: i64 =
774
        sqlx::query_scalar("SELECT COUNT(*) FROM invitations WHERE code = $1 AND used_by IS NULL").bind(code).fetch_one(pool).await?;
775
    Ok(n > 0)
776
}
777
778
/// Use an invitation up if it is still unused; whether it was.
779
pub async fn claim_invitation(pool: &Pool, code: &str, account_id: i64) -> sqlx::Result<bool> {
780
    let r = sqlx::query("UPDATE invitations SET used_by = $1 WHERE code = $2 AND used_by IS NULL")
781
        .bind(account_id)
782
        .bind(code)
783
        .execute(pool)
784
        .await?;
785
    Ok(r.rows_affected() > 0)
786
}
787
788
pub async fn use_invitation(pool: &Pool, code: &str, account_id: i64) -> sqlx::Result<()> {
789
    sqlx::query("UPDATE invitations SET used_by = $1 WHERE code = $2").bind(account_id).bind(code).execute(pool).await?;
790
    Ok(())
791
}
792
793
pub async fn invitations(pool: &Pool) -> sqlx::Result<Vec<(String, Option<i64>, i64)>> {
794
    sqlx::query_as("SELECT code, used_by, created_ms FROM invitations ORDER BY created_ms DESC").fetch_all(pool).await
795
}
796
797
// ---------------------------------------------------------------- settings
798
799
pub async fn setting(pool: &Pool, key: &str) -> sqlx::Result<Option<String>> {
800
    sqlx::query_scalar("SELECT value FROM settings WHERE key = $1").bind(key).fetch_optional(pool).await
801
}
802
803
pub async fn set_setting(pool: &Pool, key: &str, value: &str) -> sqlx::Result<()> {
804
    sqlx::query("INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = excluded.value")
805
        .bind(key)
806
        .bind(value)
807
        .execute(pool)
808
        .await?;
809
    Ok(())
810
}
811
812
// ------------------------------------------------------------------- audit
813
814
pub async fn audit(pool: &Pool, actor: Option<&Account>, action: &str, target: &str, detail: &str) -> sqlx::Result<()> {
815
    sqlx::query("INSERT INTO audit (at_ms, actor_id, actor, action, target, detail) VALUES ($1, $2, $3, $4, $5, $6)")
816
        .bind(now_ms())
817
        .bind(actor.map(|a| a.id))
818
        .bind(actor.map(|a| a.username.as_str()).unwrap_or(""))
819
        .bind(action)
820
        .bind(target)
821
        .bind(detail)
822
        .execute(pool)
823
        .await?;
824
    Ok(())
825
}
826
827
/// A page of the audit log, newest first, older than `before` (an id) when given.
828
pub async fn audit_page(pool: &Pool, before: Option<i64>, limit: i64) -> sqlx::Result<Vec<AuditRow>> {
829
    sqlx::query_as("SELECT id, at_ms, actor, action, target, detail FROM audit WHERE ($1 IS NULL OR id < $2) ORDER BY id DESC LIMIT $3")
830
        .bind(before)
831
        .bind(before)
832
        .bind(limit)
833
        .fetch_all(pool)
834
        .await
835
}
836
837
pub async fn audit_rows(pool: &Pool, limit: i64) -> sqlx::Result<Vec<AuditRow>> {
838
    sqlx::query_as("SELECT id, at_ms, actor, action, target, detail FROM audit ORDER BY at_ms DESC, id DESC LIMIT $1")
839
        .bind(limit)
840
        .fetch_all(pool)
841
        .await
842
}
843
844
/// The repositories an account can see beyond the public ones: its own,
845
/// its organisations', and those it collaborates on. Drops are not
846
/// repositories here (#279).
847
pub async fn visible_repo_ids(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<i64>> {
848
    sqlx::query_scalar(
849
        "SELECT r.id FROM repositories r WHERE r.kind = 'repo' AND (r.owner IN (SELECT username FROM accounts WHERE id = $1) \
850
         OR r.owner IN (SELECT o.name FROM organisations o JOIN org_members m ON m.org_id = o.id WHERE m.account_id = $2) \
851
         OR r.id IN (SELECT repo_id FROM collaborators WHERE account_id = $3 OR team_id IN (SELECT team_id FROM team_members WHERE account_id = $4)))",
852
    )
853
    .bind(account_id)
854
    .bind(account_id)
855
    .bind(account_id)
856
    .bind(account_id)
857
    .fetch_all(pool)
858
    .await
859
}
860
861
// ------------------------------------------------------ account links
862
863
/// A verification or reset link (hub spec §5.1.1).
864
#[derive(Clone, Debug, FromRow)]
865
pub struct AccountLink {
866
    pub id: i64,
867
    pub account_id: i64,
868
    pub kind: String,
869
    pub hash: String,
870
    pub email: String,
871
    pub created_by: Option<i64>,
872
    pub created_ms: i64,
873
    pub expires_ms: i64,
874
    pub used_ms: Option<i64>,
875
}
876
877
/// Make a link; returns its secret (shown or sent once, stored as a hash).
878
pub async fn create_link(
879
    pool: &Pool,
880
    account_id: i64,
881
    kind: &str,
882
    email: &str,
883
    created_by: Option<i64>,
884
    ttl_ms: i64,
885
) -> sqlx::Result<String> {
886
    let secret = crate::auth::random_hex(32);
887
    let now = now_ms();
888
    sqlx::query(
889
        "INSERT INTO account_links (account_id, kind, hash, email, created_by, created_ms, expires_ms) VALUES ($1, $2, $3, $4, $5, $6, $7)",
890
    )
891
    .bind(account_id)
892
    .bind(kind)
893
    .bind(crate::auth::token_hash(&secret))
894
    .bind(email)
895
    .bind(created_by)
896
    .bind(now)
897
    .bind(now + ttl_ms)
898
    .execute(pool)
899
    .await?;
900
    Ok(secret)
901
}
902
903
/// An unused, unexpired link of this kind with this secret.
904
pub async fn live_link(pool: &Pool, kind: &str, secret: &str) -> sqlx::Result<Option<AccountLink>> {
905
    sqlx::query_as("SELECT * FROM account_links WHERE kind = $1 AND hash = $2 AND used_ms IS NULL AND expires_ms > $3")
906
        .bind(kind)
907
        .bind(crate::auth::token_hash(secret))
908
        .bind(now_ms())
909
        .fetch_optional(pool)
910
        .await
911
}
912
913
/// Consume a link; false when it was already used (a race lost).
914
pub async fn use_link(pool: &Pool, id: i64) -> sqlx::Result<bool> {
915
    let r = sqlx::query("UPDATE account_links SET used_ms = $1 WHERE id = $2 AND used_ms IS NULL")
916
        .bind(now_ms())
917
        .bind(id)
918
        .execute(pool)
919
        .await?;
920
    Ok(r.rows_affected() == 1)
921
}
922
923
/// Void every unused link of a kind for an account.
924
pub async fn void_links(pool: &Pool, account_id: i64, kind: &str) -> sqlx::Result<()> {
925
    sqlx::query("UPDATE account_links SET used_ms = $1 WHERE account_id = $2 AND kind = $3 AND used_ms IS NULL")
926
        .bind(now_ms())
927
        .bind(account_id)
928
        .bind(kind)
929
        .execute(pool)
930
        .await?;
931
    Ok(())
932
}
933
934
/// When the account's newest link of a kind (for this address, when given) was made, for rate limits.
935
pub async fn last_link_ms(pool: &Pool, account_id: i64, kind: &str, email: Option<&str>) -> sqlx::Result<Option<i64>> {
936
    match email {
937
        Some(e) => {
938
            sqlx::query_scalar("SELECT MAX(created_ms) FROM account_links WHERE account_id = $1 AND kind = $2 AND email = $3")
939
                .bind(account_id)
940
                .bind(kind)
941
                .bind(e)
942
                .fetch_one(pool)
943
                .await
944
        }
945
        None => {
946
            sqlx::query_scalar("SELECT MAX(created_ms) FROM account_links WHERE account_id = $1 AND kind = $2")
947
                .bind(account_id)
948
                .bind(kind)
949
                .fetch_one(pool)
950
                .await
951
        }
952
    }
953
}
954
955
/// How many links of a kind the account got since `since_ms`.
956
pub async fn links_since(pool: &Pool, account_id: i64, kind: &str, since_ms: i64) -> sqlx::Result<i64> {
957
    sqlx::query_scalar("SELECT COUNT(*) FROM account_links WHERE account_id = $1 AND kind = $2 AND created_ms >= $3")
958
        .bind(account_id)
959
        .bind(kind)
960
        .bind(since_ms)
961
        .fetch_one(pool)
962
        .await
963
}
964
965
pub async fn set_email_verified(pool: &Pool, id: i64, verified: bool) -> sqlx::Result<()> {
966
    sqlx::query("UPDATE accounts SET email_verified = $1 WHERE id = $2").bind(verified).bind(id).execute(pool).await?;
967
    Ok(())
968
}
969
970
/// The account with this verified email (case-insensitive), if exactly one.
971
pub async fn account_by_verified_email(pool: &Pool, email: &str) -> sqlx::Result<Option<Account>> {
972
    let found: Vec<Account> = sqlx::query_as("SELECT * FROM accounts WHERE email_verified = TRUE AND lower(email) = lower($1) LIMIT 2")
973
        .bind(email)
974
        .fetch_all(pool)
975
        .await?;
976
    Ok(if found.len() == 1 { found.into_iter().next() } else { None })
977
}
978
979
/// Set or clear the picture of an account (`account`) or organisation (`org`).
980
pub async fn set_avatar(pool: &Pool, kind: &str, id: i64, value: &str) -> sqlx::Result<()> {
981
    let table = if kind == "org" { "organisations" } else { "accounts" };
982
    sqlx::query(&format!("UPDATE {table} SET avatar = $1 WHERE id = $2")).bind(value).bind(id).execute(pool).await?;
983
    Ok(())
984
}
985
986
// ------------------------------------------------------------ agent grants
987
988
/// An agent an account lets act for it (oxygenhub#241, migration 0075).
989
#[derive(Clone, Debug, FromRow)]
990
pub struct AgentGrant {
991
    pub id: i64,
992
    pub account_id: i64,
993
    pub agent: String,
994
    pub subject: String,
995
    /// The owner's KeyId (hex) that signed the grant.
996
    pub grantor: String,
997
    pub scope: String,
998
    /// The signed Oxygen grant, as certificates carry it.
999
    pub signed: Vec<u8>,
1000
    pub token_hash: String,
1001
    pub created_ms: i64,
1002
    pub expires_ms: i64,
1003
    pub revoked_ms: Option<i64>,
1004
    pub last_used_ms: Option<i64>,
1005
}
1006
1007
impl AgentGrant {
1008
    /// Whether certificates may still be issued under it at `now`.
1009
    pub fn live(&self, now: i64) -> bool {
1010
        self.revoked_ms.is_none() && now < self.expires_ms
1011
    }
1012
}
1013
1014
pub struct NewAgentGrant<'a> {
1015
    pub account_id: i64,
1016
    pub agent: &'a str,
1017
    pub subject: &'a str,
1018
    pub grantor: &'a str,
1019
    pub scope: &'a str,
1020
    pub signed: &'a [u8],
1021
    pub token_hash: &'a str,
1022
    pub expires_ms: i64,
1023
}
1024
1025
pub async fn create_agent_grant(pool: &Pool, g: NewAgentGrant<'_>) -> sqlx::Result<i64> {
1026
    sqlx::query_scalar(
1027
        "INSERT INTO agent_grants (account_id, agent, subject, grantor, scope, signed, token_hash, created_ms, expires_ms) \
1028
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id",
1029
    )
1030
    .bind(g.account_id)
1031
    .bind(g.agent)
1032
    .bind(g.subject)
1033
    .bind(g.grantor)
1034
    .bind(g.scope)
1035
    .bind(g.signed)
1036
    .bind(g.token_hash)
1037
    .bind(now_ms())
1038
    .bind(g.expires_ms)
1039
    .fetch_one(pool)
1040
    .await
1041
}
1042
1043
pub async fn agent_grants_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<AgentGrant>> {
1044
    sqlx::query_as("SELECT * FROM agent_grants WHERE account_id = $1 ORDER BY created_ms DESC, id DESC")
1045
        .bind(account_id)
1046
        .fetch_all(pool)
1047
        .await
1048
}
1049
1050
pub async fn agent_grant_by_token(pool: &Pool, token_hash: &str) -> sqlx::Result<Option<AgentGrant>> {
1051
    sqlx::query_as("SELECT * FROM agent_grants WHERE token_hash = $1").bind(token_hash).fetch_optional(pool).await
1052
}
1053
1054
pub async fn touch_agent_grant(pool: &Pool, id: i64) -> sqlx::Result<()> {
1055
    sqlx::query("UPDATE agent_grants SET last_used_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
1056
    Ok(())
1057
}
1058
1059
/// Revoke one of the account's grants; true when this call revoked it.
1060
pub async fn revoke_agent_grant(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<bool> {
1061
    let r = sqlx::query("UPDATE agent_grants SET revoked_ms = $1 WHERE id = $2 AND account_id = $3 AND revoked_ms IS NULL")
1062
        .bind(now_ms())
1063
        .bind(id)
1064
        .bind(account_id)
1065
        .execute(pool)
1066
        .await?;
1067
    Ok(r.rows_affected() == 1)
1068
}