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