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/services/identity/src/security.rs

321 lines13,388 bytesCodeBlame
1//! Account security: proving it is you again, the security log, and the
2//! seam where workspaces will ask more of their members.
3//!
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.
14//!
15//! **The security log** (`security_events`) records what happened to an
16//! account's addresses and password, by the person or by staff, and is
17//! shown to the person in their settings and to staff in sudo.
18//!
19//! **Workspace policy.** [`WorkspacePolicy`] says what a workspace asks of
20//! its members: addresses at its own domain, a confirmed address, a second
21//! factor. [`Identity::policy_refusal`] is checked wherever someone joins a
22//! workspace (`add_member`, accepting an invite) or is given a role on one
23//! of its repositories (access.rs: adding a collaborator, accepting an
24//! invitation), and
25//! [`Identity::within_policy`] and [`Identity::grants_within_policy`]
26//! wherever someone's access to a workspace or its repositories is used
27//! (every user resolved from a session or token). Today no policy is
28//! stored and [`Identity::workspace_policy`] returns the default, which
29//! asks nothing, so both are free: they return before any query. Storing a
30//! policy per workspace is the whole of turning enforcement on.
31
32use g1t_contracts::accounts::{
33 RECENT_AUTH_SECONDS, Reauth, ReauthenticateArgs, SECURITY_LOG_LIMIT, SecurityEvent, SecurityFacts,
34 WorkspacePolicy, is_recent,
35};
36use g1t_contracts::access::RepoGrant;
37use g1t_contracts::identity::UserArgs;
38use g1t_contracts::time::{SQL_NOW, rfc3339};
39use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, User, new_id};
40use g1t_kit::now_ms;
41use serde::Deserialize;
42use worker::Result;
43use worker::wasm_bindgen::JsValue;
44
45use crate::{Identity, crypto};
46
47/// What a person sees when a change needs them to prove it is them.
48pub const REAUTH: &str = "Enter your password to make this change.";
49pub const WRONG_PASSWORD: &str = "That password is not right.";
50pub 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.";
51
52/// What [`Identity::proof`] found.
53#[derive(Debug, PartialEq, Eq)]
54pub enum Proof {
55 /// A recent sign-in, or the right password.
56 Given,
57 /// Nothing recent enough: ask.
58 Missing,
59 /// A password was given and was wrong.
60 WrongPassword,
61 /// Too many wrong passwords: nothing was checked.
62 Throttled,
63}
64
65impl Proof {
66 /// The refusal to return for anything but [`Proof::Given`].
67 pub fn refusal<T>(&self) -> Option<Outcome<T>> {
68 match self {
69 Proof::Given => None,
70 Proof::Missing => Some(Outcome::fail(FailureCode::ReauthRequired, REAUTH)),
71 Proof::WrongPassword => Some(Outcome::fail(FailureCode::ReauthRequired, WRONG_PASSWORD)),
72 Proof::Throttled => Some(Outcome::fail(FailureCode::ReauthRequired, crate::throttle::THROTTLED)),
73 }
74 }
75}
76
77/// Whether `user` is a person acting for themselves: not a workspace's
78/// token, not an agent, not anyone acting on someone's behalf.
79pub fn is_person(user: &User) -> bool {
80 user.kind == PrincipalKind::User && user.acting.is_none() && !user.id.is_empty()
81}
82
83#[derive(Deserialize)]
84struct SecurityRow {
85 kind: String,
86 detail: Option<String>,
87 staff: Option<String>,
88 reason: Option<String>,
89 created_at: String,
90}
91
92impl SecurityRow {
93 fn event(self, for_staff: bool) -> SecurityEvent {
94 SecurityEvent {
95 kind: self.kind,
96 detail: self.detail,
97 by_staff: self.staff.is_some(),
98 reason: self.reason,
99 staff: if for_staff { self.staff } else { None },
100 created_at: self.created_at,
101 }
102 }
103}
104
105impl Identity {
106 /// Whether the person making a sensitive change has proved, just now,
107 /// that they are `user_id`. See the module docs.
108 pub async fn proof(&self, user_id: &str, reauth: &Reauth) -> Result<Proof> {
109 let session = reauth.session_token.as_deref().map(crypto::sha256_hex);
110 if let Some(password) = reauth.password.as_deref().filter(|password| !password.is_empty()) {
111 let (account_key, client_key) = Identity::password_keys(user_id, reauth.client.as_deref());
112 if self.password_locked(&account_key, client_key.as_deref()).await? {
113 return Ok(Proof::Throttled);
114 }
115 #[derive(Deserialize)]
116 struct Hash {
117 username: String,
118 password_hash: String,
119 }
120 let hash = self
121 .db
122 .prepare("SELECT username, password_hash FROM users WHERE id = ?")
123 .bind(&[user_id.into()])?
124 .first::<Hash>(None)
125 .await?;
126 let right = hash
127 .as_ref()
128 .is_some_and(|row| !row.password_hash.is_empty() && crypto::verify_password(password, &row.password_hash));
129 if !right {
130 let owner = hash.as_ref().map(|row| (user_id, row.username.as_str()));
131 self.password_failed(&account_key, client_key.as_deref(), owner).await?;
132 return Ok(Proof::WrongPassword);
133 }
134 self.clear(&account_key).await?;
135 if let Some(session) = &session {
136 self.renew_authentication(session, user_id).await?;
137 }
138 return Ok(Proof::Given);
139 }
140 let Some(session) = session else {
141 return Ok(Proof::Missing);
142 };
143 #[derive(Deserialize)]
144 struct Authenticated {
145 authenticated_at: Option<String>,
146 }
147 let row = self
148 .db
149 .prepare(format!(
150 "SELECT authenticated_at FROM sessions WHERE id = ? AND user_id = ? AND expires_at > {SQL_NOW}"
151 ))
152 .bind(&[session.as_str().into(), user_id.into()])?
153 .first::<Authenticated>(None)
154 .await?;
155 let recent = row.is_some_and(|row| is_recent(row.authenticated_at.as_deref(), now_ms(), RECENT_AUTH_SECONDS));
156 Ok(if recent { Proof::Given } else { Proof::Missing })
157 }
158
159 async fn renew_authentication(&self, session_hash: &str, user_id: &str) -> Result<()> {
160 self.db
161 .prepare(format!("UPDATE sessions SET authenticated_at = {SQL_NOW} WHERE id = ? AND user_id = ?"))
162 .bind(&[session_hash.into(), user_id.into()])?
163 .run()
164 .await?;
165 Ok(())
166 }
167
168 /// `reauthenticate`: the person typed their password again.
169 pub async fn reauthenticate(&self, a: ReauthenticateArgs) -> Result<Outcome<bool>> {
170 #[derive(Deserialize)]
171 struct Owner {
172 user_id: String,
173 }
174 let owner = self
175 .db
176 .prepare(format!("SELECT user_id FROM sessions WHERE id = ? AND expires_at > {SQL_NOW}"))
177 .bind(&[crypto::sha256_hex(&a.session_token).into()])?
178 .first::<Owner>(None)
179 .await?;
180 let Some(owner) = owner else {
181 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in again."));
182 };
183 let reauth = Reauth {
184 session_token: Some(a.session_token),
185 password: Some(a.password),
186 client: a.client,
187 };
188 Ok(match self.proof(&owner.user_id, &reauth).await? {
189 Proof::Given => Outcome::Ok(true),
190 other => other.refusal().unwrap_or(Outcome::Ok(true)),
191 })
192 }
193
194 /// Adds a line to an account's security log. Best effort: the change
195 /// it records has happened.
196 pub async fn log_security(&self, user_id: &str, kind: &str, detail: Option<&str>, staff: Option<(&str, &str)>) {
197 let written = async {
198 self.db
199 .prepare(
200 "INSERT INTO security_events (id, user_id, kind, detail, staff, reason, created_at)
201 VALUES (?, ?, ?, ?, ?, ?, ?)",
202 )
203 .bind(&[
204 new_id("sev", now_ms()).into(),
205 user_id.into(),
206 kind.into(),
207 detail.map_or(JsValue::NULL, Into::into),
208 staff.map_or(JsValue::NULL, |(who, _)| who.into()),
209 staff.map_or(JsValue::NULL, |(_, why)| why.into()),
210 rfc3339(now_ms()).into(),
211 ])?
212 .run()
213 .await
214 };
215 if let Err(error) = written.await {
216 worker::console_error!("security event {kind} not logged: {error}");
217 }
218 }
219
220 /// The newest entries of an account's security log.
221 pub async fn security_events(&self, user_id: &str, for_staff: bool) -> Result<Vec<SecurityEvent>> {
222 let rows = self
223 .db
224 .prepare(format!(
225 "SELECT kind, detail, staff, reason, created_at FROM security_events
226 WHERE user_id = ? ORDER BY created_at DESC, id DESC LIMIT {SECURITY_LOG_LIMIT}"
227 ))
228 .bind(&[user_id.into()])?
229 .all()
230 .await?
231 .results::<SecurityRow>()?;
232 Ok(rows.into_iter().map(|row| row.event(for_staff)).collect())
233 }
234
235 /// `security_log`: a person's own security log.
236 pub async fn security_log(&self, a: UserArgs) -> Result<Outcome<Vec<SecurityEvent>>> {
237 if !is_person(&a.user) {
238 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
239 }
240 Ok(Outcome::Ok(self.security_events(&a.user.id, false).await?))
241 }
242
243 // --- Workspace policy ---
244
245 /// What a workspace asks of its members. No policy is stored yet, so
246 /// every workspace asks nothing.
247 pub fn workspace_policy(&self, _slug: &str) -> WorkspacePolicy {
248 WorkspacePolicy::default()
249 }
250
251 /// What an account can show a policy.
252 async fn security_facts(&self, user_id: &str) -> Result<SecurityFacts> {
253 Ok(SecurityFacts {
254 verified_emails: self.verified_emails(user_id).await?,
255 two_factor: false,
256 })
257 }
258
259 /// Why `user_id` may not join or use the workspace `slug`, or `None`.
260 pub async fn policy_refusal(&self, user_id: &str, slug: &str) -> Result<Option<String>> {
261 let policy = self.workspace_policy(slug);
262 if policy.asks_nothing() {
263 return Ok(None);
264 }
265 let facts = self.security_facts(user_id).await?;
266 Ok(policy.gaps(&facts).first().map(|gap| gap.message(slug)))
267 }
268
269 /// The memberships whose workspace's policy the account meets: access to
270 /// a workspace is used only through these.
271 pub async fn within_policy(&self, user_id: &str, memberships: Vec<Membership>) -> Result<Vec<Membership>> {
272 if memberships.iter().all(|membership| self.workspace_policy(&membership.slug).asks_nothing()) {
273 return Ok(memberships);
274 }
275 let facts = self.security_facts(user_id).await?;
276 Ok(memberships
277 .into_iter()
278 .filter(|membership| self.workspace_policy(&membership.slug).gaps(&facts).is_empty())
279 .collect())
280 }
281
282 /// The same for an account's roles on single repositories: an outside
283 /// collaborator is held to the workspace's policy as its members are.
284 pub async fn grants_within_policy(&self, user_id: &str, grants: Vec<RepoGrant>) -> Result<Vec<RepoGrant>> {
285 if grants.iter().all(|grant| self.workspace_policy(&grant.workspace).asks_nothing()) {
286 return Ok(grants);
287 }
288 let facts = self.security_facts(user_id).await?;
289 Ok(grants
290 .into_iter()
291 .filter(|grant| self.workspace_policy(&grant.workspace).gaps(&facts).is_empty())
292 .collect())
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn only_a_person_acting_for_themselves_may_change_their_account() {
302 let person = User { id: "usr_1".into(), username: "ada".into(), ..User::default() };
303 assert!(is_person(&person));
304 let workspace = User { kind: PrincipalKind::Workspace, ..person.clone() };
305 assert!(!is_person(&workspace));
306 let agent = User { kind: PrincipalKind::Agent, ..person.clone() };
307 assert!(!is_person(&agent));
308 assert!(!is_person(&User::default()));
309 }
310
311 #[test]
312 fn anything_but_proof_is_refused_as_reauth_required() {
313 assert!(Proof::Given.refusal::<bool>().is_none());
314 for proof in [Proof::Missing, Proof::WrongPassword, Proof::Throttled] {
315 match proof.refusal::<bool>() {
316 Some(Outcome::Fail(failure)) => assert_eq!(failure.code, FailureCode::ReauthRequired),
317 _ => panic!("expected a refusal"),
318 }
319 }
320 }
321}