Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
773 linesCodeBlameRaw
1//! A person's email addresses and the security of their account: identity's
2//! methods for them, and the rules they follow, kept pure so every caller
3//! applies the same ones.
4//!
5//! An account has up to [`MAX_EMAILS`] addresses. One is primary: account
6//! mail and password resets go there. A confirmed address belongs to one
7//! account; until someone confirms it, any account may have added it, and
8//! the first to confirm it keeps it. Sensitive changes need the person to
9//! have signed in within [`RECENT_AUTH_SECONDS`], or to give their password
10//! again ([`Reauth`]); a refusal for that is `FailureCode::ReauthRequired`.
11//!
12//! An account can turn on two-factor authentication: a code from an
13//! authenticator app (TOTP, RFC 6238), with recovery codes for when the app
14//! is lost. Signing in with a password then asks for a code as well.
15//!
16//! Workspaces can ask more of their members. [`WorkspacePolicy`] is where
17//! that goes: identity evaluates it wherever someone gains or uses access
18//! to a workspace. Today an owner can require two-factor authentication;
19//! the email rules are there for later.
20
21use serde::{Deserialize, Serialize};
22
23use crate::User;
24
25/// The most addresses one account may have, confirmed or not.
26pub const MAX_EMAILS: usize = 10;
27
28/// How long after signing in (or confirming the password) a person may make
29/// sensitive changes without being asked to prove it is them again.
30pub const RECENT_AUTH_SECONDS: u64 = 10 * 60;
31
32/// The least time between two confirmation emails to one address.
33pub const RESEND_SECONDS: u64 = 60;
34
35/// Where each person's private commit address lives:
36/// `<id suffix>+<username>@users.noreply.g1t.sh`.
37pub const NOREPLY_DOMAIN: &str = "users.noreply.g1t.sh";
38
39/// How many characters of the account id the noreply address carries. The
40/// end of an id is its random part, so a username alone never resolves.
41pub const NOREPLY_ID_CHARS: usize = 8;
42
43/// An address trimmed and lowercased, if it looks like one: something, an
44/// `@`, and a domain with a dot, at most 254 characters, no spaces.
45pub fn normalize_email(text: &str) -> Option<String> {
46 let email = text.trim().to_lowercase();
47 let well_formed = email.len() <= 254
48 && email.split_once('@').is_some_and(|(local, domain)| {
49 !local.is_empty() && !domain.contains('@') && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
50 })
51 && !email.contains(char::is_whitespace);
52 well_formed.then_some(email)
53}
54
55/// The person's noreply address, used for commits g1t makes for them when
56/// they keep their address private.
57pub fn noreply_address(user_id: &str, username: &str) -> String {
58 format!("{}+{}@{NOREPLY_DOMAIN}", id_suffix(user_id), username.to_lowercase())
59}
60
61/// The last [`NOREPLY_ID_CHARS`] characters of an account id, lowercased.
62pub fn id_suffix(user_id: &str) -> String {
63 let chars: Vec<char> = user_id.chars().collect();
64 let start = chars.len().saturating_sub(NOREPLY_ID_CHARS);
65 chars[start..].iter().collect::<String>().to_lowercase()
66}
67
68/// The id suffix and username a noreply address names, or `None` for any
69/// other address.
70pub fn parse_noreply(email: &str) -> Option<(String, String)> {
71 let email = email.trim().to_lowercase();
72 let local = email.strip_suffix(&format!("@{NOREPLY_DOMAIN}"))?;
73 let (suffix, username) = local.split_once('+')?;
74 (suffix.chars().count() == NOREPLY_ID_CHARS && !username.is_empty()).then(|| (suffix.to_owned(), username.to_owned()))
75}
76
77/// Whether a sign-in at `authenticated_at` (RFC 3339) is recent at `now`
78/// (RFC 3339), within `window_seconds`. Both are g1t's fixed format, which
79/// compares as text.
80pub fn is_recent(authenticated_at: Option<&str>, now_ms: u64, window_seconds: u64) -> bool {
81 let since = crate::time::rfc3339(now_ms.saturating_sub(window_seconds * 1000));
82 authenticated_at.is_some_and(|at| at >= since.as_str())
83}
84
85/// One address, as the rules about removing and choosing addresses see it.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct EmailState {
88 pub email: String,
89 pub verified: bool,
90 pub primary: bool,
91}
92
93/// Why `email` cannot be removed from an account with `all`, or `None`.
94pub fn removal_refusal(all: &[EmailState], email: &str) -> Option<&'static str> {
95 let Some(target) = all.iter().find(|state| state.email == email) else {
96 return Some("That address is not on your account.");
97 };
98 if target.primary {
99 return Some("That is your primary address. Make another confirmed address primary first.");
100 }
101 let confirmed = all.iter().filter(|state| state.verified).count();
102 if target.verified && confirmed <= 1 {
103 return Some("That is your only confirmed address. Add and confirm another first.");
104 }
105 None
106}
107
108/// Why `email` cannot be made primary, or `None`.
109pub fn primary_refusal(all: &[EmailState], email: &str) -> Option<&'static str> {
110 match all.iter().find(|state| state.email == email) {
111 None => Some("That address is not on your account."),
112 Some(state) if !state.verified => Some("Confirm that address before making it primary."),
113 Some(_) => None,
114 }
115}
116
117/// Proof that the person making a sensitive change is the account's owner,
118/// now. Either is enough: the session they are using, if they signed in to
119/// it within [`RECENT_AUTH_SECONDS`], or their password. A correct password
120/// also renews the session's sign-in time, so they are not asked again
121/// straight away.
122#[derive(Clone, Debug, Default, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct Reauth {
125 #[serde(default)]
126 pub session_token: Option<String>,
127 #[serde(default)]
128 pub password: Option<String>,
129 /// Who is asking, such as the visitor's IP address, so wrong passwords
130 /// are counted against it too.
131 #[serde(default)]
132 pub client: Option<String>,
133}
134
135/// One of a person's addresses, as they see it.
136#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct AccountEmail {
139 /// As typed when it was added.
140 pub email: String,
141 pub verified: bool,
142 pub primary: bool,
143 /// Gets security notices as well as the primary.
144 pub backup: bool,
145 /// RFC 3339.
146 pub created_at: String,
147 /// RFC 3339.
148 pub verified_at: Option<String>,
149}
150
151/// A person's addresses and what they do with them. `list_emails` (takes
152/// `UserArgs`) returns `Outcome<AccountEmails>`, and so does every change.
153#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct AccountEmails {
156 /// The primary first, then confirmed addresses, then the rest, oldest
157 /// first within each.
158 pub emails: Vec<AccountEmail>,
159 /// Commits g1t makes for the person use `noreply`, not the primary.
160 pub private_email: bool,
161 /// Pushes of commits that carry one of the person's addresses are
162 /// refused while `private_email` is on.
163 pub block_private_pushes: bool,
164 /// `<id suffix>+<username>@users.noreply.g1t.sh`.
165 pub noreply: String,
166 /// The address commits g1t makes for the person carry now.
167 pub commit_email: String,
168 /// [`MAX_EMAILS`].
169 pub limit: u32,
170}
171
172/// `add_email`: adds an address and emails it a confirmation link; adding
173/// one already on the account and unconfirmed sends the link again.
174/// `remove_email`: removes one, never the primary nor the last confirmed
175/// address. Both need [`Reauth`] and tell every confirmed address.
176/// `resend_email_verification` sends the link again, at most once every
177/// [`RESEND_SECONDS`], and needs no reauth. People only: never an agent's
178/// or a workspace's token.
179#[derive(Debug, Serialize, Deserialize)]
180pub struct AccountEmailArgs {
181 pub user: User,
182 pub email: String,
183 #[serde(default)]
184 pub reauth: Reauth,
185}
186
187// --- Confirming an address ---
188
189/// How many digits the code in a confirmation email has.
190pub const CONFIRM_CODE_DIGITS: usize = 6;
191
192/// How long the code and the link in a confirmation email work. Sending
193/// another email ends both at once.
194pub const CONFIRM_TTL_SECONDS: u64 = 60 * 60;
195
196/// What an account that has not confirmed its address hears from anything
197/// other than the pages that confirm it: the API, MCP and git. `site` is
198/// where the confirmation page is, such as `https://g1t.sh`.
199pub fn confirm_email_first(site: &str) -> String {
200 format!(
201 "Confirm your email address first: enter the code from the email g1t sent you at {}/confirm-email, or follow the link in it.",
202 site.trim_end_matches('/')
203 )
204}
205
206/// A confirmation code as typed or pasted, with spaces and hyphens taken
207/// out; None unless that leaves exactly [`CONFIRM_CODE_DIGITS`] digits.
208pub fn tidy_confirm_code(code: &str) -> Option<String> {
209 let digits: String = code.chars().filter(|c| !c.is_whitespace() && *c != '-').collect();
210 (digits.len() == CONFIRM_CODE_DIGITS && digits.chars().all(|c| c.is_ascii_digit())).then_some(digits)
211}
212
213/// `confirm_email_code`: the code from a confirmation email, typed by the
214/// signed-in person it was sent to. It confirms the address it was sent
215/// to. Wrong codes are counted against the account and `client`; past a
216/// limit nothing is checked for a while. Returns `Outcome<EmailConfirmed>`.
217#[derive(Debug, Serialize, Deserialize)]
218pub struct ConfirmEmailCodeArgs {
219 pub user: User,
220 pub code: String,
221 /// Who is asking, such as the visitor's IP address, for rate limits.
222 #[serde(default)]
223 pub client: Option<String>,
224}
225
226/// `change_pending_email`: for an account that has not confirmed any
227/// address, replaces the address it signed up with and sends a new code
228/// and link there. Returns `Outcome<AccountEmails>`.
229#[derive(Debug, Serialize, Deserialize)]
230pub struct PendingEmailArgs {
231 pub user: User,
232 pub email: String,
233}
234
235/// What confirming an address did. `verify_email` (the link) and
236/// `confirm_email_code` (the code) return it.
237#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase")]
239pub struct EmailConfirmed {
240 pub username: String,
241 /// The address confirmed, as typed when it was added.
242 pub email: String,
243 /// Whether the account is confirmed now: whether its primary is.
244 pub verified: bool,
245 /// The workspace the invite the account signed up with joined it to,
246 /// by slug, now that the account is confirmed.
247 #[serde(default)]
248 pub joined: Option<String>,
249 /// The workspace the invite the account signed up with invites it to,
250 /// by slug: a workspace invitation now waits for its answer
251 /// (`accept_invitation`). Nobody joins a workspace without saying yes.
252 #[serde(default)]
253 pub invited_to: Option<String>,
254 /// Why the invite the account signed up with no longer applies, when it
255 /// was revoked, expired or its workspace deleted while the account
256 /// waited. The address is confirmed all the same.
257 #[serde(default)]
258 pub invite_lapsed: Option<String>,
259}
260
261/// `update_email_settings`: each field given is changed. `primary` must be
262/// a confirmed address. `backup` is a confirmed address to get security
263/// notices too, or empty for the primary only. Changing either needs
264/// [`Reauth`]; the privacy switches do not. Returns `Outcome<AccountEmails>`.
265#[derive(Debug, Default, Serialize, Deserialize)]
266#[serde(rename_all = "camelCase")]
267pub struct EmailSettingsArgs {
268 pub user: User,
269 #[serde(default)]
270 pub primary: Option<String>,
271 #[serde(default)]
272 pub backup: Option<String>,
273 #[serde(default)]
274 pub private_email: Option<bool>,
275 #[serde(default)]
276 pub block_private_pushes: Option<bool>,
277 #[serde(default)]
278 pub reauth: Reauth,
279}
280
281/// `reauthenticate`: the person typed their password again for the session
282/// they are using; sensitive changes need no more proof for
283/// [`RECENT_AUTH_SECONDS`]. Returns `Outcome<bool>`.
284#[derive(Debug, Serialize, Deserialize)]
285#[serde(rename_all = "camelCase")]
286pub struct ReauthenticateArgs {
287 pub session_token: String,
288 pub password: String,
289 #[serde(default)]
290 pub client: Option<String>,
291}
292
293/// `email_owners`: who wrote commits, by their author addresses. Matches
294/// confirmed addresses and noreply addresses only, never an unconfirmed
295/// one. At most 200 addresses. Returns a map from each address that
296/// matched, lowercased, to its owner.
297#[derive(Debug, Serialize, Deserialize)]
298pub struct EmailOwnersArgs {
299 pub emails: Vec<String>,
300}
301
302/// The account an address belongs to.
303#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
304pub struct EmailOwner {
305 pub id: String,
306 pub username: String,
307 pub avatar: Option<String>,
308}
309
310/// `commit_identity`: the name and address to put on a commit g1t makes for
311/// a person (a merge, a web edit, catching a branch up). Their noreply
312/// address while they keep their address private, otherwise their primary.
313/// Returns `Option<CommitIdentity>`; null for an unknown account.
314#[derive(Debug, Serialize, Deserialize)]
315#[serde(rename_all = "camelCase")]
316pub struct CommitIdentityArgs {
317 pub user_id: String,
318}
319
320#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
321pub struct CommitIdentity {
322 pub name: String,
323 pub email: String,
324}
325
326/// `push_email_guard` (takes `CommitIdentityArgs`): what a push by this
327/// person must not publish. Returns `Option<PushEmailGuard>`: null unless
328/// they keep their address private and block pushes that expose it.
329#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
330pub struct PushEmailGuard {
331 /// Their confirmed addresses, lowercased.
332 pub emails: Vec<String>,
333 /// The address to commit with instead.
334 pub noreply: String,
335}
336
337impl PushEmailGuard {
338 /// Whether a commit carrying `email` would publish one of the
339 /// person's addresses.
340 pub fn exposes(&self, email: &str) -> bool {
341 let email = email.trim().to_lowercase();
342 !email.is_empty() && self.emails.contains(&email)
343 }
344}
345
346/// An address with all but the first letter of its local part hidden:
347/// `s***@gmail.com`.
348pub fn mask_email(email: &str) -> String {
349 match email.split_once('@') {
350 Some((local, domain)) => {
351 let first: String = local.chars().take(1).collect();
352 format!("{first}***@{domain}")
353 }
354 None => "***".to_owned(),
355 }
356}
357
358/// Something that happened to an account's security. `security_log` (takes
359/// `UserArgs`) returns the newest [`SECURITY_LOG_LIMIT`], newest first.
360#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
361#[serde(rename_all = "camelCase")]
362pub struct SecurityEvent {
363 /// `email_added`, `email_verified`, `email_removed`,
364 /// `primary_email_changed`, `backup_email_changed`,
365 /// `email_privacy_changed`, `password_changed`, `two_factor_enabled`,
366 /// `two_factor_disabled`, `recovery_codes_regenerated`,
367 /// `recovery_code_used`, `token_created`, `token_deleted`,
368 /// `token_rescoped`, `ssh_key_added`, `ssh_key_removed`,
369 /// `oauth_grant_created`, `oauth_grant_revoked`,
370 /// `oauth_grant_rescoped`, `account_deleted` or `account_restored`
371 /// (seen by staff while the account waits to be purged).
372 pub kind: String,
373 /// The address concerned, or what changed.
374 pub detail: Option<String>,
375 /// Whether g1t staff made the change.
376 pub by_staff: bool,
377 /// Why staff made it.
378 pub reason: Option<String>,
379 /// The staff member, by email. Only in staff views.
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub staff: Option<String>,
382 /// RFC 3339.
383 pub created_at: String,
384}
385
386/// How many entries `security_log` returns.
387pub const SECURITY_LOG_LIMIT: usize = 50;
388
389// --- Two-factor authentication ---
390
391/// How long one TOTP code lasts, in seconds (RFC 6238's default).
392pub const TOTP_STEP_SECONDS: u64 = 30;
393/// How many digits a code has.
394pub const TOTP_DIGITS: u32 = 6;
395/// How many steps either side of now a code is accepted from, for clocks
396/// that are a little off: one, so a code works for up to 90 seconds.
397pub const TOTP_SKEW_STEPS: u64 = 1;
398/// How many recovery codes an account gets.
399pub const RECOVERY_CODES: usize = 10;
400/// How long a sign-in waits for its code, in seconds.
401pub const TWO_FACTOR_CHALLENGE_SECONDS: u64 = 10 * 60;
402/// How many wrong codes one sign-in may have before it must start again.
403pub const TWO_FACTOR_ATTEMPTS: u32 = 5;
404/// The issuer authenticator apps show beside the account.
405pub const TOTP_ISSUER: &str = "g1t";
406
407/// Where an account's two-factor authentication stands.
408/// `two_factor_status` (takes `UserArgs`) returns `Outcome<TwoFactorStatus>`.
409#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
410pub struct TwoFactorStatus {
411 pub enabled: bool,
412 /// RFC 3339.
413 pub enabled_at: Option<String>,
414 /// Recovery codes not used yet.
415 pub recovery_codes_left: u32,
416 /// The workspaces the person belongs to that require it.
417 pub required_by: Vec<String>,
418}
419
420/// What an authenticator app needs: the secret in base32, and the same as
421/// an `otpauth://` address for a QR code.
422#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
423pub struct TwoFactorSetup {
424 pub secret: String,
425 pub uri: String,
426}
427
428/// Single-use recovery codes, shown once.
429#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
430pub struct RecoveryCodes {
431 pub codes: Vec<String>,
432}
433
434/// `two_factor_start`: begins turning it on, replacing any enrolment in
435/// progress. Needs [`Reauth`]. Refused while it is on. Returns
436/// `Outcome<TwoFactorSetup>`.
437///
438/// `two_factor_recovery_codes`: makes new recovery codes, replacing the
439/// old ones. Needs [`Reauth`] and two-factor on. Returns
440/// `Outcome<RecoveryCodes>`.
441#[derive(Debug, Serialize, Deserialize)]
442pub struct TwoFactorArgs {
443 pub user: User,
444 #[serde(default)]
445 pub reauth: Reauth,
446}
447
448/// `two_factor_enable`: a code from the app confirms the enrolment, and
449/// two-factor is on; returns the recovery codes, shown once.
450/// `two_factor_disable`: turns it off; needs a code (or a recovery code)
451/// as well as [`Reauth`]. Refused for an owner of a workspace that
452/// requires it. Both return `Outcome<...>`: `RecoveryCodes` and `bool`.
453#[derive(Debug, Serialize, Deserialize)]
454pub struct TwoFactorCodeArgs {
455 pub user: User,
456 pub code: String,
457 #[serde(default)]
458 pub reauth: Reauth,
459}
460
461/// `two_factor_sign_in`: the second step of signing in. `challenge` is
462/// what `sign_in` returned as `SignedIn::two_factor_challenge`; `code` is a
463/// code from the app or a recovery code. Returns `Outcome<SignedIn>`, with
464/// a session.
465#[derive(Debug, Serialize, Deserialize)]
466pub struct TwoFactorSignInArgs {
467 pub challenge: String,
468 pub code: String,
469 #[serde(default)]
470 pub client: Option<String>,
471}
472
473/// The `otpauth://` address for a secret, as authenticator apps read it
474/// from a QR code.
475pub fn otpauth_uri(secret_base32: &str, username: &str) -> String {
476 let label: String = format!("{TOTP_ISSUER}:{username}")
477 .chars()
478 .map(|c| if c.is_ascii_alphanumeric() || "-._~:".contains(c) { c.to_string() } else { format!("%{:02X}", c as u32) })
479 .collect();
480 format!(
481 "otpauth://totp/{label}?secret={secret_base32}&issuer={TOTP_ISSUER}&algorithm=SHA1&digits={TOTP_DIGITS}&period={TOTP_STEP_SECONDS}"
482 )
483}
484
485/// A code as typed, tidied: spaces and hyphens taken out, lowercased.
486pub fn tidy_code(code: &str) -> String {
487 code.chars().filter(|c| !c.is_whitespace() && *c != '-').collect::<String>().to_lowercase()
488}
489
490/// Whether a tidied code is shaped like an app's: six digits.
491pub fn is_totp_shaped(code: &str) -> bool {
492 code.len() == TOTP_DIGITS as usize && code.chars().all(|c| c.is_ascii_digit())
493}
494
495// --- Staff ---
496
497/// `admin_user` (takes `UsernameArgs`): one account's addresses and
498/// security log, for staff. Returns `Option<AdminUser>`.
499#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
500#[serde(rename_all = "camelCase")]
501pub struct AdminUser {
502 pub id: String,
503 pub username: String,
504 /// RFC 3339.
505 pub created_at: String,
506 pub emails: Vec<AccountEmail>,
507 pub private_email: bool,
508 pub log: Vec<SecurityEvent>,
509 /// What deleting it would take, and what stands in the way (its
510 /// workspaces' billing is not asked for staff).
511 #[serde(default)]
512 pub deletion: crate::account_deletion::AccountDeletion,
513 /// Set while it is deleted and not yet purged.
514 #[serde(default)]
515 pub deleted: Option<crate::account_deletion::DeletedAccount>,
516 /// The shared invite link it was made with, if it was. Sudo shows
517 /// "Joined through <label>".
518 #[serde(default)]
519 pub joined_through: Option<crate::identity::SharedInviteSource>,
520 /// Set while staff have it suspended.
521 #[serde(default)]
522 pub suspended: Option<crate::identity::Suspension>,
523}
524
525/// `admin_remove_email`: staff remove an address from an account, such as
526/// an unconfirmed one someone else needs or a compromised one. Never the
527/// last confirmed address; removing the primary makes the oldest other
528/// confirmed address primary. Recorded in the person's security log with
529/// the reason, and the person is told. Returns `Outcome<AdminUser>`.
530#[derive(Debug, Serialize, Deserialize)]
531pub struct AdminRemoveEmailArgs {
532 pub username: String,
533 pub email: String,
534 pub reason: String,
535 /// The staff member, by email.
536 pub staff: String,
537}
538
539// --- Workspace policy ---
540
541/// What a workspace asks of its members' accounts. Nothing, today, for
542/// every workspace ([`WorkspacePolicy::default`]); identity already checks
543/// it wherever someone joins a workspace or uses access to one, so asking
544/// for more is a matter of storing it.
545#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(rename_all = "camelCase")]
547pub struct WorkspacePolicy {
548 /// Members need a confirmed address at one of these domains. Empty:
549 /// any domain.
550 #[serde(default)]
551 pub allowed_email_domains: Vec<String>,
552 /// Members need a confirmed address at all.
553 #[serde(default)]
554 pub require_verified_email: bool,
555 /// Members need a second factor on their account.
556 #[serde(default)]
557 pub require_two_factor: bool,
558}
559
560/// What a policy can ask about an account.
561#[derive(Clone, Debug, Default, PartialEq, Eq)]
562pub struct SecurityFacts {
563 /// Lowercased.
564 pub verified_emails: Vec<String>,
565 pub two_factor: bool,
566}
567
568/// What an account lacks to meet a workspace's policy.
569#[derive(Clone, Debug, PartialEq, Eq)]
570pub enum PolicyGap {
571 VerifiedEmail,
572 EmailDomain(Vec<String>),
573 TwoFactor,
574}
575
576impl PolicyGap {
577 /// Its name: `verified_email`, `email_domain` or `two_factor`.
578 pub fn as_str(&self) -> &'static str {
579 match self {
580 PolicyGap::VerifiedEmail => "verified_email",
581 PolicyGap::EmailDomain(_) => "email_domain",
582 PolicyGap::TwoFactor => "two_factor",
583 }
584 }
585
586 /// What to tell the person, for a workspace named `slug`.
587 pub fn message(&self, slug: &str) -> String {
588 match self {
589 PolicyGap::VerifiedEmail => format!("{slug} needs members to have a confirmed email address."),
590 PolicyGap::EmailDomain(domains) => format!(
591 "{slug} needs members to have a confirmed address at {}. Add one in your account settings.",
592 domains.join(" or ")
593 ),
594 PolicyGap::TwoFactor => format!("{slug} requires two-factor authentication. Turn it on in your account's security settings to use it again."),
595 }
596 }
597}
598
599impl WorkspacePolicy {
600 /// Whether the policy asks for anything, so callers can skip gathering
601 /// [`SecurityFacts`] when it does not.
602 pub fn asks_nothing(&self) -> bool {
603 self.allowed_email_domains.is_empty() && !self.require_verified_email && !self.require_two_factor
604 }
605
606 /// Everything the account lacks, or an empty list when it meets the
607 /// policy.
608 pub fn gaps(&self, facts: &SecurityFacts) -> Vec<PolicyGap> {
609 let mut gaps = Vec::new();
610 if self.require_verified_email && facts.verified_emails.is_empty() {
611 gaps.push(PolicyGap::VerifiedEmail);
612 }
613 if !self.allowed_email_domains.is_empty() {
614 let allowed: Vec<String> = self.allowed_email_domains.iter().map(|domain| domain.trim().trim_start_matches('@').to_lowercase()).collect();
615 let has = facts.verified_emails.iter().any(|email| {
616 email
617 .rsplit_once('@')
618 .is_some_and(|(_, domain)| allowed.iter().any(|allowed| domain == allowed))
619 });
620 if !has {
621 gaps.push(PolicyGap::EmailDomain(allowed));
622 }
623 }
624 if self.require_two_factor && !facts.two_factor {
625 gaps.push(PolicyGap::TwoFactor);
626 }
627 gaps
628 }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 #[test]
636 fn a_confirmation_code_is_six_digits_however_it_is_typed() {
637 assert_eq!(tidy_confirm_code("482913").as_deref(), Some("482913"));
638 assert_eq!(tidy_confirm_code(" 482 913 ").as_deref(), Some("482913"));
639 assert_eq!(tidy_confirm_code("482-913").as_deref(), Some("482913"));
640 assert_eq!(tidy_confirm_code("48291"), None);
641 assert_eq!(tidy_confirm_code("4829134"), None);
642 assert_eq!(tidy_confirm_code("48291a"), None);
643 assert_eq!(tidy_confirm_code(""), None);
644 }
645
646 #[test]
647 fn a_pending_account_is_a_person_without_a_confirmed_address() {
648 let person = User { id: "usr_1".into(), username: "ada".into(), ..User::default() };
649 assert!(person.awaits_confirmation());
650 assert!(!User { verified: true, ..person.clone() }.awaits_confirmation());
651 // A workspace's token, an agent and g1t itself are never pending.
652 assert!(!User { kind: crate::PrincipalKind::Workspace, ..person.clone() }.awaits_confirmation());
653 assert!(!User { kind: crate::PrincipalKind::Agent, ..person.clone() }.awaits_confirmation());
654 assert!(!User::system("acme").awaits_confirmation());
655 let said = confirm_email_first("https://git.example.com/");
656 assert!(said.contains("https://git.example.com/confirm-email"));
657 assert!(said.starts_with("Confirm your email address first"));
658 }
659
660 #[test]
661 fn a_push_guard_matches_the_persons_own_addresses_and_masks_them() {
662 let guard = PushEmailGuard { emails: vec!["sam@gmail.com".into()], noreply: "abc+sam@users.noreply.g1t.sh".into() };
663 assert!(guard.exposes(" Sam@Gmail.com"));
664 assert!(!guard.exposes("abc+sam@users.noreply.g1t.sh"));
665 assert!(!guard.exposes("someone@gmail.com"));
666 assert!(!guard.exposes(""));
667 assert_eq!(mask_email("sam@gmail.com"), "s***@gmail.com");
668 assert_eq!(mask_email("nope"), "***");
669 }
670
671 fn state(email: &str, verified: bool, primary: bool) -> EmailState {
672 EmailState { email: email.into(), verified, primary }
673 }
674
675 #[test]
676 fn addresses_are_trimmed_lowercased_and_checked() {
677 assert_eq!(normalize_email(" Ada@Example.COM "), Some("ada@example.com".into()));
678 assert_eq!(normalize_email("ada+g1t@mail.example.co.uk"), Some("ada+g1t@mail.example.co.uk".into()));
679 for bad in ["", "ada", "@example.com", "ada@example", "ada@@example.com", "a da@example.com", "ada@.com", "ada@example."] {
680 assert_eq!(normalize_email(bad), None, "{bad}");
681 }
682 assert_eq!(normalize_email(&format!("{}@example.com", "a".repeat(250))), None);
683 }
684
685 #[test]
686 fn the_noreply_address_carries_the_end_of_the_id_and_the_username() {
687 let address = noreply_address("usr_01j9zq4m8x7k2v5n3b6c1d0efg", "Ada");
688 assert_eq!(address, "6c1d0efg+ada@users.noreply.g1t.sh");
689 assert_eq!(parse_noreply(&address), Some(("6c1d0efg".into(), "ada".into())));
690 assert_eq!(parse_noreply("6C1D0EFG+Ada@Users.Noreply.G1T.sh"), Some(("6c1d0efg".into(), "ada".into())));
691 assert_eq!(parse_noreply("ada@example.com"), None);
692 assert_eq!(parse_noreply("short+ada@users.noreply.g1t.sh"), None);
693 assert_eq!(parse_noreply("6c1d0efg@users.noreply.g1t.sh"), None);
694 assert_eq!(parse_noreply("6c1d0efg+@users.noreply.g1t.sh"), None);
695 assert_eq!(id_suffix("usr_x"), "usr_x");
696 }
697
698 #[test]
699 fn a_sign_in_is_recent_for_ten_minutes() {
700 let now = 1_800_000_000_000;
701 let at = |ms_ago: u64| crate::time::rfc3339(now - ms_ago);
702 assert!(is_recent(Some(&at(0)), now, RECENT_AUTH_SECONDS));
703 assert!(is_recent(Some(&at(9 * 60 * 1000)), now, RECENT_AUTH_SECONDS));
704 assert!(is_recent(Some(&at(10 * 60 * 1000)), now, RECENT_AUTH_SECONDS));
705 assert!(!is_recent(Some(&at(10 * 60 * 1000 + 1)), now, RECENT_AUTH_SECONDS));
706 assert!(!is_recent(None, now, RECENT_AUTH_SECONDS));
707 }
708
709 #[test]
710 fn neither_the_primary_nor_the_last_confirmed_address_can_be_removed() {
711 let all = [state("a@x.io", true, true), state("b@x.io", true, false), state("c@x.io", false, false)];
712 assert!(removal_refusal(&all, "a@x.io").unwrap().contains("primary"));
713 assert_eq!(removal_refusal(&all, "b@x.io"), None);
714 assert_eq!(removal_refusal(&all, "c@x.io"), None);
715 assert!(removal_refusal(&all, "d@x.io").is_some());
716 // An unconfirmed primary (a new account) stays; so does the only
717 // confirmed address, primary or not.
718 let lone = [state("a@x.io", false, true), state("b@x.io", true, false)];
719 assert!(removal_refusal(&lone, "b@x.io").unwrap().contains("only confirmed"));
720 }
721
722 #[test]
723 fn only_a_confirmed_address_can_be_primary() {
724 let all = [state("a@x.io", true, true), state("b@x.io", false, false)];
725 assert_eq!(primary_refusal(&all, "a@x.io"), None);
726 assert!(primary_refusal(&all, "b@x.io").unwrap().contains("Confirm"));
727 assert!(primary_refusal(&all, "z@x.io").is_some());
728 }
729
730 #[test]
731 fn the_otpauth_address_names_the_account_and_issuer() {
732 let uri = otpauth_uri("JBSWY3DPEHPK3PXP", "ada lovelace");
733 assert_eq!(
734 uri,
735 "otpauth://totp/g1t:ada%20lovelace?secret=JBSWY3DPEHPK3PXP&issuer=g1t&algorithm=SHA1&digits=6&period=30"
736 );
737 }
738
739 #[test]
740 fn codes_are_tidied_before_they_are_checked() {
741 assert_eq!(tidy_code(" 123 456 "), "123456");
742 assert!(is_totp_shaped(&tidy_code("123-456")));
743 assert!(!is_totp_shaped("12345"));
744 assert!(!is_totp_shaped("abcdef"));
745 assert_eq!(tidy_code("ABCD-EFGH-IJ"), "abcdefghij");
746 }
747
748 #[test]
749 fn the_default_policy_asks_nothing_and_any_account_meets_it() {
750 let policy = WorkspacePolicy::default();
751 assert!(policy.asks_nothing());
752 assert!(policy.gaps(&SecurityFacts::default()).is_empty());
753 }
754
755 #[test]
756 fn a_policy_names_everything_an_account_lacks() {
757 let policy = WorkspacePolicy {
758 allowed_email_domains: vec!["@Acme.com".into()],
759 require_verified_email: true,
760 require_two_factor: true,
761 };
762 assert!(!policy.asks_nothing());
763 assert_eq!(
764 policy.gaps(&SecurityFacts::default()),
765 vec![PolicyGap::VerifiedEmail, PolicyGap::EmailDomain(vec!["acme.com".into()]), PolicyGap::TwoFactor]
766 );
767 let member = SecurityFacts { verified_emails: vec!["ada@gmail.com".into(), "ada@acme.com".into()], two_factor: true };
768 assert!(policy.gaps(&member).is_empty());
769 let lookalike = SecurityFacts { verified_emails: vec!["ada@notacme.com".into()], two_factor: true };
770 assert_eq!(policy.gaps(&lookalike), vec![PolicyGap::EmailDomain(vec!["acme.com".into()])]);
771 assert!(PolicyGap::EmailDomain(vec!["acme.com".into()]).message("acme").contains("acme.com"));
772 }
773}