Skip to content
770 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}
521
522/// `admin_remove_email`: staff remove an address from an account, such as
523/// an unconfirmed one someone else needs or a compromised one. Never the
524/// last confirmed address; removing the primary makes the oldest other
525/// confirmed address primary. Recorded in the person's security log with
526/// the reason, and the person is told. Returns `Outcome<AdminUser>`.
527#[derive(Debug, Serialize, Deserialize)]
528pub struct AdminRemoveEmailArgs {
529 pub username: String,
530 pub email: String,
531 pub reason: String,
532 /// The staff member, by email.
533 pub staff: String,
534}
535
536// --- Workspace policy ---
537
538/// What a workspace asks of its members' accounts. Nothing, today, for
539/// every workspace ([`WorkspacePolicy::default`]); identity already checks
540/// it wherever someone joins a workspace or uses access to one, so asking
541/// for more is a matter of storing it.
542#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
543#[serde(rename_all = "camelCase")]
544pub struct WorkspacePolicy {
545 /// Members need a confirmed address at one of these domains. Empty:
546 /// any domain.
547 #[serde(default)]
548 pub allowed_email_domains: Vec<String>,
549 /// Members need a confirmed address at all.
550 #[serde(default)]
551 pub require_verified_email: bool,
552 /// Members need a second factor on their account.
553 #[serde(default)]
554 pub require_two_factor: bool,
555}
556
557/// What a policy can ask about an account.
558#[derive(Clone, Debug, Default, PartialEq, Eq)]
559pub struct SecurityFacts {
560 /// Lowercased.
561 pub verified_emails: Vec<String>,
562 pub two_factor: bool,
563}
564
565/// What an account lacks to meet a workspace's policy.
566#[derive(Clone, Debug, PartialEq, Eq)]
567pub enum PolicyGap {
568 VerifiedEmail,
569 EmailDomain(Vec<String>),
570 TwoFactor,
571}
572
573impl PolicyGap {
574 /// Its name: `verified_email`, `email_domain` or `two_factor`.
575 pub fn as_str(&self) -> &'static str {
576 match self {
577 PolicyGap::VerifiedEmail => "verified_email",
578 PolicyGap::EmailDomain(_) => "email_domain",
579 PolicyGap::TwoFactor => "two_factor",
580 }
581 }
582
583 /// What to tell the person, for a workspace named `slug`.
584 pub fn message(&self, slug: &str) -> String {
585 match self {
586 PolicyGap::VerifiedEmail => format!("{slug} needs members to have a confirmed email address."),
587 PolicyGap::EmailDomain(domains) => format!(
588 "{slug} needs members to have a confirmed address at {}. Add one in your account settings.",
589 domains.join(" or ")
590 ),
591 PolicyGap::TwoFactor => format!("{slug} requires two-factor authentication. Turn it on in your account's security settings to use it again."),
592 }
593 }
594}
595
596impl WorkspacePolicy {
597 /// Whether the policy asks for anything, so callers can skip gathering
598 /// [`SecurityFacts`] when it does not.
599 pub fn asks_nothing(&self) -> bool {
600 self.allowed_email_domains.is_empty() && !self.require_verified_email && !self.require_two_factor
601 }
602
603 /// Everything the account lacks, or an empty list when it meets the
604 /// policy.
605 pub fn gaps(&self, facts: &SecurityFacts) -> Vec<PolicyGap> {
606 let mut gaps = Vec::new();
607 if self.require_verified_email && facts.verified_emails.is_empty() {
608 gaps.push(PolicyGap::VerifiedEmail);
609 }
610 if !self.allowed_email_domains.is_empty() {
611 let allowed: Vec<String> = self.allowed_email_domains.iter().map(|domain| domain.trim().trim_start_matches('@').to_lowercase()).collect();
612 let has = facts.verified_emails.iter().any(|email| {
613 email
614 .rsplit_once('@')
615 .is_some_and(|(_, domain)| allowed.iter().any(|allowed| domain == allowed))
616 });
617 if !has {
618 gaps.push(PolicyGap::EmailDomain(allowed));
619 }
620 }
621 if self.require_two_factor && !facts.two_factor {
622 gaps.push(PolicyGap::TwoFactor);
623 }
624 gaps
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[test]
633 fn a_confirmation_code_is_six_digits_however_it_is_typed() {
634 assert_eq!(tidy_confirm_code("482913").as_deref(), Some("482913"));
635 assert_eq!(tidy_confirm_code(" 482 913 ").as_deref(), Some("482913"));
636 assert_eq!(tidy_confirm_code("482-913").as_deref(), Some("482913"));
637 assert_eq!(tidy_confirm_code("48291"), None);
638 assert_eq!(tidy_confirm_code("4829134"), None);
639 assert_eq!(tidy_confirm_code("48291a"), None);
640 assert_eq!(tidy_confirm_code(""), None);
641 }
642
643 #[test]
644 fn a_pending_account_is_a_person_without_a_confirmed_address() {
645 let person = User { id: "usr_1".into(), username: "ada".into(), ..User::default() };
646 assert!(person.awaits_confirmation());
647 assert!(!User { verified: true, ..person.clone() }.awaits_confirmation());
648 // A workspace's token, an agent and g1t itself are never pending.
649 assert!(!User { kind: crate::PrincipalKind::Workspace, ..person.clone() }.awaits_confirmation());
650 assert!(!User { kind: crate::PrincipalKind::Agent, ..person.clone() }.awaits_confirmation());
651 assert!(!User::system("acme").awaits_confirmation());
652 let said = confirm_email_first("https://git.example.com/");
653 assert!(said.contains("https://git.example.com/confirm-email"));
654 assert!(said.starts_with("Confirm your email address first"));
655 }
656
657 #[test]
658 fn a_push_guard_matches_the_persons_own_addresses_and_masks_them() {
659 let guard = PushEmailGuard { emails: vec!["sam@gmail.com".into()], noreply: "abc+sam@users.noreply.g1t.sh".into() };
660 assert!(guard.exposes(" Sam@Gmail.com"));
661 assert!(!guard.exposes("abc+sam@users.noreply.g1t.sh"));
662 assert!(!guard.exposes("someone@gmail.com"));
663 assert!(!guard.exposes(""));
664 assert_eq!(mask_email("sam@gmail.com"), "s***@gmail.com");
665 assert_eq!(mask_email("nope"), "***");
666 }
667
668 fn state(email: &str, verified: bool, primary: bool) -> EmailState {
669 EmailState { email: email.into(), verified, primary }
670 }
671
672 #[test]
673 fn addresses_are_trimmed_lowercased_and_checked() {
674 assert_eq!(normalize_email(" Ada@Example.COM "), Some("ada@example.com".into()));
675 assert_eq!(normalize_email("ada+g1t@mail.example.co.uk"), Some("ada+g1t@mail.example.co.uk".into()));
676 for bad in ["", "ada", "@example.com", "ada@example", "ada@@example.com", "a da@example.com", "ada@.com", "ada@example."] {
677 assert_eq!(normalize_email(bad), None, "{bad}");
678 }
679 assert_eq!(normalize_email(&format!("{}@example.com", "a".repeat(250))), None);
680 }
681
682 #[test]
683 fn the_noreply_address_carries_the_end_of_the_id_and_the_username() {
684 let address = noreply_address("usr_01j9zq4m8x7k2v5n3b6c1d0efg", "Ada");
685 assert_eq!(address, "6c1d0efg+ada@users.noreply.g1t.sh");
686 assert_eq!(parse_noreply(&address), Some(("6c1d0efg".into(), "ada".into())));
687 assert_eq!(parse_noreply("6C1D0EFG+Ada@Users.Noreply.G1T.sh"), Some(("6c1d0efg".into(), "ada".into())));
688 assert_eq!(parse_noreply("ada@example.com"), None);
689 assert_eq!(parse_noreply("short+ada@users.noreply.g1t.sh"), None);
690 assert_eq!(parse_noreply("6c1d0efg@users.noreply.g1t.sh"), None);
691 assert_eq!(parse_noreply("6c1d0efg+@users.noreply.g1t.sh"), None);
692 assert_eq!(id_suffix("usr_x"), "usr_x");
693 }
694
695 #[test]
696 fn a_sign_in_is_recent_for_ten_minutes() {
697 let now = 1_800_000_000_000;
698 let at = |ms_ago: u64| crate::time::rfc3339(now - ms_ago);
699 assert!(is_recent(Some(&at(0)), now, RECENT_AUTH_SECONDS));
700 assert!(is_recent(Some(&at(9 * 60 * 1000)), now, RECENT_AUTH_SECONDS));
701 assert!(is_recent(Some(&at(10 * 60 * 1000)), now, RECENT_AUTH_SECONDS));
702 assert!(!is_recent(Some(&at(10 * 60 * 1000 + 1)), now, RECENT_AUTH_SECONDS));
703 assert!(!is_recent(None, now, RECENT_AUTH_SECONDS));
704 }
705
706 #[test]
707 fn neither_the_primary_nor_the_last_confirmed_address_can_be_removed() {
708 let all = [state("a@x.io", true, true), state("b@x.io", true, false), state("c@x.io", false, false)];
709 assert!(removal_refusal(&all, "a@x.io").unwrap().contains("primary"));
710 assert_eq!(removal_refusal(&all, "b@x.io"), None);
711 assert_eq!(removal_refusal(&all, "c@x.io"), None);
712 assert!(removal_refusal(&all, "d@x.io").is_some());
713 // An unconfirmed primary (a new account) stays; so does the only
714 // confirmed address, primary or not.
715 let lone = [state("a@x.io", false, true), state("b@x.io", true, false)];
716 assert!(removal_refusal(&lone, "b@x.io").unwrap().contains("only confirmed"));
717 }
718
719 #[test]
720 fn only_a_confirmed_address_can_be_primary() {
721 let all = [state("a@x.io", true, true), state("b@x.io", false, false)];
722 assert_eq!(primary_refusal(&all, "a@x.io"), None);
723 assert!(primary_refusal(&all, "b@x.io").unwrap().contains("Confirm"));
724 assert!(primary_refusal(&all, "z@x.io").is_some());
725 }
726
727 #[test]
728 fn the_otpauth_address_names_the_account_and_issuer() {
729 let uri = otpauth_uri("JBSWY3DPEHPK3PXP", "ada lovelace");
730 assert_eq!(
731 uri,
732 "otpauth://totp/g1t:ada%20lovelace?secret=JBSWY3DPEHPK3PXP&issuer=g1t&algorithm=SHA1&digits=6&period=30"
733 );
734 }
735
736 #[test]
737 fn codes_are_tidied_before_they_are_checked() {
738 assert_eq!(tidy_code(" 123 456 "), "123456");
739 assert!(is_totp_shaped(&tidy_code("123-456")));
740 assert!(!is_totp_shaped("12345"));
741 assert!(!is_totp_shaped("abcdef"));
742 assert_eq!(tidy_code("ABCD-EFGH-IJ"), "abcdefghij");
743 }
744
745 #[test]
746 fn the_default_policy_asks_nothing_and_any_account_meets_it() {
747 let policy = WorkspacePolicy::default();
748 assert!(policy.asks_nothing());
749 assert!(policy.gaps(&SecurityFacts::default()).is_empty());
750 }
751
752 #[test]
753 fn a_policy_names_everything_an_account_lacks() {
754 let policy = WorkspacePolicy {
755 allowed_email_domains: vec!["@Acme.com".into()],
756 require_verified_email: true,
757 require_two_factor: true,
758 };
759 assert!(!policy.asks_nothing());
760 assert_eq!(
761 policy.gaps(&SecurityFacts::default()),
762 vec![PolicyGap::VerifiedEmail, PolicyGap::EmailDomain(vec!["acme.com".into()]), PolicyGap::TwoFactor]
763 );
764 let member = SecurityFacts { verified_emails: vec!["ada@gmail.com".into(), "ada@acme.com".into()], two_factor: true };
765 assert!(policy.gaps(&member).is_empty());
766 let lookalike = SecurityFacts { verified_emails: vec!["ada@notacme.com".into()], two_factor: true };
767 assert_eq!(policy.gaps(&lookalike), vec![PolicyGap::EmailDomain(vec!["acme.com".into()])]);
768 assert!(PolicyGap::EmailDomain(vec!["acme.com".into()]).message("acme").contains("acme.com"));
769 }
770}