Split admin auth service module
Deploy / deploy (push) Successful in 1m35s
CI / Rust Checks (push) Failing after 5m3s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Deployment Manifests (push) Has been skipped

This commit is contained in:
github-ops
2026-06-21 02:12:09 +00:00
parent 365041d666
commit 75b0db0eaf
2 changed files with 267 additions and 256 deletions
+8 -256
View File
@@ -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<Option<AuthenticatedSession>, 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<crank_core::AuthenticatedIdentity, ApiError> {
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<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()),
}))
}
pub async fn update_profile(
&self,
user_id: &crank_core::UserId,
current_workspace_id: Option<&WorkspaceId>,
payload: UpdateProfilePayload,
) -> Result<SessionResponse, ApiError> {
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,
+259
View File
@@ -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<Option<AuthenticatedSession>, 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<crank_core::AuthenticatedIdentity, ApiError> {
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<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()),
}))
}
pub async fn update_profile(
&self,
user_id: &crank_core::UserId,
current_workspace_id: Option<&WorkspaceId>,
payload: UpdateProfilePayload,
) -> Result<SessionResponse, ApiError> {
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(())
}
}