Skip to content
765 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1//! 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//!
Merge main (membership, two-factor, GitHub repo roles) into tokens12//! 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.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look20
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
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)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 /// Why the invite the account signed up with no longer applies, when it
250 /// was revoked, expired or its workspace deleted while the account
251 /// waited. The address is confirmed all the same.
252 #[serde(default)]
253 pub invite_lapsed: Option<String>,
254}
255
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look256/// `update_email_settings`: each field given is changed. `primary` must be
257/// a confirmed address. `backup` is a confirmed address to get security
258/// notices too, or empty for the primary only. Changing either needs
259/// [`Reauth`]; the privacy switches do not. Returns `Outcome<AccountEmails>`.
260#[derive(Debug, Default, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub struct EmailSettingsArgs {
263 pub user: User,
264 #[serde(default)]
265 pub primary: Option<String>,
266 #[serde(default)]
267 pub backup: Option<String>,
268 #[serde(default)]
269 pub private_email: Option<bool>,
270 #[serde(default)]
271 pub block_private_pushes: Option<bool>,
272 #[serde(default)]
273 pub reauth: Reauth,
274}
275
276/// `reauthenticate`: the person typed their password again for the session
277/// they are using; sensitive changes need no more proof for
278/// [`RECENT_AUTH_SECONDS`]. Returns `Outcome<bool>`.
279#[derive(Debug, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase")]
281pub struct ReauthenticateArgs {
282 pub session_token: String,
283 pub password: String,
284 #[serde(default)]
285 pub client: Option<String>,
286}
287
288/// `email_owners`: who wrote commits, by their author addresses. Matches
289/// confirmed addresses and noreply addresses only, never an unconfirmed
290/// one. At most 200 addresses. Returns a map from each address that
291/// matched, lowercased, to its owner.
292#[derive(Debug, Serialize, Deserialize)]
293pub struct EmailOwnersArgs {
294 pub emails: Vec<String>,
295}
296
297/// The account an address belongs to.
298#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
299pub struct EmailOwner {
300 pub id: String,
301 pub username: String,
302 pub avatar: Option<String>,
303}
304
305/// `commit_identity`: the name and address to put on a commit g1t makes for
306/// a person (a merge, a web edit, catching a branch up). Their noreply
307/// address while they keep their address private, otherwise their primary.
308/// Returns `Option<CommitIdentity>`; null for an unknown account.
309#[derive(Debug, Serialize, Deserialize)]
310#[serde(rename_all = "camelCase")]
311pub struct CommitIdentityArgs {
312 pub user_id: String,
313}
314
315#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
316pub struct CommitIdentity {
317 pub name: String,
318 pub email: String,
319}
320
321/// `push_email_guard` (takes `CommitIdentityArgs`): what a push by this
322/// person must not publish. Returns `Option<PushEmailGuard>`: null unless
323/// they keep their address private and block pushes that expose it.
324#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
325pub struct PushEmailGuard {
326 /// Their confirmed addresses, lowercased.
327 pub emails: Vec<String>,
328 /// The address to commit with instead.
329 pub noreply: String,
330}
331
332impl PushEmailGuard {
333 /// Whether a commit carrying `email` would publish one of the
334 /// person's addresses.
335 pub fn exposes(&self, email: &str) -> bool {
336 let email = email.trim().to_lowercase();
337 !email.is_empty() && self.emails.contains(&email)
338 }
339}
340
341/// An address with all but the first letter of its local part hidden:
342/// `s***@gmail.com`.
343pub fn mask_email(email: &str) -> String {
344 match email.split_once('@') {
345 Some((local, domain)) => {
346 let first: String = local.chars().take(1).collect();
347 format!("{first}***@{domain}")
348 }
349 None => "***".to_owned(),
350 }
351}
352
353/// Something that happened to an account's security. `security_log` (takes
354/// `UserArgs`) returns the newest [`SECURITY_LOG_LIMIT`], newest first.
355#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
356#[serde(rename_all = "camelCase")]
357pub struct SecurityEvent {
358 /// `email_added`, `email_verified`, `email_removed`,
359 /// `primary_email_changed`, `backup_email_changed`,
Merge main (membership, two-factor, GitHub repo roles) into tokens360 /// `email_privacy_changed`, `password_changed`, `two_factor_enabled`,
361 /// `two_factor_disabled`, `recovery_codes_regenerated`,
362 /// `recovery_code_used`, `token_created`, `token_deleted`,
363 /// `token_rescoped`, `ssh_key_added`, `ssh_key_removed`,
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)364 /// `oauth_grant_created`, `oauth_grant_revoked`,
365 /// `oauth_grant_rescoped`, `account_deleted` or `account_restored`
366 /// (seen by staff while the account waits to be purged).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look367 pub kind: String,
368 /// The address concerned, or what changed.
369 pub detail: Option<String>,
370 /// Whether g1t staff made the change.
371 pub by_staff: bool,
372 /// Why staff made it.
373 pub reason: Option<String>,
374 /// The staff member, by email. Only in staff views.
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub staff: Option<String>,
377 /// RFC 3339.
378 pub created_at: String,
379}
380
381/// How many entries `security_log` returns.
382pub const SECURITY_LOG_LIMIT: usize = 50;
383
Merge main (membership, two-factor, GitHub repo roles) into tokens384// --- Two-factor authentication ---
385
386/// How long one TOTP code lasts, in seconds (RFC 6238's default).
387pub const TOTP_STEP_SECONDS: u64 = 30;
388/// How many digits a code has.
389pub const TOTP_DIGITS: u32 = 6;
390/// How many steps either side of now a code is accepted from, for clocks
391/// that are a little off: one, so a code works for up to 90 seconds.
392pub const TOTP_SKEW_STEPS: u64 = 1;
393/// How many recovery codes an account gets.
394pub const RECOVERY_CODES: usize = 10;
395/// How long a sign-in waits for its code, in seconds.
396pub const TWO_FACTOR_CHALLENGE_SECONDS: u64 = 10 * 60;
397/// How many wrong codes one sign-in may have before it must start again.
398pub const TWO_FACTOR_ATTEMPTS: u32 = 5;
399/// The issuer authenticator apps show beside the account.
400pub const TOTP_ISSUER: &str = "g1t";
401
402/// Where an account's two-factor authentication stands.
403/// `two_factor_status` (takes `UserArgs`) returns `Outcome<TwoFactorStatus>`.
404#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
405pub struct TwoFactorStatus {
406 pub enabled: bool,
407 /// RFC 3339.
408 pub enabled_at: Option<String>,
409 /// Recovery codes not used yet.
410 pub recovery_codes_left: u32,
411 /// The workspaces the person belongs to that require it.
412 pub required_by: Vec<String>,
413}
414
415/// What an authenticator app needs: the secret in base32, and the same as
416/// an `otpauth://` address for a QR code.
417#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
418pub struct TwoFactorSetup {
419 pub secret: String,
420 pub uri: String,
421}
422
423/// Single-use recovery codes, shown once.
424#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
425pub struct RecoveryCodes {
426 pub codes: Vec<String>,
427}
428
429/// `two_factor_start`: begins turning it on, replacing any enrolment in
430/// progress. Needs [`Reauth`]. Refused while it is on. Returns
431/// `Outcome<TwoFactorSetup>`.
432///
433/// `two_factor_recovery_codes`: makes new recovery codes, replacing the
434/// old ones. Needs [`Reauth`] and two-factor on. Returns
435/// `Outcome<RecoveryCodes>`.
436#[derive(Debug, Serialize, Deserialize)]
437pub struct TwoFactorArgs {
438 pub user: User,
439 #[serde(default)]
440 pub reauth: Reauth,
441}
442
443/// `two_factor_enable`: a code from the app confirms the enrolment, and
444/// two-factor is on; returns the recovery codes, shown once.
445/// `two_factor_disable`: turns it off; needs a code (or a recovery code)
446/// as well as [`Reauth`]. Refused for an owner of a workspace that
447/// requires it. Both return `Outcome<...>`: `RecoveryCodes` and `bool`.
448#[derive(Debug, Serialize, Deserialize)]
449pub struct TwoFactorCodeArgs {
450 pub user: User,
451 pub code: String,
452 #[serde(default)]
453 pub reauth: Reauth,
454}
455
456/// `two_factor_sign_in`: the second step of signing in. `challenge` is
457/// what `sign_in` returned as `SignedIn::two_factor_challenge`; `code` is a
458/// code from the app or a recovery code. Returns `Outcome<SignedIn>`, with
459/// a session.
460#[derive(Debug, Serialize, Deserialize)]
461pub struct TwoFactorSignInArgs {
462 pub challenge: String,
463 pub code: String,
464 #[serde(default)]
465 pub client: Option<String>,
466}
467
468/// The `otpauth://` address for a secret, as authenticator apps read it
469/// from a QR code.
470pub fn otpauth_uri(secret_base32: &str, username: &str) -> String {
471 let label: String = format!("{TOTP_ISSUER}:{username}")
472 .chars()
473 .map(|c| if c.is_ascii_alphanumeric() || "-._~:".contains(c) { c.to_string() } else { format!("%{:02X}", c as u32) })
474 .collect();
475 format!(
476 "otpauth://totp/{label}?secret={secret_base32}&issuer={TOTP_ISSUER}&algorithm=SHA1&digits={TOTP_DIGITS}&period={TOTP_STEP_SECONDS}"
477 )
478}
479
480/// A code as typed, tidied: spaces and hyphens taken out, lowercased.
481pub fn tidy_code(code: &str) -> String {
482 code.chars().filter(|c| !c.is_whitespace() && *c != '-').collect::<String>().to_lowercase()
483}
484
485/// Whether a tidied code is shaped like an app's: six digits.
486pub fn is_totp_shaped(code: &str) -> bool {
487 code.len() == TOTP_DIGITS as usize && code.chars().all(|c| c.is_ascii_digit())
488}
489
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look490// --- Staff ---
491
492/// `admin_user` (takes `UsernameArgs`): one account's addresses and
493/// security log, for staff. Returns `Option<AdminUser>`.
494#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
495#[serde(rename_all = "camelCase")]
496pub struct AdminUser {
497 pub id: String,
498 pub username: String,
499 /// RFC 3339.
500 pub created_at: String,
501 pub emails: Vec<AccountEmail>,
502 pub private_email: bool,
503 pub log: Vec<SecurityEvent>,
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)504 /// What deleting it would take, and what stands in the way (its
505 /// workspaces' billing is not asked for staff).
506 #[serde(default)]
507 pub deletion: crate::account_deletion::AccountDeletion,
508 /// Set while it is deleted and not yet purged.
509 #[serde(default)]
510 pub deleted: Option<crate::account_deletion::DeletedAccount>,
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)511 /// The shared invite link it was made with, if it was. Sudo shows
512 /// "Joined through <label>".
513 #[serde(default)]
514 pub joined_through: Option<crate::identity::SharedInviteSource>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look515}
516
517/// `admin_remove_email`: staff remove an address from an account, such as
518/// an unconfirmed one someone else needs or a compromised one. Never the
519/// last confirmed address; removing the primary makes the oldest other
520/// confirmed address primary. Recorded in the person's security log with
521/// the reason, and the person is told. Returns `Outcome<AdminUser>`.
522#[derive(Debug, Serialize, Deserialize)]
523pub struct AdminRemoveEmailArgs {
524 pub username: String,
525 pub email: String,
526 pub reason: String,
527 /// The staff member, by email.
528 pub staff: String,
529}
530
531// --- Workspace policy ---
532
533/// What a workspace asks of its members' accounts. Nothing, today, for
534/// every workspace ([`WorkspacePolicy::default`]); identity already checks
535/// it wherever someone joins a workspace or uses access to one, so asking
536/// for more is a matter of storing it.
537#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
538#[serde(rename_all = "camelCase")]
539pub struct WorkspacePolicy {
540 /// Members need a confirmed address at one of these domains. Empty:
541 /// any domain.
542 #[serde(default)]
543 pub allowed_email_domains: Vec<String>,
544 /// Members need a confirmed address at all.
545 #[serde(default)]
546 pub require_verified_email: bool,
547 /// Members need a second factor on their account.
548 #[serde(default)]
549 pub require_two_factor: bool,
550}
551
552/// What a policy can ask about an account.
553#[derive(Clone, Debug, Default, PartialEq, Eq)]
554pub struct SecurityFacts {
555 /// Lowercased.
556 pub verified_emails: Vec<String>,
557 pub two_factor: bool,
558}
559
560/// What an account lacks to meet a workspace's policy.
561#[derive(Clone, Debug, PartialEq, Eq)]
562pub enum PolicyGap {
563 VerifiedEmail,
564 EmailDomain(Vec<String>),
565 TwoFactor,
566}
567
568impl PolicyGap {
Merge main (membership, two-factor, GitHub repo roles) into tokens569 /// Its name: `verified_email`, `email_domain` or `two_factor`.
570 pub fn as_str(&self) -> &'static str {
571 match self {
572 PolicyGap::VerifiedEmail => "verified_email",
573 PolicyGap::EmailDomain(_) => "email_domain",
574 PolicyGap::TwoFactor => "two_factor",
575 }
576 }
577
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look578 /// What to tell the person, for a workspace named `slug`.
579 pub fn message(&self, slug: &str) -> String {
580 match self {
581 PolicyGap::VerifiedEmail => format!("{slug} needs members to have a confirmed email address."),
582 PolicyGap::EmailDomain(domains) => format!(
583 "{slug} needs members to have a confirmed address at {}. Add one in your account settings.",
584 domains.join(" or ")
585 ),
Merge main (membership, two-factor, GitHub repo roles) into tokens586 PolicyGap::TwoFactor => format!("{slug} requires two-factor authentication. Turn it on in your account's security settings to use it again."),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look587 }
588 }
589}
590
591impl WorkspacePolicy {
592 /// Whether the policy asks for anything, so callers can skip gathering
593 /// [`SecurityFacts`] when it does not.
594 pub fn asks_nothing(&self) -> bool {
595 self.allowed_email_domains.is_empty() && !self.require_verified_email && !self.require_two_factor
596 }
597
598 /// Everything the account lacks, or an empty list when it meets the
599 /// policy.
600 pub fn gaps(&self, facts: &SecurityFacts) -> Vec<PolicyGap> {
601 let mut gaps = Vec::new();
602 if self.require_verified_email && facts.verified_emails.is_empty() {
603 gaps.push(PolicyGap::VerifiedEmail);
604 }
605 if !self.allowed_email_domains.is_empty() {
606 let allowed: Vec<String> = self.allowed_email_domains.iter().map(|domain| domain.trim().trim_start_matches('@').to_lowercase()).collect();
607 let has = facts.verified_emails.iter().any(|email| {
608 email
609 .rsplit_once('@')
610 .is_some_and(|(_, domain)| allowed.iter().any(|allowed| domain == allowed))
611 });
612 if !has {
613 gaps.push(PolicyGap::EmailDomain(allowed));
614 }
615 }
616 if self.require_two_factor && !facts.two_factor {
617 gaps.push(PolicyGap::TwoFactor);
618 }
619 gaps
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 #[test]
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)628 fn a_confirmation_code_is_six_digits_however_it_is_typed() {
629 assert_eq!(tidy_confirm_code("482913").as_deref(), Some("482913"));
630 assert_eq!(tidy_confirm_code(" 482 913 ").as_deref(), Some("482913"));
631 assert_eq!(tidy_confirm_code("482-913").as_deref(), Some("482913"));
632 assert_eq!(tidy_confirm_code("48291"), None);
633 assert_eq!(tidy_confirm_code("4829134"), None);
634 assert_eq!(tidy_confirm_code("48291a"), None);
635 assert_eq!(tidy_confirm_code(""), None);
636 }
637
638 #[test]
639 fn a_pending_account_is_a_person_without_a_confirmed_address() {
640 let person = User { id: "usr_1".into(), username: "ada".into(), ..User::default() };
641 assert!(person.awaits_confirmation());
642 assert!(!User { verified: true, ..person.clone() }.awaits_confirmation());
643 // A workspace's token, an agent and g1t itself are never pending.
644 assert!(!User { kind: crate::PrincipalKind::Workspace, ..person.clone() }.awaits_confirmation());
645 assert!(!User { kind: crate::PrincipalKind::Agent, ..person.clone() }.awaits_confirmation());
646 assert!(!User::system("acme").awaits_confirmation());
647 let said = confirm_email_first("https://git.example.com/");
648 assert!(said.contains("https://git.example.com/confirm-email"));
649 assert!(said.starts_with("Confirm your email address first"));
650 }
651
652 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look653 fn a_push_guard_matches_the_persons_own_addresses_and_masks_them() {
654 let guard = PushEmailGuard { emails: vec!["sam@gmail.com".into()], noreply: "abc+sam@users.noreply.g1t.sh".into() };
655 assert!(guard.exposes(" Sam@Gmail.com"));
656 assert!(!guard.exposes("abc+sam@users.noreply.g1t.sh"));
657 assert!(!guard.exposes("someone@gmail.com"));
658 assert!(!guard.exposes(""));
659 assert_eq!(mask_email("sam@gmail.com"), "s***@gmail.com");
660 assert_eq!(mask_email("nope"), "***");
661 }
662
663 fn state(email: &str, verified: bool, primary: bool) -> EmailState {
664 EmailState { email: email.into(), verified, primary }
665 }
666
667 #[test]
668 fn addresses_are_trimmed_lowercased_and_checked() {
669 assert_eq!(normalize_email(" Ada@Example.COM "), Some("ada@example.com".into()));
670 assert_eq!(normalize_email("ada+g1t@mail.example.co.uk"), Some("ada+g1t@mail.example.co.uk".into()));
671 for bad in ["", "ada", "@example.com", "ada@example", "ada@@example.com", "a da@example.com", "ada@.com", "ada@example."] {
672 assert_eq!(normalize_email(bad), None, "{bad}");
673 }
674 assert_eq!(normalize_email(&format!("{}@example.com", "a".repeat(250))), None);
675 }
676
677 #[test]
678 fn the_noreply_address_carries_the_end_of_the_id_and_the_username() {
679 let address = noreply_address("usr_01j9zq4m8x7k2v5n3b6c1d0efg", "Ada");
680 assert_eq!(address, "6c1d0efg+ada@users.noreply.g1t.sh");
681 assert_eq!(parse_noreply(&address), Some(("6c1d0efg".into(), "ada".into())));
682 assert_eq!(parse_noreply("6C1D0EFG+Ada@Users.Noreply.G1T.sh"), Some(("6c1d0efg".into(), "ada".into())));
683 assert_eq!(parse_noreply("ada@example.com"), None);
684 assert_eq!(parse_noreply("short+ada@users.noreply.g1t.sh"), None);
685 assert_eq!(parse_noreply("6c1d0efg@users.noreply.g1t.sh"), None);
686 assert_eq!(parse_noreply("6c1d0efg+@users.noreply.g1t.sh"), None);
687 assert_eq!(id_suffix("usr_x"), "usr_x");
688 }
689
690 #[test]
691 fn a_sign_in_is_recent_for_ten_minutes() {
692 let now = 1_800_000_000_000;
693 let at = |ms_ago: u64| crate::time::rfc3339(now - ms_ago);
694 assert!(is_recent(Some(&at(0)), now, RECENT_AUTH_SECONDS));
695 assert!(is_recent(Some(&at(9 * 60 * 1000)), now, RECENT_AUTH_SECONDS));
696 assert!(is_recent(Some(&at(10 * 60 * 1000)), now, RECENT_AUTH_SECONDS));
697 assert!(!is_recent(Some(&at(10 * 60 * 1000 + 1)), now, RECENT_AUTH_SECONDS));
698 assert!(!is_recent(None, now, RECENT_AUTH_SECONDS));
699 }
700
701 #[test]
702 fn neither_the_primary_nor_the_last_confirmed_address_can_be_removed() {
703 let all = [state("a@x.io", true, true), state("b@x.io", true, false), state("c@x.io", false, false)];
704 assert!(removal_refusal(&all, "a@x.io").unwrap().contains("primary"));
705 assert_eq!(removal_refusal(&all, "b@x.io"), None);
706 assert_eq!(removal_refusal(&all, "c@x.io"), None);
707 assert!(removal_refusal(&all, "d@x.io").is_some());
708 // An unconfirmed primary (a new account) stays; so does the only
709 // confirmed address, primary or not.
710 let lone = [state("a@x.io", false, true), state("b@x.io", true, false)];
711 assert!(removal_refusal(&lone, "b@x.io").unwrap().contains("only confirmed"));
712 }
713
714 #[test]
715 fn only_a_confirmed_address_can_be_primary() {
716 let all = [state("a@x.io", true, true), state("b@x.io", false, false)];
717 assert_eq!(primary_refusal(&all, "a@x.io"), None);
718 assert!(primary_refusal(&all, "b@x.io").unwrap().contains("Confirm"));
719 assert!(primary_refusal(&all, "z@x.io").is_some());
720 }
721
722 #[test]
Merge main (membership, two-factor, GitHub repo roles) into tokens723 fn the_otpauth_address_names_the_account_and_issuer() {
724 let uri = otpauth_uri("JBSWY3DPEHPK3PXP", "ada lovelace");
725 assert_eq!(
726 uri,
727 "otpauth://totp/g1t:ada%20lovelace?secret=JBSWY3DPEHPK3PXP&issuer=g1t&algorithm=SHA1&digits=6&period=30"
728 );
729 }
730
731 #[test]
732 fn codes_are_tidied_before_they_are_checked() {
733 assert_eq!(tidy_code(" 123 456 "), "123456");
734 assert!(is_totp_shaped(&tidy_code("123-456")));
735 assert!(!is_totp_shaped("12345"));
736 assert!(!is_totp_shaped("abcdef"));
737 assert_eq!(tidy_code("ABCD-EFGH-IJ"), "abcdefghij");
738 }
739
740 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look741 fn the_default_policy_asks_nothing_and_any_account_meets_it() {
742 let policy = WorkspacePolicy::default();
743 assert!(policy.asks_nothing());
744 assert!(policy.gaps(&SecurityFacts::default()).is_empty());
745 }
746
747 #[test]
748 fn a_policy_names_everything_an_account_lacks() {
749 let policy = WorkspacePolicy {
750 allowed_email_domains: vec!["@Acme.com".into()],
751 require_verified_email: true,
752 require_two_factor: true,
753 };
754 assert!(!policy.asks_nothing());
755 assert_eq!(
756 policy.gaps(&SecurityFacts::default()),
757 vec![PolicyGap::VerifiedEmail, PolicyGap::EmailDomain(vec!["acme.com".into()]), PolicyGap::TwoFactor]
758 );
759 let member = SecurityFacts { verified_emails: vec!["ada@gmail.com".into(), "ada@acme.com".into()], two_factor: true };
760 assert!(policy.gaps(&member).is_empty());
761 let lookalike = SecurityFacts { verified_emails: vec!["ada@notacme.com".into()], two_factor: true };
762 assert_eq!(policy.gaps(&lookalike), vec![PolicyGap::EmailDomain(vec!["acme.com".into()])]);
763 assert!(PolicyGap::EmailDomain(vec!["acme.com".into()]).message("acme").contains("acme.com"));
764 }
765}

This file's history is long; its oldest lines are credited to the oldest commit read.