feat: complete Epic 1 production foundation

This commit is contained in:
2026-08-25 01:24:11 +03:00
parent 767428436d
commit 182bde8ac0
298 changed files with 35719 additions and 5299 deletions
+238 -40
View File
@@ -1,20 +1,101 @@
use crank_core::{LoginOutcome, MembershipRole, UserSessionId, WorkspaceId};
use crank_core::{
LoginOutcome, MembershipRole, User, UserId, UserSessionId, UserStatus, WorkspaceId,
};
use crank_registry::{AdminSecurityAuditRequest, ConsumeAdminBootstrapContractRequest};
use serde_json::json;
use time::OffsetDateTime;
use tracing::instrument;
use crate::{
auth::{
AuthenticatedSession, SessionCookie, create_session_cookie, hash_password,
hash_session_secret, verify_password,
AuthenticatedSession, SessionCookie, create_csrf_token_value, create_session_cookie,
hash_csrf_token, hash_password, hash_session_secret, verify_password,
},
error::ApiError,
service::{
AdminService, ChangePasswordPayload, LoginPayload, SessionResponse, UpdateProfilePayload,
map_identity_error, validate_profile_display_name, validate_profile_email,
AdminService, BootstrapStatusResponse, ChangePasswordPayload, CompleteBootstrapPayload,
LoginPayload, SessionResponse, UpdateProfilePayload, hash_access_secret,
map_identity_error, new_prefixed_id, validate_profile_display_name, validate_profile_email,
},
};
impl AdminService {
pub async fn bootstrap_status(&self) -> Result<BootstrapStatusResponse, ApiError> {
Ok(BootstrapStatusResponse {
bootstrap_required: !self.registry.has_password_admin().await?,
})
}
pub async fn complete_bootstrap(
&self,
payload: CompleteBootstrapPayload,
) -> Result<(SessionCookie, SessionResponse), ApiError> {
if payload.token.len() < 32 || payload.token.len() > 256 {
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
.await;
return Err(Self::constant_bootstrap_error());
}
if payload.password.len() < 12 || payload.password.len() > 256 {
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
.await;
return Err(Self::constant_bootstrap_error());
}
let now = OffsetDateTime::now_utc();
let bootstrap_token_hash = hash_access_secret(&format!("bootstrap:{}", payload.token));
if !self
.registry
.admin_bootstrap_contract_is_consumable(&bootstrap_token_hash, &now)
.await?
{
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
.await;
return Err(Self::constant_bootstrap_error());
}
let password_hash = hash_password(&payload.password, &self.auth_settings.password_pepper)?;
let user_id = self
.registry
.consume_admin_bootstrap_contract(ConsumeAdminBootstrapContractRequest {
token_hash: &bootstrap_token_hash,
password_hash: &password_hash,
now: &now,
})
.await
.map_err(|_| Self::constant_bootstrap_error());
let user_id = match user_id {
Ok(user_id) => user_id,
Err(error) => {
self.record_admin_security_audit(
None,
"bootstrap_rejected",
"rejected",
"bootstrap",
)
.await;
return Err(error);
}
};
self.record_admin_security_audit(
Some(&user_id),
"bootstrap_completed",
"success",
"bootstrap",
)
.await;
let user = self
.registry
.get_auth_user_by_id(&user_id)
.await?
.ok_or_else(|| ApiError::internal("bootstrap user was not found"))?
.user;
self.create_session_for_user(user).await
}
fn constant_bootstrap_error() -> ApiError {
ApiError::unauthorized("bootstrap request is invalid or expired")
}
pub async fn bootstrap_admin_user(&self) -> Result<(), ApiError> {
let password_hash = hash_password(
&self.auth_settings.bootstrap_admin.password,
@@ -83,36 +164,99 @@ impl AdminService {
Ok(())
}
pub async fn verify_session_csrf(
&self,
session_id: &UserSessionId,
csrf_hash: &str,
) -> Result<bool, ApiError> {
Ok(self
.registry
.verify_session_csrf(session_id, csrf_hash)
.await?)
}
pub async fn login(
&self,
payload: LoginPayload,
) -> Result<(SessionCookie, SessionResponse), ApiError> {
let authenticated = self.authenticate_login(&payload).await?;
self.login_with_client_bucket(payload, "anonymous").await
}
pub async fn login_with_client_bucket(
&self,
payload: LoginPayload,
client_bucket: &str,
) -> Result<(SessionCookie, SessionResponse), ApiError> {
let scope_hash = hash_access_secret(&format!(
"login:{}:{}",
client_bucket,
payload.email.trim().to_ascii_lowercase()
));
if let Some(locked_until) = self
.registry
.login_backoff_locked_until(&scope_hash)
.await?
{
let retry_after_ms = (locked_until - OffsetDateTime::now_utc())
.whole_milliseconds()
.clamp(1, 300_000);
return Err(ApiError::rate_limited_with_context(
"login temporarily unavailable",
json!({
"retry_after_ms": retry_after_ms,
"error_code": "login_throttled",
"recovery": "retry_after_delay"
}),
));
}
let authenticated = match self.authenticate_login(&payload).await {
Ok(authenticated) => authenticated,
Err(error) => {
let _ = self
.registry
.record_login_failure(&scope_hash, &OffsetDateTime::now_utc())
.await;
self.record_admin_security_audit(None, "login_rejected", "rejected", "login")
.await;
return Err(error);
}
};
self.registry.reset_login_backoff(&scope_hash).await?;
self.record_admin_security_audit(
Some(&authenticated.user.id),
"login_succeeded",
"success",
"login",
)
.await;
self.create_session_for_user(authenticated.user).await
}
async fn create_session_for_user(
&self,
user: User,
) -> Result<(SessionCookie, SessionResponse), ApiError> {
let session_cookie = create_session_cookie(&self.auth_settings)?;
let csrf_token = create_csrf_token_value();
let secret_hash = hash_session_secret(
&session_cookie.session_id,
&session_cookie.value,
&self.auth_settings.session_secret,
);
let memberships = self
.registry
.list_workspaces_for_user(&authenticated.user.id)
.await?;
let csrf_hash = hash_csrf_token(
&session_cookie.session_id,
&csrf_token,
&self.auth_settings.session_secret,
);
let memberships = self.registry.list_workspaces_for_user(&user.id).await?;
let default_workspace_id = memberships
.iter()
.find(|membership| membership.workspace.id.as_str() == "ws_default")
.map(|membership| membership.workspace.id.as_str().to_owned());
let current_workspace_id = default_workspace_id.or_else(|| {
authenticated
.current_workspace_id
.as_ref()
.map(|workspace_id| workspace_id.as_str().to_owned())
.or_else(|| {
memberships
.first()
.map(|membership| membership.workspace.id.as_str().to_owned())
})
memberships
.first()
.map(|membership| membership.workspace.id.as_str().to_owned())
});
let current_workspace_ref = current_workspace_id
.as_ref()
@@ -120,9 +264,10 @@ impl AdminService {
self.registry
.create_user_session(
&session_cookie.session_id,
&authenticated.user.id,
&user.id,
current_workspace_ref.as_ref(),
&secret_hash,
Some(&csrf_hash),
&session_cookie.expires_at,
)
.await?;
@@ -130,9 +275,10 @@ impl AdminService {
Ok((
session_cookie,
SessionResponse {
user: authenticated.user,
user,
memberships,
current_workspace_id,
csrf_token,
},
))
}
@@ -158,7 +304,19 @@ impl AdminService {
.registry
.get_auth_user_by_email(&payload.email)
.await?
.ok_or_else(|| ApiError::unauthorized("invalid email or password"))?;
.ok_or_else(|| {
let _ = hash_password(&payload.password, &self.auth_settings.password_pepper);
ApiError::unauthorized("invalid email or password")
})?;
if user.user.status != UserStatus::Active {
let _ = verify_password(
&payload.password,
&self.auth_settings.password_pepper,
&user.password_hash,
);
return Err(ApiError::unauthorized("invalid email or password"));
}
if !verify_password(
&payload.password,
@@ -181,6 +339,8 @@ impl AdminService {
_session_value: &str,
) -> Result<(), ApiError> {
self.registry.revoke_user_session(session_id).await?;
self.record_admin_security_audit(None, "logout", "success", "session")
.await;
Ok(())
}
@@ -190,21 +350,23 @@ impl AdminService {
session_id: &UserSessionId,
session_value: &str,
) -> Result<Option<SessionResponse>, ApiError> {
Ok(self
.get_session(session_id, session_value)
.await?
.map(|session| SessionResponse {
user: session.user,
memberships: session.memberships,
current_workspace_id: session
.current_workspace_id
.map(|id| id.as_str().to_owned()),
}))
let Some(session) = self.get_session(session_id, session_value).await? else {
return Ok(None);
};
Ok(Some(SessionResponse {
user: session.user,
memberships: session.memberships,
current_workspace_id: session
.current_workspace_id
.map(|id| id.as_str().to_owned()),
csrf_token: String::new(),
}))
}
pub async fn update_profile(
&self,
user_id: &crank_core::UserId,
session_id: &UserSessionId,
current_workspace_id: Option<&WorkspaceId>,
payload: UpdateProfilePayload,
) -> Result<SessionResponse, ApiError> {
@@ -221,18 +383,19 @@ impl AdminService {
user,
memberships,
current_workspace_id: current_workspace_id.map(|id| id.as_str().to_owned()),
csrf_token: self.rotate_session_csrf_token(session_id).await?,
})
}
pub async fn change_password(
&self,
user_id: &crank_core::UserId,
current_session_id: &UserSessionId,
_current_session_id: &UserSessionId,
payload: ChangePasswordPayload,
) -> Result<(), ApiError> {
if payload.new_password.len() < 12 {
if payload.new_password.len() < 12 || payload.new_password.len() > 256 {
return Err(ApiError::validation(
"new password must be at least 12 characters long",
"new password must be between 12 and 256 characters long",
));
}
@@ -258,13 +421,48 @@ impl AdminService {
let password_hash =
hash_password(&payload.new_password, &self.auth_settings.password_pepper)?;
self.registry
.update_user_password_and_revoke_other_sessions(
user_id,
current_session_id,
&password_hash,
)
.update_user_password_and_revoke_all_sessions(user_id, &password_hash)
.await?;
self.record_admin_security_audit(Some(user_id), "password_rotated", "success", "password")
.await;
Ok(())
}
async fn record_admin_security_audit(
&self,
actor_user_id: Option<&UserId>,
action: &'static str,
outcome: &'static str,
source: &'static str,
) {
let (request_id, trace_id) = crank_observability::current_request_correlation();
let audit_id = new_prefixed_id("audit");
let _ = self
.registry
.record_admin_security_audit(AdminSecurityAuditRequest {
id: &audit_id,
action,
outcome,
actor_user_id,
session_id: None,
request_id: request_id.as_deref(),
trace_id: trace_id.as_deref(),
source,
})
.await;
}
pub async fn rotate_session_csrf_token(
&self,
session_id: &UserSessionId,
) -> Result<String, ApiError> {
let csrf_token = create_csrf_token_value();
let csrf_hash =
hash_csrf_token(session_id, &csrf_token, &self.auth_settings.session_secret);
self.registry
.update_user_session_csrf(session_id, &csrf_hash)
.await?;
Ok(csrf_token)
}
}