feat: add app-level authentication foundation

This commit is contained in:
a.tolmachev
2026-03-30 23:47:09 +03:00
parent 91854a4153
commit ab2e603997
42 changed files with 1624 additions and 236 deletions
+176 -8
View File
@@ -8,7 +8,8 @@ use crank_core::{
InvitationId, InvitationStatus, InvitationToken, InvocationLevel, InvocationLog,
InvocationLogId, InvocationSource, InvocationStatus, MembershipRole, OperationId,
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus,
Protocol, SampleId, Samples, Target, UsagePeriod, Workspace, WorkspaceId, WorkspaceStatus,
Protocol, SampleId, Samples, Target, UsagePeriod, UserSessionId, Workspace, WorkspaceId,
WorkspaceStatus,
};
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
use crank_proto::{ProtoService, services_from_descriptor_set_bytes};
@@ -21,7 +22,7 @@ use crank_registry::{
PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind, SaveAgentBindingsRequest,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
UsageSummary, UsageTimelinePoint, WorkspaceRecord,
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord,
};
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
use crank_schema::Schema;
@@ -32,13 +33,33 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::{info, instrument};
use uuid::Uuid;
use crate::{error::ApiError, storage::LocalArtifactStorage};
use crate::{
auth::{
AuthSettings, AuthenticatedSession, SessionCookie, create_session_cookie, hash_password,
hash_session_secret, verify_password,
},
error::ApiError,
storage::LocalArtifactStorage,
};
#[derive(Clone)]
pub struct AdminService {
registry: PostgresRegistry,
runtime: RuntimeExecutor,
storage: LocalArtifactStorage,
auth_settings: AuthSettings,
}
#[derive(Clone, Debug, Deserialize)]
pub struct LoginPayload {
pub email: String,
pub password: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct SessionResponse {
pub user: crank_core::User,
pub memberships: Vec<WorkspaceMembershipRecord>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -421,22 +442,151 @@ fn default_operation_category() -> String {
}
impl AdminService {
pub fn new(registry: PostgresRegistry, storage_root: PathBuf) -> Self {
pub fn new(
registry: PostgresRegistry,
storage_root: PathBuf,
auth_settings: AuthSettings,
) -> Self {
Self {
registry,
runtime: RuntimeExecutor::new(),
storage: LocalArtifactStorage::new(storage_root),
auth_settings,
}
}
#[instrument(skip(self))]
pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, ApiError> {
Ok(self.registry.list_workspaces().await?)
pub fn auth_settings(&self) -> &AuthSettings {
&self.auth_settings
}
#[instrument(skip(self, payload), fields(workspace_slug = %payload.slug))]
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 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 {
user: record.user,
memberships: record.memberships,
});
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 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"));
}
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,
);
self.registry
.create_user_session(
&session_cookie.session_id,
&user.user.id,
&secret_hash,
&session_cookie.expires_at,
)
.await?;
let memberships = self
.registry
.list_workspaces_for_user(&user.user.id)
.await?;
Ok((
session_cookie,
SessionResponse {
user: user.user,
memberships,
},
))
}
pub async fn logout(
&self,
session_id: &UserSessionId,
_session_value: &str,
) -> Result<(), ApiError> {
self.registry.revoke_user_session(session_id).await?;
Ok(())
}
pub async fn list_workspaces_for_user(
&self,
user_id: &crank_core::UserId,
) -> Result<Vec<WorkspaceMembershipRecord>, ApiError> {
Ok(self.registry.list_workspaces_for_user(user_id).await?)
}
pub async fn user_has_workspace_access(
&self,
user_id: &crank_core::UserId,
workspace_id: &WorkspaceId,
) -> Result<bool, ApiError> {
Ok(self
.registry
.user_has_workspace_access(user_id, workspace_id)
.await?)
}
#[instrument(skip(self, payload), fields(workspace_slug = %payload.slug, user_id = %user_id.as_str()))]
pub async fn create_workspace(
&self,
user_id: &crank_core::UserId,
payload: WorkspacePayload,
) -> Result<WorkspaceRecord, ApiError> {
let now = now_string()?;
@@ -455,10 +605,28 @@ impl AdminService {
workspace: &workspace,
})
.await?;
self.registry
.ensure_membership(&workspace.id, user_id, MembershipRole::Owner)
.await?;
Ok(WorkspaceRecord { workspace })
}
#[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,
}))
}
pub async fn get_workspace(
&self,
workspace_id: &WorkspaceId,