flagon-io/g1t

public

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/crates/contracts/src/accounts.rs

517 lines21,077 bytesCodeBlame
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//! Workspaces will be able to ask more of their members (addresses at their
13//! own domain, a second factor). [`WorkspacePolicy`] is where that goes:
14//! identity evaluates it wherever someone gains or uses access to a
15//! workspace, and today every workspace's policy asks for nothing.
16
17use serde::{Deserialize, Serialize};
18
19use crate::User;
20
21/// The most addresses one account may have, confirmed or not.
22pub const MAX_EMAILS: usize = 10;
23
24/// How long after signing in (or confirming the password) a person may make
25/// sensitive changes without being asked to prove it is them again.
26pub const RECENT_AUTH_SECONDS: u64 = 10 * 60;
27
28/// The least time between two confirmation emails to one address.
29pub const RESEND_SECONDS: u64 = 60;
30
31/// Where each person's private commit address lives:
32/// `<id suffix>+<username>@users.noreply.g1t.sh`.
33pub const NOREPLY_DOMAIN: &str = "users.noreply.g1t.sh";
34
35/// How many characters of the account id the noreply address carries. The
36/// end of an id is its random part, so a username alone never resolves.
37pub const NOREPLY_ID_CHARS: usize = 8;
38
39/// An address trimmed and lowercased, if it looks like one: something, an
40/// `@`, and a domain with a dot, at most 254 characters, no spaces.
41pub fn normalize_email(text: &str) -> Option<String> {
42 let email = text.trim().to_lowercase();
43 let well_formed = email.len() <= 254
44 && email.split_once('@').is_some_and(|(local, domain)| {
45 !local.is_empty() && !domain.contains('@') && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
46 })
47 && !email.contains(char::is_whitespace);
48 well_formed.then_some(email)
49}
50
51/// The person's noreply address, used for commits g1t makes for them when
52/// they keep their address private.
53pub fn noreply_address(user_id: &str, username: &str) -> String {
54 format!("{}+{}@{NOREPLY_DOMAIN}", id_suffix(user_id), username.to_lowercase())
55}
56
57/// The last [`NOREPLY_ID_CHARS`] characters of an account id, lowercased.
58pub fn id_suffix(user_id: &str) -> String {
59 let chars: Vec<char> = user_id.chars().collect();
60 let start = chars.len().saturating_sub(NOREPLY_ID_CHARS);
61 chars[start..].iter().collect::<String>().to_lowercase()
62}
63
64/// The id suffix and username a noreply address names, or `None` for any
65/// other address.
66pub fn parse_noreply(email: &str) -> Option<(String, String)> {
67 let email = email.trim().to_lowercase();
68 let local = email.strip_suffix(&format!("@{NOREPLY_DOMAIN}"))?;
69 let (suffix, username) = local.split_once('+')?;
70 (suffix.chars().count() == NOREPLY_ID_CHARS && !username.is_empty()).then(|| (suffix.to_owned(), username.to_owned()))
71}
72
73/// Whether a sign-in at `authenticated_at` (RFC 3339) is recent at `now`
74/// (RFC 3339), within `window_seconds`. Both are g1t's fixed format, which
75/// compares as text.
76pub fn is_recent(authenticated_at: Option<&str>, now_ms: u64, window_seconds: u64) -> bool {
77 let since = crate::time::rfc3339(now_ms.saturating_sub(window_seconds * 1000));
78 authenticated_at.is_some_and(|at| at >= since.as_str())
79}
80
81/// One address, as the rules about removing and choosing addresses see it.
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct EmailState {
84 pub email: String,
85 pub verified: bool,
86 pub primary: bool,
87}
88
89/// Why `email` cannot be removed from an account with `all`, or `None`.
90pub fn removal_refusal(all: &[EmailState], email: &str) -> Option<&'static str> {
91 let Some(target) = all.iter().find(|state| state.email == email) else {
92 return Some("That address is not on your account.");
93 };
94 if target.primary {
95 return Some("That is your primary address. Make another confirmed address primary first.");
96 }
97 let confirmed = all.iter().filter(|state| state.verified).count();
98 if target.verified && confirmed <= 1 {
99 return Some("That is your only confirmed address. Add and confirm another first.");
100 }
101 None
102}
103
104/// Why `email` cannot be made primary, or `None`.
105pub fn primary_refusal(all: &[EmailState], email: &str) -> Option<&'static str> {
106 match all.iter().find(|state| state.email == email) {
107 None => Some("That address is not on your account."),
108 Some(state) if !state.verified => Some("Confirm that address before making it primary."),
109 Some(_) => None,
110 }
111}
112
113/// Proof that the person making a sensitive change is the account's owner,
114/// now. Either is enough: the session they are using, if they signed in to
115/// it within [`RECENT_AUTH_SECONDS`], or their password. A correct password
116/// also renews the session's sign-in time, so they are not asked again
117/// straight away.
118#[derive(Clone, Debug, Default, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct Reauth {
121 #[serde(default)]
122 pub session_token: Option<String>,
123 #[serde(default)]
124 pub password: Option<String>,
125 /// Who is asking, such as the visitor's IP address, so wrong passwords
126 /// are counted against it too.
127 #[serde(default)]
128 pub client: Option<String>,
129}
130
131/// One of a person's addresses, as they see it.
132#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct AccountEmail {
135 /// As typed when it was added.
136 pub email: String,
137 pub verified: bool,
138 pub primary: bool,
139 /// Gets security notices as well as the primary.
140 pub backup: bool,
141 /// RFC 3339.
142 pub created_at: String,
143 /// RFC 3339.
144 pub verified_at: Option<String>,
145}
146
147/// A person's addresses and what they do with them. `list_emails` (takes
148/// `UserArgs`) returns `Outcome<AccountEmails>`, and so does every change.
149#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct AccountEmails {
152 /// The primary first, then confirmed addresses, then the rest, oldest
153 /// first within each.
154 pub emails: Vec<AccountEmail>,
155 /// Commits g1t makes for the person use `noreply`, not the primary.
156 pub private_email: bool,
157 /// Pushes of commits that carry one of the person's addresses are
158 /// refused while `private_email` is on.
159 pub block_private_pushes: bool,
160 /// `<id suffix>+<username>@users.noreply.g1t.sh`.
161 pub noreply: String,
162 /// The address commits g1t makes for the person carry now.
163 pub commit_email: String,
164 /// [`MAX_EMAILS`].
165 pub limit: u32,
166}
167
168/// `add_email`: adds an address and emails it a confirmation link; adding
169/// one already on the account and unconfirmed sends the link again.
170/// `remove_email`: removes one, never the primary nor the last confirmed
171/// address. Both need [`Reauth`] and tell every confirmed address.
172/// `resend_email_verification` sends the link again, at most once every
173/// [`RESEND_SECONDS`], and needs no reauth. People only: never an agent's
174/// or a workspace's token.
175#[derive(Debug, Serialize, Deserialize)]
176pub struct AccountEmailArgs {
177 pub user: User,
178 pub email: String,
179 #[serde(default)]
180 pub reauth: Reauth,
181}
182
183/// `update_email_settings`: each field given is changed. `primary` must be
184/// a confirmed address. `backup` is a confirmed address to get security
185/// notices too, or empty for the primary only. Changing either needs
186/// [`Reauth`]; the privacy switches do not. Returns `Outcome<AccountEmails>`.
187#[derive(Debug, Default, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct EmailSettingsArgs {
190 pub user: User,
191 #[serde(default)]
192 pub primary: Option<String>,
193 #[serde(default)]
194 pub backup: Option<String>,
195 #[serde(default)]
196 pub private_email: Option<bool>,
197 #[serde(default)]
198 pub block_private_pushes: Option<bool>,
199 #[serde(default)]
200 pub reauth: Reauth,
201}
202
203/// `reauthenticate`: the person typed their password again for the session
204/// they are using; sensitive changes need no more proof for
205/// [`RECENT_AUTH_SECONDS`]. Returns `Outcome<bool>`.
206#[derive(Debug, Serialize, Deserialize)]
207#[serde(rename_all = "camelCase")]
208pub struct ReauthenticateArgs {
209 pub session_token: String,
210 pub password: String,
211 #[serde(default)]
212 pub client: Option<String>,
213}
214
215/// `email_owners`: who wrote commits, by their author addresses. Matches
216/// confirmed addresses and noreply addresses only, never an unconfirmed
217/// one. At most 200 addresses. Returns a map from each address that
218/// matched, lowercased, to its owner.
219#[derive(Debug, Serialize, Deserialize)]
220pub struct EmailOwnersArgs {
221 pub emails: Vec<String>,
222}
223
224/// The account an address belongs to.
225#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
226pub struct EmailOwner {
227 pub id: String,
228 pub username: String,
229 pub avatar: Option<String>,
230}
231
232/// `commit_identity`: the name and address to put on a commit g1t makes for
233/// a person (a merge, a web edit, catching a branch up). Their noreply
234/// address while they keep their address private, otherwise their primary.
235/// Returns `Option<CommitIdentity>`; null for an unknown account.
236#[derive(Debug, Serialize, Deserialize)]
237#[serde(rename_all = "camelCase")]
238pub struct CommitIdentityArgs {
239 pub user_id: String,
240}
241
242#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
243pub struct CommitIdentity {
244 pub name: String,
245 pub email: String,
246}
247
248/// `push_email_guard` (takes `CommitIdentityArgs`): what a push by this
249/// person must not publish. Returns `Option<PushEmailGuard>`: null unless
250/// they keep their address private and block pushes that expose it.
251#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
252pub struct PushEmailGuard {
253 /// Their confirmed addresses, lowercased.
254 pub emails: Vec<String>,
255 /// The address to commit with instead.
256 pub noreply: String,
257}
258
259impl PushEmailGuard {
260 /// Whether a commit carrying `email` would publish one of the
261 /// person's addresses.
262 pub fn exposes(&self, email: &str) -> bool {
263 let email = email.trim().to_lowercase();
264 !email.is_empty() && self.emails.contains(&email)
265 }
266}
267
268/// An address with all but the first letter of its local part hidden:
269/// `s***@gmail.com`.
270pub fn mask_email(email: &str) -> String {
271 match email.split_once('@') {
272 Some((local, domain)) => {
273 let first: String = local.chars().take(1).collect();
274 format!("{first}***@{domain}")
275 }
276 None => "***".to_owned(),
277 }
278}
279
280/// Something that happened to an account's security. `security_log` (takes
281/// `UserArgs`) returns the newest [`SECURITY_LOG_LIMIT`], newest first.
282#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct SecurityEvent {
285 /// `email_added`, `email_verified`, `email_removed`,
286 /// `primary_email_changed`, `backup_email_changed`,
287 /// `email_privacy_changed` or `password_changed`.
288 pub kind: String,
289 /// The address concerned, or what changed.
290 pub detail: Option<String>,
291 /// Whether g1t staff made the change.
292 pub by_staff: bool,
293 /// Why staff made it.
294 pub reason: Option<String>,
295 /// The staff member, by email. Only in staff views.
296 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub staff: Option<String>,
298 /// RFC 3339.
299 pub created_at: String,
300}
301
302/// How many entries `security_log` returns.
303pub const SECURITY_LOG_LIMIT: usize = 50;
304
305// --- Staff ---
306
307/// `admin_user` (takes `UsernameArgs`): one account's addresses and
308/// security log, for staff. Returns `Option<AdminUser>`.
309#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "camelCase")]
311pub struct AdminUser {
312 pub id: String,
313 pub username: String,
314 /// RFC 3339.
315 pub created_at: String,
316 pub emails: Vec<AccountEmail>,
317 pub private_email: bool,
318 pub log: Vec<SecurityEvent>,
319}
320
321/// `admin_remove_email`: staff remove an address from an account, such as
322/// an unconfirmed one someone else needs or a compromised one. Never the
323/// last confirmed address; removing the primary makes the oldest other
324/// confirmed address primary. Recorded in the person's security log with
325/// the reason, and the person is told. Returns `Outcome<AdminUser>`.
326#[derive(Debug, Serialize, Deserialize)]
327pub struct AdminRemoveEmailArgs {
328 pub username: String,
329 pub email: String,
330 pub reason: String,
331 /// The staff member, by email.
332 pub staff: String,
333}
334
335// --- Workspace policy ---
336
337/// What a workspace asks of its members' accounts. Nothing, today, for
338/// every workspace ([`WorkspacePolicy::default`]); identity already checks
339/// it wherever someone joins a workspace or uses access to one, so asking
340/// for more is a matter of storing it.
341#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
342#[serde(rename_all = "camelCase")]
343pub struct WorkspacePolicy {
344 /// Members need a confirmed address at one of these domains. Empty:
345 /// any domain.
346 #[serde(default)]
347 pub allowed_email_domains: Vec<String>,
348 /// Members need a confirmed address at all.
349 #[serde(default)]
350 pub require_verified_email: bool,
351 /// Members need a second factor on their account.
352 #[serde(default)]
353 pub require_two_factor: bool,
354}
355
356/// What a policy can ask about an account.
357#[derive(Clone, Debug, Default, PartialEq, Eq)]
358pub struct SecurityFacts {
359 /// Lowercased.
360 pub verified_emails: Vec<String>,
361 pub two_factor: bool,
362}
363
364/// What an account lacks to meet a workspace's policy.
365#[derive(Clone, Debug, PartialEq, Eq)]
366pub enum PolicyGap {
367 VerifiedEmail,
368 EmailDomain(Vec<String>),
369 TwoFactor,
370}
371
372impl PolicyGap {
373 /// What to tell the person, for a workspace named `slug`.
374 pub fn message(&self, slug: &str) -> String {
375 match self {
376 PolicyGap::VerifiedEmail => format!("{slug} needs members to have a confirmed email address."),
377 PolicyGap::EmailDomain(domains) => format!(
378 "{slug} needs members to have a confirmed address at {}. Add one in your account settings.",
379 domains.join(" or ")
380 ),
381 PolicyGap::TwoFactor => format!("{slug} needs members to turn on two-factor authentication."),
382 }
383 }
384}
385
386impl WorkspacePolicy {
387 /// Whether the policy asks for anything, so callers can skip gathering
388 /// [`SecurityFacts`] when it does not.
389 pub fn asks_nothing(&self) -> bool {
390 self.allowed_email_domains.is_empty() && !self.require_verified_email && !self.require_two_factor
391 }
392
393 /// Everything the account lacks, or an empty list when it meets the
394 /// policy.
395 pub fn gaps(&self, facts: &SecurityFacts) -> Vec<PolicyGap> {
396 let mut gaps = Vec::new();
397 if self.require_verified_email && facts.verified_emails.is_empty() {
398 gaps.push(PolicyGap::VerifiedEmail);
399 }
400 if !self.allowed_email_domains.is_empty() {
401 let allowed: Vec<String> = self.allowed_email_domains.iter().map(|domain| domain.trim().trim_start_matches('@').to_lowercase()).collect();
402 let has = facts.verified_emails.iter().any(|email| {
403 email
404 .rsplit_once('@')
405 .is_some_and(|(_, domain)| allowed.iter().any(|allowed| domain == allowed))
406 });
407 if !has {
408 gaps.push(PolicyGap::EmailDomain(allowed));
409 }
410 }
411 if self.require_two_factor && !facts.two_factor {
412 gaps.push(PolicyGap::TwoFactor);
413 }
414 gaps
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn a_push_guard_matches_the_persons_own_addresses_and_masks_them() {
424 let guard = PushEmailGuard { emails: vec!["sam@gmail.com".into()], noreply: "abc+sam@users.noreply.g1t.sh".into() };
425 assert!(guard.exposes(" Sam@Gmail.com"));
426 assert!(!guard.exposes("abc+sam@users.noreply.g1t.sh"));
427 assert!(!guard.exposes("someone@gmail.com"));
428 assert!(!guard.exposes(""));
429 assert_eq!(mask_email("sam@gmail.com"), "s***@gmail.com");
430 assert_eq!(mask_email("nope"), "***");
431 }
432
433 fn state(email: &str, verified: bool, primary: bool) -> EmailState {
434 EmailState { email: email.into(), verified, primary }
435 }
436
437 #[test]
438 fn addresses_are_trimmed_lowercased_and_checked() {
439 assert_eq!(normalize_email(" Ada@Example.COM "), Some("ada@example.com".into()));
440 assert_eq!(normalize_email("ada+g1t@mail.example.co.uk"), Some("ada+g1t@mail.example.co.uk".into()));
441 for bad in ["", "ada", "@example.com", "ada@example", "ada@@example.com", "a da@example.com", "ada@.com", "ada@example."] {
442 assert_eq!(normalize_email(bad), None, "{bad}");
443 }
444 assert_eq!(normalize_email(&format!("{}@example.com", "a".repeat(250))), None);
445 }
446
447 #[test]
448 fn the_noreply_address_carries_the_end_of_the_id_and_the_username() {
449 let address = noreply_address("usr_01j9zq4m8x7k2v5n3b6c1d0efg", "Ada");
450 assert_eq!(address, "6c1d0efg+ada@users.noreply.g1t.sh");
451 assert_eq!(parse_noreply(&address), Some(("6c1d0efg".into(), "ada".into())));
452 assert_eq!(parse_noreply("6C1D0EFG+Ada@Users.Noreply.G1T.sh"), Some(("6c1d0efg".into(), "ada".into())));
453 assert_eq!(parse_noreply("ada@example.com"), None);
454 assert_eq!(parse_noreply("short+ada@users.noreply.g1t.sh"), None);
455 assert_eq!(parse_noreply("6c1d0efg@users.noreply.g1t.sh"), None);
456 assert_eq!(parse_noreply("6c1d0efg+@users.noreply.g1t.sh"), None);
457 assert_eq!(id_suffix("usr_x"), "usr_x");
458 }
459
460 #[test]
461 fn a_sign_in_is_recent_for_ten_minutes() {
462 let now = 1_800_000_000_000;
463 let at = |ms_ago: u64| crate::time::rfc3339(now - ms_ago);
464 assert!(is_recent(Some(&at(0)), now, RECENT_AUTH_SECONDS));
465 assert!(is_recent(Some(&at(9 * 60 * 1000)), now, RECENT_AUTH_SECONDS));
466 assert!(is_recent(Some(&at(10 * 60 * 1000)), now, RECENT_AUTH_SECONDS));
467 assert!(!is_recent(Some(&at(10 * 60 * 1000 + 1)), now, RECENT_AUTH_SECONDS));
468 assert!(!is_recent(None, now, RECENT_AUTH_SECONDS));
469 }
470
471 #[test]
472 fn neither_the_primary_nor_the_last_confirmed_address_can_be_removed() {
473 let all = [state("a@x.io", true, true), state("b@x.io", true, false), state("c@x.io", false, false)];
474 assert!(removal_refusal(&all, "a@x.io").unwrap().contains("primary"));
475 assert_eq!(removal_refusal(&all, "b@x.io"), None);
476 assert_eq!(removal_refusal(&all, "c@x.io"), None);
477 assert!(removal_refusal(&all, "d@x.io").is_some());
478 // An unconfirmed primary (a new account) stays; so does the only
479 // confirmed address, primary or not.
480 let lone = [state("a@x.io", false, true), state("b@x.io", true, false)];
481 assert!(removal_refusal(&lone, "b@x.io").unwrap().contains("only confirmed"));
482 }
483
484 #[test]
485 fn only_a_confirmed_address_can_be_primary() {
486 let all = [state("a@x.io", true, true), state("b@x.io", false, false)];
487 assert_eq!(primary_refusal(&all, "a@x.io"), None);
488 assert!(primary_refusal(&all, "b@x.io").unwrap().contains("Confirm"));
489 assert!(primary_refusal(&all, "z@x.io").is_some());
490 }
491
492 #[test]
493 fn the_default_policy_asks_nothing_and_any_account_meets_it() {
494 let policy = WorkspacePolicy::default();
495 assert!(policy.asks_nothing());
496 assert!(policy.gaps(&SecurityFacts::default()).is_empty());
497 }
498
499 #[test]
500 fn a_policy_names_everything_an_account_lacks() {
501 let policy = WorkspacePolicy {
502 allowed_email_domains: vec!["@Acme.com".into()],
503 require_verified_email: true,
504 require_two_factor: true,
505 };
506 assert!(!policy.asks_nothing());
507 assert_eq!(
508 policy.gaps(&SecurityFacts::default()),
509 vec![PolicyGap::VerifiedEmail, PolicyGap::EmailDomain(vec!["acme.com".into()]), PolicyGap::TwoFactor]
510 );
511 let member = SecurityFacts { verified_emails: vec!["ada@gmail.com".into(), "ada@acme.com".into()], two_factor: true };
512 assert!(policy.gaps(&member).is_empty());
513 let lookalike = SecurityFacts { verified_emails: vec!["ada@notacme.com".into()], two_factor: true };
514 assert_eq!(policy.gaps(&lookalike), vec![PolicyGap::EmailDomain(vec!["acme.com".into()])]);
515 assert!(PolicyGap::EmailDomain(vec!["acme.com".into()]).message("acme").contains("acme.com"));
516 }
517}