feat: add app-level authentication foundation
This commit is contained in:
@@ -42,6 +42,7 @@ define_id!(SampleId);
|
||||
define_id!(AuthProfileId);
|
||||
define_id!(WorkspaceId);
|
||||
define_id!(UserId);
|
||||
define_id!(UserSessionId);
|
||||
define_id!(AgentId);
|
||||
define_id!(InvitationId);
|
||||
define_id!(PlatformApiKeyId);
|
||||
|
||||
@@ -18,7 +18,7 @@ pub use auth::{
|
||||
};
|
||||
pub use ids::{
|
||||
AgentId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId,
|
||||
PlatformApiKeyId, SampleId, ToolId, UserId, WorkspaceId,
|
||||
PlatformApiKeyId, SampleId, ToolId, UserId, UserSessionId, WorkspaceId,
|
||||
};
|
||||
pub use observability::{
|
||||
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup,
|
||||
|
||||
@@ -13,6 +13,7 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -5,7 +5,7 @@ mod postgres;
|
||||
|
||||
pub use error::RegistryError;
|
||||
pub use model::{
|
||||
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
|
||||
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest,
|
||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata,
|
||||
InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord,
|
||||
@@ -13,8 +13,9 @@ pub use model::{
|
||||
OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest,
|
||||
PublishedAgentTool, RegistryOperation, SampleKind, SaveAgentBindingsRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
|
||||
UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceRecord, YamlImportJob,
|
||||
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
|
||||
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||
WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
|
||||
YamlImportJobId, YamlImportJobStatus,
|
||||
};
|
||||
pub use postgres::PostgresRegistry;
|
||||
|
||||
@@ -20,6 +20,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
id text primary key,
|
||||
email text not null unique,
|
||||
display_name text not null,
|
||||
password_hash text null,
|
||||
status text not null,
|
||||
created_at timestamptz not null
|
||||
)",
|
||||
@@ -27,6 +28,10 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query("alter table users add column if not exists password_hash text null")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"insert into users (
|
||||
id,
|
||||
@@ -58,6 +63,20 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists user_sessions (
|
||||
id text primary key,
|
||||
user_id text not null references users(id) on delete cascade,
|
||||
secret_hash text not null,
|
||||
status text not null,
|
||||
expires_at timestamptz not null,
|
||||
last_seen_at timestamptz null,
|
||||
created_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"insert into workspaces (
|
||||
id,
|
||||
|
||||
@@ -2,7 +2,7 @@ use crank_core::{
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, DescriptorId,
|
||||
ExportMode, InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole,
|
||||
Operation, OperationId, OperationStatus, PlatformApiKey, Protocol, SampleId, UsagePeriod,
|
||||
UsageRollup, User, Workspace, WorkspaceId,
|
||||
UsageRollup, User, UserSessionId, Workspace, WorkspaceId,
|
||||
};
|
||||
use crank_mapping::MappingSet;
|
||||
use crank_schema::Schema;
|
||||
@@ -55,6 +55,25 @@ pub struct MembershipRecord {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WorkspaceMembershipRecord {
|
||||
pub workspace: Workspace,
|
||||
pub role: MembershipRole,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AuthUserRecord {
|
||||
pub user: User,
|
||||
pub password_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SessionRecord {
|
||||
pub session_id: UserSessionId,
|
||||
pub user: User,
|
||||
pub memberships: Vec<WorkspaceMembershipRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InvitationRecord {
|
||||
pub invitation: InvitationToken,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crank_core::{
|
||||
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, GraphqlOperationType,
|
||||
HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId, OperationId,
|
||||
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Target, UsageRollup,
|
||||
User, UserId, Workspace, WorkspaceId,
|
||||
HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId, MembershipRole,
|
||||
OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Target,
|
||||
UsageRollup, User, UserId, UserSessionId, Workspace, WorkspaceId,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use serde_json::Value;
|
||||
@@ -16,17 +16,18 @@ use crate::{
|
||||
error::RegistryError,
|
||||
migrations,
|
||||
model::{
|
||||
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest,
|
||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord,
|
||||
InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
|
||||
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
||||
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool,
|
||||
RegistryOperation, SaveAgentBindingsRequest, SaveAuthProfileRequest,
|
||||
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest,
|
||||
UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
|
||||
UsageTimelinePoint, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
|
||||
YamlImportJobId, YamlImportJobStatus,
|
||||
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest,
|
||||
CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
||||
DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
|
||||
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
|
||||
OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest,
|
||||
PublishRequest, PublishedAgentTool, RegistryOperation, SaveAgentBindingsRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown,
|
||||
UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
|
||||
WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
|
||||
YamlImportJobStatus,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -68,6 +69,251 @@ impl PostgresRegistry {
|
||||
rows.iter().map(map_workspace_record).collect()
|
||||
}
|
||||
|
||||
pub async fn list_workspaces_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<WorkspaceMembershipRecord>, RegistryError> {
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
w.id,
|
||||
w.slug,
|
||||
w.display_name,
|
||||
w.status,
|
||||
w.settings_json,
|
||||
to_char(w.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
||||
to_char(w.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
|
||||
m.role
|
||||
from memberships m
|
||||
join workspaces w on w.id = m.workspace_id
|
||||
where m.user_id = $1
|
||||
order by w.slug asc",
|
||||
)
|
||||
.bind(user_id.as_str())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.iter().map(map_workspace_membership_record).collect()
|
||||
}
|
||||
|
||||
pub async fn upsert_bootstrap_user(
|
||||
&self,
|
||||
email: &str,
|
||||
display_name: &str,
|
||||
password_hash: &str,
|
||||
) -> Result<UserId, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"insert into users (
|
||||
id,
|
||||
email,
|
||||
display_name,
|
||||
password_hash,
|
||||
status,
|
||||
created_at
|
||||
) values (
|
||||
$1, $2, $3, $4, 'active', now()
|
||||
)
|
||||
on conflict (email) do update
|
||||
set display_name = excluded.display_name,
|
||||
password_hash = excluded.password_hash,
|
||||
status = 'active'
|
||||
returning id",
|
||||
)
|
||||
.bind(format!("user_{}", uuid::Uuid::now_v7().simple()))
|
||||
.bind(email)
|
||||
.bind(display_name)
|
||||
.bind(password_hash)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(UserId::new(row.try_get::<String, _>("id")?))
|
||||
}
|
||||
|
||||
pub async fn ensure_membership(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
user_id: &UserId,
|
||||
role: MembershipRole,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"insert into memberships (
|
||||
workspace_id,
|
||||
user_id,
|
||||
role,
|
||||
created_at
|
||||
) values (
|
||||
$1, $2, $3, now()
|
||||
)
|
||||
on conflict (workspace_id, user_id) do update
|
||||
set role = excluded.role",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(user_id.as_str())
|
||||
.bind(serialize_enum_text(&role, "role")?)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_auth_user_by_email(
|
||||
&self,
|
||||
email: &str,
|
||||
) -> Result<Option<AuthUserRecord>, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
email,
|
||||
display_name,
|
||||
password_hash,
|
||||
status,
|
||||
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at
|
||||
from users
|
||||
where email = $1
|
||||
limit 1",
|
||||
)
|
||||
.bind(email)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.as_ref().map(map_auth_user_record).transpose()
|
||||
}
|
||||
|
||||
pub async fn create_user_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
user_id: &UserId,
|
||||
secret_hash: &str,
|
||||
expires_at: &str,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"insert into user_sessions (
|
||||
id,
|
||||
user_id,
|
||||
secret_hash,
|
||||
status,
|
||||
expires_at,
|
||||
last_seen_at,
|
||||
created_at
|
||||
) values (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
'active',
|
||||
$4::timestamptz,
|
||||
now(),
|
||||
now()
|
||||
)",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.bind(user_id.as_str())
|
||||
.bind(secret_hash)
|
||||
.bind(expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_user_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
secret_hash: &str,
|
||||
) -> Result<Option<SessionRecord>, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
s.id,
|
||||
s.user_id,
|
||||
u.email,
|
||||
u.display_name,
|
||||
u.status,
|
||||
to_char(u.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at
|
||||
from user_sessions s
|
||||
join users u on u.id = s.user_id
|
||||
where s.id = $1
|
||||
and s.secret_hash = $2
|
||||
and s.status = 'active'
|
||||
and s.expires_at > now()
|
||||
limit 1",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.bind(secret_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let user_id = UserId::new(row.try_get::<String, _>("user_id")?);
|
||||
let user = User {
|
||||
id: user_id.clone(),
|
||||
email: row.try_get("email")?,
|
||||
display_name: row.try_get("display_name")?,
|
||||
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
};
|
||||
let memberships = self.list_workspaces_for_user(&user_id).await?;
|
||||
|
||||
Ok(Some(SessionRecord {
|
||||
session_id: UserSessionId::new(row.try_get::<String, _>("id")?),
|
||||
user,
|
||||
memberships,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn touch_user_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"update user_sessions
|
||||
set last_seen_at = now()
|
||||
where id = $1",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn revoke_user_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"update user_sessions
|
||||
set status = 'revoked'
|
||||
where id = $1",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn user_has_workspace_access(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<bool, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select exists(
|
||||
select 1
|
||||
from memberships
|
||||
where user_id = $1
|
||||
and workspace_id = $2
|
||||
) as allowed",
|
||||
)
|
||||
.bind(user_id.as_str())
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.try_get("allowed")?)
|
||||
}
|
||||
|
||||
pub async fn list_memberships(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
@@ -1204,7 +1450,9 @@ impl PostgresRegistry {
|
||||
schema: Option<&str>,
|
||||
) -> Result<Self, RegistryError> {
|
||||
let pool = PgPoolOptions::new()
|
||||
.min_connections(0)
|
||||
.max_connections(1)
|
||||
.idle_timeout(std::time::Duration::from_secs(1))
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
|
||||
@@ -2404,6 +2652,23 @@ fn map_workspace_record(row: &PgRow) -> Result<WorkspaceRecord, RegistryError> {
|
||||
})
|
||||
}
|
||||
|
||||
fn map_workspace_membership_record(
|
||||
row: &PgRow,
|
||||
) -> Result<WorkspaceMembershipRecord, RegistryError> {
|
||||
Ok(WorkspaceMembershipRecord {
|
||||
workspace: Workspace {
|
||||
id: WorkspaceId::new(row.try_get::<String, _>("id")?),
|
||||
slug: row.try_get("slug")?,
|
||||
display_name: row.try_get("display_name")?,
|
||||
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
|
||||
settings: row.try_get::<Json<Value>, _>("settings_json")?.0,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
},
|
||||
role: deserialize_enum_text(&row.try_get::<String, _>("role")?, "role")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_membership_record(row: &PgRow) -> Result<MembershipRecord, RegistryError> {
|
||||
Ok(MembershipRecord {
|
||||
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
|
||||
@@ -2419,6 +2684,19 @@ fn map_membership_record(row: &PgRow) -> Result<MembershipRecord, RegistryError>
|
||||
})
|
||||
}
|
||||
|
||||
fn map_auth_user_record(row: &PgRow) -> Result<AuthUserRecord, RegistryError> {
|
||||
Ok(AuthUserRecord {
|
||||
user: User {
|
||||
id: UserId::new(row.try_get::<String, _>("id")?),
|
||||
email: row.try_get("email")?,
|
||||
display_name: row.try_get("display_name")?,
|
||||
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
},
|
||||
password_hash: row.try_get("password_hash")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_invitation_record(row: &PgRow) -> Result<InvitationRecord, RegistryError> {
|
||||
Ok(InvitationRecord {
|
||||
invitation: InvitationToken {
|
||||
|
||||
Reference in New Issue
Block a user