diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 7c8c3f8..95b7956 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -7,12 +7,12 @@ use crank_core::{ AgentId, AgentStatus, AuditSink, AuthConfig, AuthKind, AuthProfile, AuthProfileId, CapabilityProfile, CommunityCapabilityProfile, EditionCapabilities, ExecutionMode, ExportMode, GeneratedDraft, GeneratedDraftStatus, IdentityError, IdentityProvider, InvocationLevel, - InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, LoginOutcome, - MembershipRole, NoopAuditSink, OperationId, OperationSecurityLevel, OperationStatus, - OwnerOnlyPolicyEngine, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, - PlatformApiKeyStatus, PolicyEngine, ProductEdition, Protocol, ResponseCachePolicy, SampleId, - SecretKind, Target, ToolQualityMappingRule, ToolQualityMappingSet, ToolQualitySchemaKind, - ToolQualitySchemaNode, UsagePeriod, UserSessionId, WizardState, WorkspaceId, WorkspaceStatus, + InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, MembershipRole, + NoopAuditSink, OperationId, OperationSecurityLevel, OperationStatus, OwnerOnlyPolicyEngine, + PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, PolicyEngine, + ProductEdition, Protocol, ResponseCachePolicy, SampleId, SecretKind, Target, + ToolQualityMappingRule, ToolQualityMappingSet, ToolQualitySchemaKind, ToolQualitySchemaNode, + UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus, }; use crank_mapping::{JsonPathRoot, MappingRule, MappingSet, infer_mapping_from_samples}; use crank_registry::{ @@ -33,20 +33,14 @@ use tracing::{info, instrument}; use uuid::Uuid; mod agents; +mod auth; mod import_export; mod observability; mod operations; mod secrets; mod workspaces; -use crate::{ - auth::{ - AuthSettings, AuthenticatedSession, SessionCookie, create_session_cookie, hash_password, - hash_session_secret, verify_password, - }, - error::ApiError, - storage::LocalArtifactStorage, -}; +use crate::{auth::AuthSettings, error::ApiError, storage::LocalArtifactStorage}; #[derive(Clone)] pub struct AdminService { @@ -628,248 +622,6 @@ impl AdminServiceBuilder { } impl AdminService { - pub async fn bootstrap_admin_user(&self) -> Result<(), ApiError> { - let password_hash = hash_password( - &self.auth_settings.bootstrap_admin.password, - &self.auth_settings.password_pepper, - )?; - let user_id = self - .registry - .upsert_bootstrap_user( - &self.auth_settings.bootstrap_admin.email, - &self.auth_settings.bootstrap_admin.display_name, - &password_hash, - ) - .await?; - self.registry - .ensure_membership( - &WorkspaceId::new("ws_default"), - &user_id, - MembershipRole::Owner, - ) - .await?; - - Ok(()) - } - - pub async fn seed_demo_assets(&self) -> Result<(), ApiError> { - let admin_user = self - .registry - .get_auth_user_by_email(&self.auth_settings.bootstrap_admin.email) - .await? - .ok_or_else(|| ApiError::internal("bootstrap admin user was not found"))?; - let admin_user_id = admin_user.user.id.clone(); - let default_workspace_id = WorkspaceId::new("ws_default"); - - self.seed_default_workspace_demo(&admin_user_id, &default_workspace_id) - .await?; - - Ok(()) - } - - pub async fn get_session( - &self, - session_id: &UserSessionId, - session_value: &str, - ) -> Result, ApiError> { - let secret_hash = hash_session_secret( - session_id, - session_value, - &self.auth_settings.session_secret, - ); - let session = self - .registry - .get_user_session(session_id, &secret_hash) - .await? - .map(|record| AuthenticatedSession { - session_id: record.session_id, - user: record.user, - memberships: record.memberships, - current_workspace_id: record.current_workspace_id, - }); - - Ok(session) - } - - pub async fn touch_session(&self, session_id: &UserSessionId) -> Result<(), ApiError> { - self.registry.touch_user_session(session_id).await?; - Ok(()) - } - - pub async fn login( - &self, - payload: LoginPayload, - ) -> Result<(SessionCookie, SessionResponse), ApiError> { - let authenticated = self.authenticate_login(&payload).await?; - - let session_cookie = create_session_cookie(&self.auth_settings)?; - 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 current_workspace_id = 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()) - }); - let current_workspace_ref = current_workspace_id - .as_ref() - .map(|workspace_id| WorkspaceId::new(workspace_id.clone())); - self.registry - .create_user_session( - &session_cookie.session_id, - &authenticated.user.id, - current_workspace_ref.as_ref(), - &secret_hash, - &session_cookie.expires_at, - ) - .await?; - - Ok(( - session_cookie, - SessionResponse { - user: authenticated.user, - memberships, - current_workspace_id, - }, - )) - } - - async fn authenticate_login( - &self, - payload: &LoginPayload, - ) -> Result { - if let Some(identity_provider) = &self.identity_provider { - return match identity_provider - .login_password(crank_core::LoginPayload { - email: payload.email.clone(), - password: payload.password.clone(), - }) - .await - { - Ok(LoginOutcome::Authenticated(identity)) => Ok(identity), - Err(error) => Err(map_identity_error(error)), - }; - } - - let user = self - .registry - .get_auth_user_by_email(&payload.email) - .await? - .ok_or_else(|| ApiError::unauthorized("invalid email or password"))?; - - if !verify_password( - &payload.password, - &self.auth_settings.password_pepper, - &user.password_hash, - ) { - return Err(ApiError::unauthorized("invalid email or password")); - } - - Ok(crank_core::AuthenticatedIdentity { - user: user.user, - memberships: vec![], - current_workspace_id: None, - }) - } - - pub async fn logout( - &self, - session_id: &UserSessionId, - _session_value: &str, - ) -> Result<(), ApiError> { - self.registry.revoke_user_session(session_id).await?; - Ok(()) - } - - #[instrument(skip(self))] - pub async fn session_response( - &self, - session_id: &UserSessionId, - session_value: &str, - ) -> Result, 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()), - })) - } - - pub async fn update_profile( - &self, - user_id: &crank_core::UserId, - current_workspace_id: Option<&WorkspaceId>, - payload: UpdateProfilePayload, - ) -> Result { - let display_name = validate_profile_display_name(&payload.display_name)?; - let email = validate_profile_email(&payload.email)?; - - let user = self - .registry - .update_user_profile(user_id, &email, &display_name) - .await?; - let memberships = self.registry.list_workspaces_for_user(user_id).await?; - - Ok(SessionResponse { - user, - memberships, - current_workspace_id: current_workspace_id.map(|id| id.as_str().to_owned()), - }) - } - - pub async fn change_password( - &self, - user_id: &crank_core::UserId, - payload: ChangePasswordPayload, - ) -> Result<(), ApiError> { - if payload.new_password.len() < 12 { - return Err(ApiError::validation( - "new password must be at least 12 characters long", - )); - } - - let user = self - .registry - .get_auth_user_by_id(user_id) - .await? - .ok_or_else(|| { - ApiError::not_found_with_context( - format!("user {} was not found", user_id.as_str()), - json!({ "user_id": user_id.as_str() }), - ) - })?; - - if !verify_password( - &payload.current_password, - &self.auth_settings.password_pepper, - &user.password_hash, - ) { - return Err(ApiError::unauthorized("current password is invalid")); - } - - let password_hash = - hash_password(&payload.new_password, &self.auth_settings.password_pepper)?; - self.registry - .update_user_password(user_id, &password_hash) - .await?; - - Ok(()) - } - pub async fn export_workspace( &self, workspace_id: &WorkspaceId, diff --git a/apps/admin-api/src/service/auth.rs b/apps/admin-api/src/service/auth.rs new file mode 100644 index 0000000..c71b57e --- /dev/null +++ b/apps/admin-api/src/service/auth.rs @@ -0,0 +1,259 @@ +use crank_core::{LoginOutcome, MembershipRole, UserSessionId, WorkspaceId}; +use serde_json::json; +use tracing::instrument; + +use crate::{ + auth::{ + AuthenticatedSession, SessionCookie, create_session_cookie, 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, + }, +}; + +impl AdminService { + pub async fn bootstrap_admin_user(&self) -> Result<(), ApiError> { + let password_hash = hash_password( + &self.auth_settings.bootstrap_admin.password, + &self.auth_settings.password_pepper, + )?; + let user_id = self + .registry + .upsert_bootstrap_user( + &self.auth_settings.bootstrap_admin.email, + &self.auth_settings.bootstrap_admin.display_name, + &password_hash, + ) + .await?; + self.registry + .ensure_membership( + &WorkspaceId::new("ws_default"), + &user_id, + MembershipRole::Owner, + ) + .await?; + + Ok(()) + } + + pub async fn seed_demo_assets(&self) -> Result<(), ApiError> { + let admin_user = self + .registry + .get_auth_user_by_email(&self.auth_settings.bootstrap_admin.email) + .await? + .ok_or_else(|| ApiError::internal("bootstrap admin user was not found"))?; + let admin_user_id = admin_user.user.id.clone(); + let default_workspace_id = WorkspaceId::new("ws_default"); + + self.seed_default_workspace_demo(&admin_user_id, &default_workspace_id) + .await?; + + Ok(()) + } + + pub async fn get_session( + &self, + session_id: &UserSessionId, + session_value: &str, + ) -> Result, ApiError> { + let secret_hash = hash_session_secret( + session_id, + session_value, + &self.auth_settings.session_secret, + ); + let session = self + .registry + .get_user_session(session_id, &secret_hash) + .await? + .map(|record| AuthenticatedSession { + session_id: record.session_id, + user: record.user, + memberships: record.memberships, + current_workspace_id: record.current_workspace_id, + }); + + Ok(session) + } + + pub async fn touch_session(&self, session_id: &UserSessionId) -> Result<(), ApiError> { + self.registry.touch_user_session(session_id).await?; + Ok(()) + } + + pub async fn login( + &self, + payload: LoginPayload, + ) -> Result<(SessionCookie, SessionResponse), ApiError> { + let authenticated = self.authenticate_login(&payload).await?; + + let session_cookie = create_session_cookie(&self.auth_settings)?; + 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 current_workspace_id = 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()) + }); + let current_workspace_ref = current_workspace_id + .as_ref() + .map(|workspace_id| WorkspaceId::new(workspace_id.clone())); + self.registry + .create_user_session( + &session_cookie.session_id, + &authenticated.user.id, + current_workspace_ref.as_ref(), + &secret_hash, + &session_cookie.expires_at, + ) + .await?; + + Ok(( + session_cookie, + SessionResponse { + user: authenticated.user, + memberships, + current_workspace_id, + }, + )) + } + + async fn authenticate_login( + &self, + payload: &LoginPayload, + ) -> Result { + if let Some(identity_provider) = &self.identity_provider { + return match identity_provider + .login_password(crank_core::LoginPayload { + email: payload.email.clone(), + password: payload.password.clone(), + }) + .await + { + Ok(LoginOutcome::Authenticated(identity)) => Ok(identity), + Err(error) => Err(map_identity_error(error)), + }; + } + + let user = self + .registry + .get_auth_user_by_email(&payload.email) + .await? + .ok_or_else(|| ApiError::unauthorized("invalid email or password"))?; + + if !verify_password( + &payload.password, + &self.auth_settings.password_pepper, + &user.password_hash, + ) { + return Err(ApiError::unauthorized("invalid email or password")); + } + + Ok(crank_core::AuthenticatedIdentity { + user: user.user, + memberships: vec![], + current_workspace_id: None, + }) + } + + pub async fn logout( + &self, + session_id: &UserSessionId, + _session_value: &str, + ) -> Result<(), ApiError> { + self.registry.revoke_user_session(session_id).await?; + Ok(()) + } + + #[instrument(skip(self))] + pub async fn session_response( + &self, + session_id: &UserSessionId, + session_value: &str, + ) -> Result, 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()), + })) + } + + pub async fn update_profile( + &self, + user_id: &crank_core::UserId, + current_workspace_id: Option<&WorkspaceId>, + payload: UpdateProfilePayload, + ) -> Result { + let display_name = validate_profile_display_name(&payload.display_name)?; + let email = validate_profile_email(&payload.email)?; + + let user = self + .registry + .update_user_profile(user_id, &email, &display_name) + .await?; + let memberships = self.registry.list_workspaces_for_user(user_id).await?; + + Ok(SessionResponse { + user, + memberships, + current_workspace_id: current_workspace_id.map(|id| id.as_str().to_owned()), + }) + } + + pub async fn change_password( + &self, + user_id: &crank_core::UserId, + payload: ChangePasswordPayload, + ) -> Result<(), ApiError> { + if payload.new_password.len() < 12 { + return Err(ApiError::validation( + "new password must be at least 12 characters long", + )); + } + + let user = self + .registry + .get_auth_user_by_id(user_id) + .await? + .ok_or_else(|| { + ApiError::not_found_with_context( + format!("user {} was not found", user_id.as_str()), + json!({ "user_id": user_id.as_str() }), + ) + })?; + + if !verify_password( + &payload.current_password, + &self.auth_settings.password_pepper, + &user.password_hash, + ) { + return Err(ApiError::unauthorized("current password is invalid")); + } + + let password_hash = + hash_password(&payload.new_password, &self.auth_settings.password_pepper)?; + self.registry + .update_user_password(user_id, &password_hash) + .await?; + + Ok(()) + } +}