Skip to content

g1t/services/identity/src/security.rs

415 lines17,241 bytesCodeBlame

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.

Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA1//! Account security: proving it is you again, the security log, and what
2//! workspaces ask of their members.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look3//!
4//! **Proving it is you again ("sudo mode").** Changing an account's email
5//! addresses, and anything as sensitive added later (a second factor,
6//! recovery codes, deleting the account), calls [`Identity::proof`] first.
7//! It accepts the session the person is using when they signed in to it
8//! within `RECENT_AUTH_SECONDS` (`sessions.authenticated_at`, set when a
9//! session starts), or their password, which also renews that time. A
10//! caller that gets anything but [`Proof::Given`] answers
11//! `FailureCode::ReauthRequired`, and the site asks for the password (or a
12//! fresh GitHub sign-in, for an account without one). A second factor will
13//! be one more way to give proof, here, and nothing that calls it changes.
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA14//! Turning two-factor authentication on or off, and making new recovery
15//! codes, call it too (two_factor.rs).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look16//!
17//! **The security log** (`security_events`) records what happened to an
18//! account's addresses and password, by the person or by staff, and is
19//! shown to the person in their settings and to staff in sudo.
20//!
21//! **Workspace policy.** [`WorkspacePolicy`] says what a workspace asks of
22//! its members: addresses at its own domain, a confirmed address, a second
23//! factor. [`Identity::policy_refusal`] is checked wherever someone joins a
24//! workspace (`add_member`, accepting an invite) or is given a role on one
25//! of its repositories (access.rs: adding a collaborator, accepting an
26//! invitation), and
27//! [`Identity::within_policy`] and [`Identity::grants_within_policy`]
28//! wherever someone's access to a workspace or its repositories is used
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA29//! (every user resolved from a session or token). The one stored policy
30//! is `workspaces.require_two_factor`, read with the memberships and grants
31//! themselves, so a workspace that asks nothing costs nothing; only when
32//! one asks is the account's own two-factor state read, once. A membership
33//! or grant the account does not meet the policy for is left out, and the
34//! workspace is named in [`User::held`] with why, for the site's notice.
35//! The membership itself stays: turning two-factor on brings it back.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look36
37use g1t_contracts::accounts::{
38 RECENT_AUTH_SECONDS, Reauth, ReauthenticateArgs, SECURITY_LOG_LIMIT, SecurityEvent, SecurityFacts,
39 WorkspacePolicy, is_recent,
40};
41use g1t_contracts::access::RepoGrant;
42use g1t_contracts::identity::UserArgs;
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA43use g1t_contracts::members::PolicyHold;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look44use g1t_contracts::time::{SQL_NOW, rfc3339};
45use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, User, new_id};
46use g1t_kit::now_ms;
47use serde::Deserialize;
48use worker::Result;
49use worker::wasm_bindgen::JsValue;
50
51use crate::{Identity, crypto};
52
53/// What a person sees when a change needs them to prove it is them.
54pub const REAUTH: &str = "Enter your password to make this change.";
55pub const WRONG_PASSWORD: &str = "That password is not right.";
56pub const PEOPLE_ONLY: &str = "Only you can change your email addresses, signed in as yourself; never with an agent's or a workspace's token.";
57
58/// What [`Identity::proof`] found.
59#[derive(Debug, PartialEq, Eq)]
60pub enum Proof {
61 /// A recent sign-in, or the right password.
62 Given,
63 /// Nothing recent enough: ask.
64 Missing,
65 /// A password was given and was wrong.
66 WrongPassword,
67 /// Too many wrong passwords: nothing was checked.
68 Throttled,
69}
70
71impl Proof {
72 /// The refusal to return for anything but [`Proof::Given`].
73 pub fn refusal<T>(&self) -> Option<Outcome<T>> {
74 match self {
75 Proof::Given => None,
76 Proof::Missing => Some(Outcome::fail(FailureCode::ReauthRequired, REAUTH)),
77 Proof::WrongPassword => Some(Outcome::fail(FailureCode::ReauthRequired, WRONG_PASSWORD)),
78 Proof::Throttled => Some(Outcome::fail(FailureCode::ReauthRequired, crate::throttle::THROTTLED)),
79 }
80 }
81}
82
83/// Whether `user` is a person acting for themselves: not a workspace's
84/// token, not an agent, not anyone acting on someone's behalf.
85pub fn is_person(user: &User) -> bool {
86 user.kind == PrincipalKind::User && user.acting.is_none() && !user.id.is_empty()
87}
88
89#[derive(Deserialize)]
90struct SecurityRow {
91 kind: String,
92 detail: Option<String>,
93 staff: Option<String>,
94 reason: Option<String>,
95 created_at: String,
96}
97
98impl SecurityRow {
99 fn event(self, for_staff: bool) -> SecurityEvent {
100 SecurityEvent {
101 kind: self.kind,
102 detail: self.detail,
103 by_staff: self.staff.is_some(),
104 reason: self.reason,
105 staff: if for_staff { self.staff } else { None },
106 created_at: self.created_at,
107 }
108 }
109}
110
111impl Identity {
112 /// Whether the person making a sensitive change has proved, just now,
113 /// that they are `user_id`. See the module docs.
114 pub async fn proof(&self, user_id: &str, reauth: &Reauth) -> Result<Proof> {
115 let session = reauth.session_token.as_deref().map(crypto::sha256_hex);
116 if let Some(password) = reauth.password.as_deref().filter(|password| !password.is_empty()) {
117 let (account_key, client_key) = Identity::password_keys(user_id, reauth.client.as_deref());
118 if self.password_locked(&account_key, client_key.as_deref()).await? {
119 return Ok(Proof::Throttled);
120 }
121 #[derive(Deserialize)]
122 struct Hash {
123 username: String,
124 password_hash: String,
125 }
126 let hash = self
127 .db
128 .prepare("SELECT username, password_hash FROM users WHERE id = ?")
129 .bind(&[user_id.into()])?
130 .first::<Hash>(None)
131 .await?;
132 let right = hash
133 .as_ref()
134 .is_some_and(|row| !row.password_hash.is_empty() && crypto::verify_password(password, &row.password_hash));
135 if !right {
136 let owner = hash.as_ref().map(|row| (user_id, row.username.as_str()));
137 self.password_failed(&account_key, client_key.as_deref(), owner).await?;
138 return Ok(Proof::WrongPassword);
139 }
140 self.clear(&account_key).await?;
141 if let Some(session) = &session {
142 self.renew_authentication(session, user_id).await?;
143 }
144 return Ok(Proof::Given);
145 }
146 let Some(session) = session else {
147 return Ok(Proof::Missing);
148 };
149 #[derive(Deserialize)]
150 struct Authenticated {
151 authenticated_at: Option<String>,
152 }
153 let row = self
154 .db
155 .prepare(format!(
156 "SELECT authenticated_at FROM sessions WHERE id = ? AND user_id = ? AND expires_at > {SQL_NOW}"
157 ))
158 .bind(&[session.as_str().into(), user_id.into()])?
159 .first::<Authenticated>(None)
160 .await?;
161 let recent = row.is_some_and(|row| is_recent(row.authenticated_at.as_deref(), now_ms(), RECENT_AUTH_SECONDS));
162 Ok(if recent { Proof::Given } else { Proof::Missing })
163 }
164
165 async fn renew_authentication(&self, session_hash: &str, user_id: &str) -> Result<()> {
166 self.db
167 .prepare(format!("UPDATE sessions SET authenticated_at = {SQL_NOW} WHERE id = ? AND user_id = ?"))
168 .bind(&[session_hash.into(), user_id.into()])?
169 .run()
170 .await?;
171 Ok(())
172 }
173
174 /// `reauthenticate`: the person typed their password again.
175 pub async fn reauthenticate(&self, a: ReauthenticateArgs) -> Result<Outcome<bool>> {
176 #[derive(Deserialize)]
177 struct Owner {
178 user_id: String,
179 }
180 let owner = self
181 .db
182 .prepare(format!("SELECT user_id FROM sessions WHERE id = ? AND expires_at > {SQL_NOW}"))
183 .bind(&[crypto::sha256_hex(&a.session_token).into()])?
184 .first::<Owner>(None)
185 .await?;
186 let Some(owner) = owner else {
187 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in again."));
188 };
189 let reauth = Reauth {
190 session_token: Some(a.session_token),
191 password: Some(a.password),
192 client: a.client,
193 };
194 Ok(match self.proof(&owner.user_id, &reauth).await? {
195 Proof::Given => Outcome::Ok(true),
196 other => other.refusal().unwrap_or(Outcome::Ok(true)),
197 })
198 }
199
200 /// Adds a line to an account's security log. Best effort: the change
201 /// it records has happened.
202 pub async fn log_security(&self, user_id: &str, kind: &str, detail: Option<&str>, staff: Option<(&str, &str)>) {
203 let written = async {
204 self.db
205 .prepare(
206 "INSERT INTO security_events (id, user_id, kind, detail, staff, reason, created_at)
207 VALUES (?, ?, ?, ?, ?, ?, ?)",
208 )
209 .bind(&[
210 new_id("sev", now_ms()).into(),
211 user_id.into(),
212 kind.into(),
213 detail.map_or(JsValue::NULL, Into::into),
214 staff.map_or(JsValue::NULL, |(who, _)| who.into()),
215 staff.map_or(JsValue::NULL, |(_, why)| why.into()),
216 rfc3339(now_ms()).into(),
217 ])?
218 .run()
219 .await
220 };
221 if let Err(error) = written.await {
222 worker::console_error!("security event {kind} not logged: {error}");
223 }
224 }
225
226 /// The newest entries of an account's security log.
227 pub async fn security_events(&self, user_id: &str, for_staff: bool) -> Result<Vec<SecurityEvent>> {
228 let rows = self
229 .db
230 .prepare(format!(
231 "SELECT kind, detail, staff, reason, created_at FROM security_events
232 WHERE user_id = ? ORDER BY created_at DESC, id DESC LIMIT {SECURITY_LOG_LIMIT}"
233 ))
234 .bind(&[user_id.into()])?
235 .all()
236 .await?
237 .results::<SecurityRow>()?;
238 Ok(rows.into_iter().map(|row| row.event(for_staff)).collect())
239 }
240
241 /// `security_log`: a person's own security log.
242 pub async fn security_log(&self, a: UserArgs) -> Result<Outcome<Vec<SecurityEvent>>> {
243 if !is_person(&a.user) {
244 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
245 }
246 Ok(Outcome::Ok(self.security_events(&a.user.id, false).await?))
247 }
248
249 // --- Workspace policy ---
250
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA251 /// What a workspace asks of its members: today, whether it requires
252 /// two-factor authentication.
253 pub async fn workspace_policy(&self, slug: &str) -> Result<WorkspacePolicy> {
254 #[derive(Deserialize)]
255 struct Row {
256 require_two_factor: u8,
257 }
258 let row = self
259 .db
260 .prepare("SELECT require_two_factor FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
261 .bind(&[slug.to_lowercase().into()])?
262 .first::<Row>(None)
263 .await?;
264 Ok(policy_of(row.is_some_and(|row| row.require_two_factor != 0)))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look265 }
266
267 /// What an account can show a policy.
268 async fn security_facts(&self, user_id: &str) -> Result<SecurityFacts> {
269 Ok(SecurityFacts {
270 verified_emails: self.verified_emails(user_id).await?,
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA271 two_factor: self.two_factor_enabled(user_id).await?,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look272 })
273 }
274
275 /// Why `user_id` may not join or use the workspace `slug`, or `None`.
276 pub async fn policy_refusal(&self, user_id: &str, slug: &str) -> Result<Option<String>> {
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA277 let policy = self.workspace_policy(slug).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look278 if policy.asks_nothing() {
279 return Ok(None);
280 }
281 let facts = self.security_facts(user_id).await?;
282 Ok(policy.gaps(&facts).first().map(|gap| gap.message(slug)))
283 }
284
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA285 /// The memberships and grants whose workspace's policy the account
286 /// meets: access to a workspace, as a member or an outside
287 /// collaborator, is used only through these. Each carries whether its
288 /// workspace requires two-factor authentication.
289 pub async fn within_policy(
290 &self,
291 user_id: &str,
292 memberships: Vec<(Membership, bool)>,
293 grants: Vec<(RepoGrant, bool)>,
294 ) -> Result<WithinPolicy> {
295 let asks = memberships.iter().any(|(_, required)| *required) || grants.iter().any(|(_, required)| *required);
296 let facts = if asks { Some(self.security_facts(user_id).await?) } else { None };
297 Ok(apply_policies(facts.as_ref(), memberships, grants))
298 }
299}
300
301/// What access an account may use under its workspaces' policies.
302#[derive(Debug, Default)]
303pub struct WithinPolicy {
304 pub memberships: Vec<Membership>,
305 pub grants: Vec<RepoGrant>,
306 /// The workspaces the account is held out of, with why.
307 pub held: Vec<PolicyHold>,
308}
309
310/// The policy a workspace's stored settings make.
311pub fn policy_of(require_two_factor: bool) -> WorkspacePolicy {
312 WorkspacePolicy { require_two_factor, ..WorkspacePolicy::default() }
313}
314
315/// Keeps the memberships and grants whose policy `facts` meets. `facts`
316/// is only needed, and only read, when some workspace asks something.
317pub fn apply_policies(
318 facts: Option<&SecurityFacts>,
319 memberships: Vec<(Membership, bool)>,
320 grants: Vec<(RepoGrant, bool)>,
321) -> WithinPolicy {
322 let mut within = WithinPolicy::default();
323 let gap = |slug: &str, required: bool| -> Option<PolicyHold> {
324 let facts = facts?;
325 policy_of(required).gaps(facts).first().map(|gap| PolicyHold {
326 slug: slug.to_owned(),
327 reason: gap.message(slug),
328 gap: gap.as_str().to_owned(),
329 })
330 };
331 for (membership, required) in memberships {
332 match gap(&membership.slug, required) {
333 Some(hold) => within.held.push(hold),
334 None => within.memberships.push(membership),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look335 }
336 }
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA337 for (grant, required) in grants {
338 match gap(&grant.workspace, required) {
339 Some(hold) => {
340 if !within.held.iter().any(|held| held.slug == hold.slug) {
341 within.held.push(hold);
342 }
343 }
344 None => within.grants.push(grant),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look345 }
346 }
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA347 within
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn only_a_person_acting_for_themselves_may_change_their_account() {
356 let person = User { id: "usr_1".into(), username: "ada".into(), ..User::default() };
357 assert!(is_person(&person));
358 let workspace = User { kind: PrincipalKind::Workspace, ..person.clone() };
359 assert!(!is_person(&workspace));
360 let agent = User { kind: PrincipalKind::Agent, ..person.clone() };
361 assert!(!is_person(&agent));
362 assert!(!is_person(&User::default()));
363 }
364
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA365 fn membership(slug: &str) -> Membership {
366 Membership::member(slug)
367 }
368
369 fn grant(repo: &str, workspace: &str) -> RepoGrant {
370 RepoGrant { repo_id: repo.into(), workspace: workspace.into(), role: g1t_contracts::access::RepoRole::Write, team: None }
371 }
372
373 #[test]
374 fn a_workspace_requiring_two_factor_holds_out_whoever_lacks_it() {
375 let without = SecurityFacts { verified_emails: vec!["ada@x.io".into()], two_factor: false };
376 let within = apply_policies(
377 Some(&without),
378 vec![(membership("acme"), true), (membership("globex"), false)],
379 vec![(grant("rep_1", "initech"), true), (grant("rep_2", "globex"), false)],
380 );
381 assert_eq!(within.memberships.iter().map(|m| m.slug.as_str()).collect::<Vec<_>>(), vec!["globex"]);
382 assert_eq!(within.grants.iter().map(|g| g.repo_id.as_str()).collect::<Vec<_>>(), vec!["rep_2"]);
383 assert_eq!(within.held.iter().map(|h| h.slug.as_str()).collect::<Vec<_>>(), vec!["acme", "initech"]);
384 assert!(within.held.iter().all(|hold| hold.gap == "two_factor" && hold.reason.contains("two-factor")));
385 // Turned on: everything is back.
386 let with = SecurityFacts { two_factor: true, ..without };
387 let within = apply_policies(
388 Some(&with),
389 vec![(membership("acme"), true)],
390 vec![(grant("rep_1", "initech"), true)],
391 );
392 assert_eq!(within.memberships.len(), 1);
393 assert_eq!(within.grants.len(), 1);
394 assert!(within.held.is_empty());
395 }
396
397 #[test]
398 fn nothing_is_held_where_no_workspace_asks() {
399 let within = apply_policies(None, vec![(membership("acme"), false)], vec![(grant("rep_1", "acme"), false)]);
400 assert_eq!(within.memberships.len(), 1);
401 assert_eq!(within.grants.len(), 1);
402 assert!(within.held.is_empty());
403 }
404
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look405 #[test]
406 fn anything_but_proof_is_refused_as_reauth_required() {
407 assert!(Proof::Given.refusal::<bool>().is_none());
408 for proof in [Proof::Missing, Proof::WrongPassword, Proof::Throttled] {
409 match proof.refusal::<bool>() {
410 Some(Outcome::Fail(failure)) => assert_eq!(failure.code, FailureCode::ReauthRequired),
411 _ => panic!("expected a refusal"),
412 }
413 }
414 }
415}

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