steid

@jamesgill /

feat: bootstrap_owner and login use cases

Application error type distinguishing domain, repository, and password
failures, plus the two use cases Milestone 1 needs. 19 tests.

bootstrap_owner is idempotent: it runs on every boot and short-circuits on
any_exist(), so restarting is safe rather than a duplicate-key error. It
saves org, then user, then membership -- the user references the org and the
membership references both, so any other order trips the foreign keys.
Attempt #2 had to fix exactly this in two places.

login returns InvalidCredentials for an unknown email, a malformed email,
and a wrong password alike, so the response cannot be used to probe which
addresses have accounts. It also verifies against a dummy hash when no user
matched, because returning early on the unknown-email path makes it
measurably faster and leaks the same information through timing. A test
asserts that dummy hash stays parseable by the real Argon2 hasher -- if it
ever isn't, verify() returns early and the defence silently stops working.

A corrupt stored hash surfaces as Error::Password rather than a failed
login, so an operational fault can't hide behind "invalid credentials".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 1 month agoparent26d9828Browse files0e162fda844fc97851961570f879feead41a8a3a

4 files changed+484 −0

src/application/bootstrap.rs+236 −0View file
@@ -0,0 +1,236 @@
1+use crate::domain::{
2+ Email, Membership, MembershipId, OrgId, Organization, Role, User, UserId,
3+ repository::{MembershipRepository, OrgRepository, UserRepository},
4+};
5+
6+use super::{error::Result, port::PasswordHasher};
7+
8+/// The owner account to create on an empty installation.
9+#[derive(Debug, Clone, PartialEq, Eq)]
10+pub struct OwnerSpec {
11+ pub handle: String,
12+ pub email: String,
13+ pub password: String,
14+}
15+
16+/// What `bootstrap_owner` did.
17+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18+pub enum Bootstrap {
19+ /// The owner was created.
20+ Created,
21+ /// Users already existed, so nothing happened.
22+ AlreadyBootstrapped,
23+}
24+
25+/// Creates the installation's owner if there isn't one yet.
26+///
27+/// Runs on every boot, so it must be idempotent — the `any_exist` check is what makes
28+/// restarting safe rather than a duplicate-key error.
29+pub async fn bootstrap_owner(
30+ spec: &OwnerSpec,
31+ users: &impl UserRepository,
32+ orgs: &impl OrgRepository,
33+ memberships: &impl MembershipRepository,
34+ hasher: &impl PasswordHasher,
35+) -> Result<Bootstrap> {
36+ if users.any_exist().await? {
37+ return Ok(Bootstrap::AlreadyBootstrapped);
38+ }
39+
40+ let email = Email::new(&spec.email)?;
41+ let org = Organization::new(OrgId::generate(), &spec.handle, None)?;
42+ let password_hash = hasher.hash(&spec.password)?;
43+
44+ let user = User::new(UserId::generate(), email, password_hash, org.id.clone());
45+ let membership = Membership::new(
46+ MembershipId::generate(),
47+ org.id.clone(),
48+ user.id.clone(),
49+ Role::Owner,
50+ );
51+
52+ // Order matters: the user references the org and the membership references both,
53+ // so anything else trips the foreign keys. Attempt #2 had to fix exactly this.
54+ orgs.save(&org).await?;
55+ users.save(&user).await?;
56+ memberships.save(&membership).await?;
57+
58+ Ok(Bootstrap::Created)
59+}
60+
61+#[cfg(test)]
62+mod tests {
63+ use super::*;
64+ use crate::{
65+ application::error::Error,
66+ domain::{DomainError, OrgName},
67+ infrastructure::{
68+ password::StubHasher,
69+ repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryUserRepo},
70+ },
71+ };
72+
73+ struct Fixture {
74+ users: InMemoryUserRepo,
75+ orgs: InMemoryOrgRepo,
76+ memberships: InMemoryMembershipRepo,
77+ hasher: StubHasher,
78+ }
79+
80+ impl Fixture {
81+ fn new() -> Self {
82+ Self {
83+ users: InMemoryUserRepo::new(),
84+ orgs: InMemoryOrgRepo::new(),
85+ memberships: InMemoryMembershipRepo::new(),
86+ hasher: StubHasher::new(),
87+ }
88+ }
89+
90+ async fn run(&self, spec: &OwnerSpec) -> Result<Bootstrap> {
91+ bootstrap_owner(
92+ spec,
93+ &self.users,
94+ &self.orgs,
95+ &self.memberships,
96+ &self.hasher,
97+ )
98+ .await
99+ }
100+ }
101+
102+ fn spec() -> OwnerSpec {
103+ OwnerSpec {
104+ handle: "james".to_owned(),
105+ email: "dev@example.com".to_owned(),
106+ password: "hunter2".to_owned(),
107+ }
108+ }
109+
110+ #[tokio::test]
111+ async fn creates_org_user_and_owner_membership() {
112+ let fixture = Fixture::new();
113+
114+ let outcome = fixture.run(&spec()).await.expect("bootstrap");
115+
116+ assert_eq!(outcome, Bootstrap::Created);
117+
118+ let org = fixture
119+ .orgs
120+ .find_by_name(&OrgName::new("james").unwrap())
121+ .await
122+ .expect("lookup")
123+ .expect("org should exist");
124+ let user = fixture
125+ .users
126+ .find_by_email(&Email::new("dev@example.com").unwrap())
127+ .await
128+ .expect("lookup")
129+ .expect("user should exist");
130+ let membership = fixture
131+ .memberships
132+ .find(&org.id, &user.id)
133+ .await
134+ .expect("lookup")
135+ .expect("membership should exist");
136+
137+ assert_eq!(user.personal_org_id, org.id);
138+ assert_eq!(membership.role, Role::Owner);
139+ assert!(membership.can_write());
140+ }
141+
142+ #[tokio::test]
143+ async fn stores_a_hash_never_the_plaintext() {
144+ let fixture = Fixture::new();
145+ fixture.run(&spec()).await.expect("bootstrap");
146+
147+ let user = fixture
148+ .users
149+ .find_by_email(&Email::new("dev@example.com").unwrap())
150+ .await
151+ .expect("lookup")
152+ .expect("user should exist");
153+
154+ assert_ne!(user.password_hash.as_str(), "hunter2");
155+ assert!(
156+ fixture
157+ .hasher
158+ .verify("hunter2", &user.password_hash)
159+ .expect("verify")
160+ );
161+ }
162+
163+ #[tokio::test]
164+ async fn is_idempotent_across_reboots() {
165+ let fixture = Fixture::new();
166+ fixture.run(&spec()).await.expect("first boot");
167+
168+ let outcome = fixture.run(&spec()).await.expect("second boot");
169+
170+ assert_eq!(outcome, Bootstrap::AlreadyBootstrapped);
171+ }
172+
173+ #[tokio::test]
174+ async fn a_second_boot_does_not_overwrite_a_changed_password() {
175+ let fixture = Fixture::new();
176+ fixture.run(&spec()).await.expect("first boot");
177+
178+ let changed = OwnerSpec {
179+ password: "different".to_owned(),
180+ ..spec()
181+ };
182+ fixture.run(&changed).await.expect("second boot");
183+
184+ let user = fixture
185+ .users
186+ .find_by_email(&Email::new("dev@example.com").unwrap())
187+ .await
188+ .expect("lookup")
189+ .expect("user should exist");
190+
191+ assert!(
192+ fixture
193+ .hasher
194+ .verify("hunter2", &user.password_hash)
195+ .expect("verify"),
196+ "the original password should still be the one that works"
197+ );
198+ }
199+
200+ #[tokio::test]
201+ async fn rejects_an_invalid_handle_without_writing_anything() {
202+ let fixture = Fixture::new();
203+ let bad = OwnerSpec {
204+ handle: "not a handle".to_owned(),
205+ ..spec()
206+ };
207+
208+ let error = fixture.run(&bad).await.expect_err("should reject");
209+
210+ assert!(matches!(
211+ error,
212+ Error::Domain(DomainError::Validation { .. })
213+ ));
214+ assert!(
215+ !fixture.users.any_exist().await.expect("any_exist"),
216+ "nothing should be written when validation fails"
217+ );
218+ }
219+
220+ #[tokio::test]
221+ async fn rejects_an_invalid_email_without_writing_anything() {
222+ let fixture = Fixture::new();
223+ let bad = OwnerSpec {
224+ email: "not-an-email".to_owned(),
225+ ..spec()
226+ };
227+
228+ let error = fixture.run(&bad).await.expect_err("should reject");
229+
230+ assert!(matches!(
231+ error,
232+ Error::Domain(DomainError::Validation { .. })
233+ ));
234+ assert!(!fixture.users.any_exist().await.expect("any_exist"));
235+ }
236+}
src/application/error.rs+58 −0View file
@@ -0,0 +1,58 @@
1+use crate::domain::{DomainError, repository::RepositoryError};
2+
3+use super::port::PasswordError;
4+
5+/// What a use case can fail with.
6+///
7+/// Keeps the three failure sources distinct: a broken rule, broken storage, and a
8+/// broken hash are different problems with different responses, and flattening them
9+/// makes a storage outage indistinguishable from a validation error.
10+#[derive(Debug)]
11+pub enum Error {
12+ /// A domain rule was violated.
13+ Domain(DomainError),
14+ /// Persistence failed.
15+ Repository(RepositoryError),
16+ /// Hashing or verification failed for a reason other than a wrong password.
17+ Password(PasswordError),
18+}
19+
20+impl From<DomainError> for Error {
21+ fn from(error: DomainError) -> Self {
22+ Self::Domain(error)
23+ }
24+}
25+
26+impl From<RepositoryError> for Error {
27+ fn from(error: RepositoryError) -> Self {
28+ Self::Repository(error)
29+ }
30+}
31+
32+impl From<PasswordError> for Error {
33+ fn from(error: PasswordError) -> Self {
34+ Self::Password(error)
35+ }
36+}
37+
38+impl std::fmt::Display for Error {
39+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40+ match self {
41+ Self::Domain(error) => write!(f, "{error}"),
42+ Self::Repository(error) => write!(f, "{error}"),
43+ Self::Password(error) => write!(f, "{error}"),
44+ }
45+ }
46+}
47+
48+impl std::error::Error for Error {
49+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
50+ match self {
51+ Self::Domain(error) => Some(error),
52+ Self::Repository(error) => Some(error),
53+ Self::Password(error) => Some(error),
54+ }
55+ }
56+}
57+
58+pub type Result<T> = std::result::Result<T, Error>;
src/application/login.rs+179 −0View file
@@ -0,0 +1,179 @@
1+use crate::domain::{Actor, DomainError, Email, repository::UserRepository};
2+
3+use super::{error::Result, port::PasswordHasher};
4+
5+/// A hash to verify against when no user matched.
6+///
7+/// Without this, an unknown email returns before hashing and a known one doesn't, so
8+/// response time tells an attacker which addresses have accounts. Verifying a dummy
9+/// keeps both paths doing the same work.
10+const ABSENT_USER_HASH: &str =
11+ "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHRzYWx0c2FsdA$YXNkZmFzZGZhc2RmYXNkZmFzZGZhc2Rm";
12+
13+/// Verifies credentials and returns the resulting actor.
14+///
15+/// Every failure — unknown email, malformed email, wrong password — yields
16+/// [`DomainError::InvalidCredentials`]. Distinguishing them would let anyone probe
17+/// which addresses have accounts.
18+pub async fn login(
19+ email: &str,
20+ password: &str,
21+ users: &impl UserRepository,
22+ hasher: &impl PasswordHasher,
23+) -> Result<Actor> {
24+ let user = match Email::new(email) {
25+ Ok(email) => users.find_by_email(&email).await?,
26+ // A malformed address can't match anyone, but still costs the same work.
27+ Err(_) => None,
28+ };
29+
30+ let Some(user) = user else {
31+ let _ = hasher.verify(
32+ password,
33+ &crate::domain::PasswordHash::from_trusted(ABSENT_USER_HASH),
34+ );
35+ return Err(DomainError::InvalidCredentials.into());
36+ };
37+
38+ if hasher.verify(password, &user.password_hash)? {
39+ Ok(Actor::User(user.id))
40+ } else {
41+ Err(DomainError::InvalidCredentials.into())
42+ }
43+}
44+
45+#[cfg(test)]
46+mod tests {
47+ use super::*;
48+ use crate::{
49+ application::error::Error,
50+ domain::{OrgId, PasswordHash, User, UserId},
51+ infrastructure::{password::StubHasher, repository::InMemoryUserRepo},
52+ };
53+
54+ async fn repo_with_user(email: &str, password: &str) -> (InMemoryUserRepo, UserId) {
55+ let repo = InMemoryUserRepo::new();
56+ let hasher = StubHasher::new();
57+ let user = User::new(
58+ UserId::generate(),
59+ Email::new(email).expect("valid email"),
60+ hasher.hash(password).expect("hash"),
61+ OrgId::generate(),
62+ );
63+ let id = user.id.clone();
64+ repo.save(&user).await.expect("save");
65+ (repo, id)
66+ }
67+
68+ fn assert_invalid_credentials(error: Error) {
69+ assert!(
70+ matches!(error, Error::Domain(DomainError::InvalidCredentials)),
71+ "expected InvalidCredentials, got {error:?}"
72+ );
73+ }
74+
75+ #[tokio::test]
76+ async fn correct_credentials_yield_the_user() {
77+ let (users, id) = repo_with_user("dev@example.com", "hunter2").await;
78+
79+ let actor = login("dev@example.com", "hunter2", &users, &StubHasher::new())
80+ .await
81+ .expect("login");
82+
83+ assert_eq!(actor, Actor::User(id));
84+ }
85+
86+ #[tokio::test]
87+ async fn the_email_is_normalised_before_lookup() {
88+ let (users, id) = repo_with_user("dev@example.com", "hunter2").await;
89+
90+ let actor = login(" DEV@Example.COM ", "hunter2", &users, &StubHasher::new())
91+ .await
92+ .expect("login");
93+
94+ assert_eq!(actor, Actor::User(id));
95+ }
96+
97+ #[tokio::test]
98+ async fn a_wrong_password_is_rejected() {
99+ let (users, _) = repo_with_user("dev@example.com", "hunter2").await;
100+
101+ let error = login("dev@example.com", "wrong", &users, &StubHasher::new())
102+ .await
103+ .expect_err("should reject");
104+
105+ assert_invalid_credentials(error);
106+ }
107+
108+ #[tokio::test]
109+ async fn an_unknown_email_is_rejected_the_same_way() {
110+ let (users, _) = repo_with_user("dev@example.com", "hunter2").await;
111+
112+ let error = login("nobody@example.com", "hunter2", &users, &StubHasher::new())
113+ .await
114+ .expect_err("should reject");
115+
116+ assert_invalid_credentials(error);
117+ }
118+
119+ #[tokio::test]
120+ async fn a_malformed_email_is_rejected_the_same_way() {
121+ let (users, _) = repo_with_user("dev@example.com", "hunter2").await;
122+
123+ let error = login("not-an-email", "hunter2", &users, &StubHasher::new())
124+ .await
125+ .expect_err("should reject");
126+
127+ assert_invalid_credentials(error);
128+ }
129+
130+ #[tokio::test]
131+ async fn an_empty_password_does_not_match_a_stored_hash() {
132+ let (users, _) = repo_with_user("dev@example.com", "hunter2").await;
133+
134+ let error = login("dev@example.com", "", &users, &StubHasher::new())
135+ .await
136+ .expect_err("should reject");
137+
138+ assert_invalid_credentials(error);
139+ }
140+
141+ /// Guards the timing defence. If `ABSENT_USER_HASH` stops being parseable, the
142+ /// real hasher returns early instead of doing the work, and the unknown-email path
143+ /// becomes measurably faster than the known-email one again — silently.
144+ #[test]
145+ fn the_absent_user_hash_is_parseable_by_the_real_hasher() {
146+ use crate::infrastructure::password::Argon2Hasher;
147+
148+ let outcome =
149+ Argon2Hasher::new().verify("anything", &PasswordHash::from_trusted(ABSENT_USER_HASH));
150+
151+ assert!(
152+ matches!(outcome, Ok(false)),
153+ "dummy hash must verify to Ok(false), got {outcome:?}"
154+ );
155+ }
156+
157+ #[tokio::test]
158+ async fn a_corrupt_stored_hash_surfaces_rather_than_reading_as_a_bad_password() {
159+ let users = InMemoryUserRepo::new();
160+ users
161+ .save(&User::new(
162+ UserId::generate(),
163+ Email::new("dev@example.com").expect("valid email"),
164+ PasswordHash::from_trusted("not-a-stub-hash"),
165+ OrgId::generate(),
166+ ))
167+ .await
168+ .expect("save");
169+
170+ let error = login("dev@example.com", "hunter2", &users, &StubHasher::new())
171+ .await
172+ .expect_err("should fail");
173+
174+ assert!(
175+ matches!(error, Error::Password(_)),
176+ "a broken hash is an operational fault, not a failed login: got {error:?}"
177+ );
178+ }
179+}
src/application/mod.rs+11 −0View file
@@ -1,4 +1,15 @@
1+//! The application layer: use cases and the ports they depend on.
2+//!
3+//! Every use case takes an actor or a credential plus the ports it needs, and enforces
4+//! the rules before any side effect. Nothing here knows about HTTP or Topcoat.
5+
6+pub mod bootstrap;
17 pub mod config;
8+pub mod error;
9+pub mod login;
210 pub mod port;
311
12+pub use bootstrap::{Bootstrap, OwnerSpec, bootstrap_owner};
413 pub use config::AppConfig;
14+pub use error::{Error, Result};
15+pub use login::login;