diff --git a/backend/migrations/202511110100_add_tenant_capability_enum/down.sql b/backend/migrations/202511110100_add_tenant_capability_enum/down.sql new file mode 100644 index 0000000..c7d48e0 --- /dev/null +++ b/backend/migrations/202511110100_add_tenant_capability_enum/down.sql @@ -0,0 +1,4 @@ +-- diesel:run_in_transaction = false + +-- Enum values cannot be removed safely; this down migration intentionally left empty. +SELECT 1; diff --git a/backend/migrations/202511110100_add_tenant_capability_enum/up.sql b/backend/migrations/202511110100_add_tenant_capability_enum/up.sql new file mode 100644 index 0000000..0063e29 --- /dev/null +++ b/backend/migrations/202511110100_add_tenant_capability_enum/up.sql @@ -0,0 +1,46 @@ +-- diesel:run_in_transaction = false + +DO $$ +BEGIN + BEGIN + EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:write'''; + EXCEPTION + WHEN undefined_object THEN + BEGIN + EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:write'''; + EXCEPTION + WHEN duplicate_object THEN NULL; + END; + WHEN duplicate_object THEN NULL; + END; +END $$; + +DO $$ +BEGIN + BEGIN + EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:reset'''; + EXCEPTION + WHEN undefined_object THEN + BEGIN + EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:reset'''; + EXCEPTION + WHEN duplicate_object THEN NULL; + END; + WHEN duplicate_object THEN NULL; + END; +END $$; + +DO $$ +BEGIN + BEGIN + EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:delete'''; + EXCEPTION + WHEN undefined_object THEN + BEGIN + EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:delete'''; + EXCEPTION + WHEN duplicate_object THEN NULL; + END; + WHEN duplicate_object THEN NULL; + END; +END $$; diff --git a/backend/migrations/202511110200_owner_tenant_capabilities/down.sql b/backend/migrations/202511110200_owner_tenant_capabilities/down.sql new file mode 100644 index 0000000..ed3e808 --- /dev/null +++ b/backend/migrations/202511110200_owner_tenant_capabilities/down.sql @@ -0,0 +1,9 @@ +-- diesel:run_in_transaction = false + +DELETE FROM tenant.capability_set_capabilities +WHERE capability IN ( + 'tenants:write'::api_capability, + 'tenants:reset'::api_capability, + 'tenants:delete'::api_capability + ) + AND capability_set_id IN (SELECT id FROM tenant.capability_sets WHERE slug = 'owner'); diff --git a/backend/migrations/202511110200_owner_tenant_capabilities/up.sql b/backend/migrations/202511110200_owner_tenant_capabilities/up.sql new file mode 100644 index 0000000..24cabab --- /dev/null +++ b/backend/migrations/202511110200_owner_tenant_capabilities/up.sql @@ -0,0 +1,22 @@ +-- diesel:run_in_transaction = false + +WITH owner_sets AS ( + SELECT id FROM tenant.capability_sets WHERE slug = 'owner' +) +INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability) +SELECT id, 'tenants:write'::api_capability FROM owner_sets +ON CONFLICT DO NOTHING; + +WITH owner_sets AS ( + SELECT id FROM tenant.capability_sets WHERE slug = 'owner' +) +INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability) +SELECT id, 'tenants:reset'::api_capability FROM owner_sets +ON CONFLICT DO NOTHING; + +WITH owner_sets AS ( + SELECT id FROM tenant.capability_sets WHERE slug = 'owner' +) +INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability) +SELECT id, 'tenants:delete'::api_capability FROM owner_sets +ON CONFLICT DO NOTHING; diff --git a/backend/src/auth/capability_sets.rs b/backend/src/auth/capability_sets.rs index f1c1983..e759a82 100644 --- a/backend/src/auth/capability_sets.rs +++ b/backend/src/auth/capability_sets.rs @@ -12,7 +12,7 @@ use crate::{ }, }; -const OWNER_CAPABILITIES: [ApiCapability; 19] = [ +const OWNER_CAPABILITIES: [ApiCapability; 22] = [ ApiCapability::CorrespondentsEdit, ApiCapability::CorrespondentsRead, ApiCapability::CorrespondentsWrite, @@ -32,6 +32,9 @@ const OWNER_CAPABILITIES: [ApiCapability; 19] = [ ApiCapability::WebdavWrite, ApiCapability::CapabilitySetsRead, ApiCapability::CapabilitySetsWrite, + ApiCapability::TenantsWrite, + ApiCapability::TenantsReset, + ApiCapability::TenantsDelete, ]; const USER_CAPABILITIES: [ApiCapability; 16] = [ diff --git a/backend/src/auth/jwt.rs b/backend/src/auth/jwt.rs index 75afd64..714ec10 100644 --- a/backend/src/auth/jwt.rs +++ b/backend/src/auth/jwt.rs @@ -42,16 +42,18 @@ pub struct JwtService { impl JwtService { pub fn from_config(config: &AppConfig) -> Result { + let access_expiry = Duration::minutes(config.jwt_expiry_minutes); + Ok(Self { encoding: EncodingKey::from_secret(config.jwt_secret.as_bytes()), decoding: DecodingKey::from_secret(config.jwt_secret.as_bytes()), issuer: config.jwt_issuer.clone(), audience: config.jwt_audience.clone(), - expiry: Duration::minutes(config.jwt_expiry_minutes), + expiry: access_expiry, download_audience: config.download_token_audience.clone(), download_expiry: Duration::minutes(config.download_token_expiry_minutes), selector_audience: format!("{}:tenant-selector", config.jwt_audience), - selector_expiry: Duration::minutes(15), + selector_expiry: access_expiry, signup_audience: format!("{}:signup", config.jwt_audience), signup_expiry: Duration::minutes(15), }) diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index abcf34f..9218f61 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -28,6 +28,44 @@ use uuid::Uuid; use crate::auth::jwt::PrincipalKind; +#[derive(Debug, Clone)] +pub struct TenantMembershipUser { + pub user_id: Uuid, +} + +impl FromRequestParts for TenantMembershipUser { + type Rejection = AppError; + + #[allow(refining_impl_trait)] + fn from_request_parts<'a>( + parts: &'a mut Parts, + state: &AppState, + ) -> impl std::future::Future> + Send + 'a { + let state = state.clone(); + async move { + let TypedHeader(Authorization(bearer)) = + TypedHeader::>::from_request_parts(parts, &state) + .await + .map_err(|_| AppError::unauthorized())?; + + if let Ok(claims) = state.jwt.verify_token(bearer.token()) { + return Ok(Self { + user_id: claims.sub, + }); + } + + let selector = state + .jwt + .verify_tenant_selector_token(bearer.token()) + .map_err(|_| AppError::unauthorized())?; + + Ok(Self { + user_id: selector.sub, + }) + } + } +} + #[derive(Clone)] pub struct TenantConnectionHolder { inner: Arc>>, diff --git a/backend/src/error.rs b/backend/src/error.rs index 6df113a..3c4c855 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -40,6 +40,10 @@ impl AppError { Self::new(StatusCode::UNAUTHORIZED, "unauthorized") } + pub fn forbidden(message: impl Into) -> Self { + Self::new(StatusCode::FORBIDDEN, message) + } + pub fn not_found() -> Self { Self::new(StatusCode::NOT_FOUND, "resource not found") } @@ -91,8 +95,9 @@ impl From for AppError { match value { diesel::result::Error::NotFound => AppError::not_found(), other => { - tracing::error!(error = ?other, "database operation failed"); - AppError::internal("database operation failed") + let message = format!("database operation failed: {other}"); + tracing::error!(error = ?other, message); + AppError::internal(message) } } } diff --git a/backend/src/http/responders.rs b/backend/src/http/responders.rs index 20571c0..2d30d02 100644 --- a/backend/src/http/responders.rs +++ b/backend/src/http/responders.rs @@ -77,6 +77,14 @@ impl JsonResponse { pub fn accepted(payload: T) -> Self { Self::new(StatusCode::ACCEPTED, payload) } + + pub fn into_inner(self) -> T { + self.payload + } + + pub fn as_inner(&self) -> &T { + &self.payload + } } impl From for JsonResponse { diff --git a/backend/src/models.rs b/backend/src/models.rs index ede3a84..ece7530 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -111,6 +111,12 @@ pub enum ApiCapability { CapabilitySetsRead, #[serde(rename = "capability_sets:write")] CapabilitySetsWrite, + #[serde(rename = "tenants:write")] + TenantsWrite, + #[serde(rename = "tenants:reset")] + TenantsReset, + #[serde(rename = "tenants:delete")] + TenantsDelete, } impl MagicTokenKind { @@ -148,6 +154,9 @@ impl ApiCapability { ApiCapability::WebdavWrite => "webdav:write", ApiCapability::CapabilitySetsRead => "capability_sets:read", ApiCapability::CapabilitySetsWrite => "capability_sets:write", + ApiCapability::TenantsWrite => "tenants:write", + ApiCapability::TenantsReset => "tenants:reset", + ApiCapability::TenantsDelete => "tenants:delete", } } @@ -172,6 +181,9 @@ impl ApiCapability { "webdav:write", "capability_sets:read", "capability_sets:write", + "tenants:write", + "tenants:reset", + "tenants:delete", ] } } @@ -237,6 +249,9 @@ impl FromSql for ApiCapability { "webdav:write" => Ok(ApiCapability::WebdavWrite), "capability_sets:read" => Ok(ApiCapability::CapabilitySetsRead), "capability_sets:write" => Ok(ApiCapability::CapabilitySetsWrite), + "tenants:write" => Ok(ApiCapability::TenantsWrite), + "tenants:reset" => Ok(ApiCapability::TenantsReset), + "tenants:delete" => Ok(ApiCapability::TenantsDelete), other => Err(Box::new(std::io::Error::new( std::io::ErrorKind::InvalidData, format!("invalid api_capability '{other}'"), @@ -281,6 +296,9 @@ impl str::FromStr for ApiCapability { "webdav:write" => Ok(ApiCapability::WebdavWrite), "capability_sets:read" => Ok(ApiCapability::CapabilitySetsRead), "capability_sets:write" => Ok(ApiCapability::CapabilitySetsWrite), + "tenants:write" => Ok(ApiCapability::TenantsWrite), + "tenants:reset" => Ok(ApiCapability::TenantsReset), + "tenants:delete" => Ok(ApiCapability::TenantsDelete), _ => Err("unsupported api capability"), } } diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index 5b20f14..7e947af 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -13,6 +13,7 @@ impl OpenApi for ApiDoc { doc.merge(crate::routes::correspondents::CorrespondentsApiDoc::openapi()); doc.merge(crate::routes::profile::ProfileApiDoc::openapi()); doc.merge(crate::routes::capability_sets::CapabilitySetsApiDoc::openapi()); + doc.merge(crate::routes::tenants::TenantsApiDoc::openapi()); doc.info = InfoBuilder::new() .title("Papercrate API") @@ -56,6 +57,10 @@ impl OpenApi for ApiDoc { .name("Capability Sets") .description(Some("Capability set management")) .build(), + TagBuilder::new() + .name("Tenants") + .description(Some("Tenant catalog")) + .build(), ]); doc @@ -112,6 +117,9 @@ pub mod schemas { pub use crate::services::tags::{ AssignTagsRequest, BulkTagAction, BulkTagRequest, BulkTagResponse, }; + pub use crate::services::tenants::{ + TenantUserListResponse, TenantUserSummary, UpdateTenantRequest, UpdateTenantUserRequest, + }; } #[cfg(test)] diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 4473f22..5531c56 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -38,7 +38,6 @@ use crate::{ refresh, logout, me, - list_tenants, select_tenant, passkey_register_start, passkey_register_finish, @@ -201,19 +200,6 @@ pub async fn me(user: AuthenticatedUser) -> Json { Json(user) } -#[utoipa::path( - get, - path = "/api/auth/tenants", - responses((status = 200, description = "List of tenants", body = TenantListResponse)), - tag = "Auth" -)] -pub async fn list_tenants( - State(state): State, - user: AuthenticatedUser, -) -> AppResult> { - AuthService::new(&state).list_tenants(user) -} - #[utoipa::path( post, path = "/api/auth/passkeys/register/start", diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 3eac18e..68c0b62 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -28,6 +28,7 @@ pub mod folders; pub mod health; pub mod profile; pub mod tags; +pub mod tenants; pub mod webdav; pub fn create_router(state: AppState) -> Router<()> { @@ -67,7 +68,6 @@ pub fn create_router(state: AppState) -> Router<()> { .route("/refresh", post(auth::refresh)) .route("/logout", post(auth::logout)) .route("/select-tenant", post(auth::select_tenant)) - .route("/tenants", get(auth::list_tenants)) .route( "/passkeys/register/start", post(auth::passkey_register_start), @@ -373,6 +373,31 @@ pub fn create_router(state: AppState) -> Router<()> { ])), ); + let manage_tenants_layer = RequireCapabilitiesLayer::all([ApiCapability::TenantsWrite]); + let tenants_routes = Router::new() + .route("/", get(tenants::list_tenants)) + .route("/{tenant_id}", get(tenants::get_tenant)) + .route( + "/{tenant_id}", + patch(tenants::update_tenant).layer(manage_tenants_layer.clone()), + ) + .route( + "/{tenant_id}/users", + get(tenants::list_tenant_users).layer(manage_tenants_layer.clone()), + ) + .route( + "/{tenant_id}/users/{user_id}", + get(tenants::get_tenant_user).layer(manage_tenants_layer.clone()), + ) + .route( + "/{tenant_id}/users/{user_id}", + patch(tenants::update_tenant_user).layer(manage_tenants_layer.clone()), + ) + .route( + "/{tenant_id}/users/{user_id}", + delete(tenants::delete_tenant_user).layer(manage_tenants_layer.clone()), + ); + let protected_routes = Router::new() .nest("/api/documents", documents_routes) .nest("/api/folders", folders_routes) @@ -382,6 +407,7 @@ pub fn create_router(state: AppState) -> Router<()> { .nest("/api/capability-sets", capability_sets_routes) .nest("/api/capabilities", capabilities_routes) .nest("/api/assets", assets_routes) + .nest("/api/tenants", tenants_routes) .layer(middleware::from_extractor_with_state::(protected_state)); let upload_limit = state.config.upload_body_limit_bytes; diff --git a/backend/src/routes/tenants.rs b/backend/src/routes/tenants.rs new file mode 100644 index 0000000..da79041 --- /dev/null +++ b/backend/src/routes/tenants.rs @@ -0,0 +1,154 @@ +use axum::extract::{Path, State}; +use axum::{http::StatusCode, Json}; +use uuid::Uuid; + +use crate::auth::{AuthenticatedUser, TenantMembershipUser}; +use crate::error::AppResult; +use crate::http::responders::JsonResponse; +use crate::services::auth::{AuthService, TenantSnippet}; +use crate::services::tenants::{ + TenantApiService, TenantUserListResponse, TenantUserSummary, UpdateTenantRequest, + UpdateTenantUserRequest, +}; +use crate::state::AppState; + +#[utoipa::path( + get, + path = "/api/tenants", + responses((status = 200, body = [TenantSnippet], description = "Tenant memberships for the current user")), + tag = "Tenants" +)] +pub async fn list_tenants( + State(state): State, + user: TenantMembershipUser, +) -> AppResult>> { + let response = AuthService::new(&state).list_tenants(user.user_id)?; + Ok(Json(response.into_inner().tenants)) +} + +#[utoipa::path( + get, + path = "/api/tenants/{tenant_id}", + params(("tenant_id" = Uuid, Path, description = "Tenant identifier")), + responses((status = 200, body = TenantSnippet, description = "Tenant details")), + tag = "Tenants" +)] +pub async fn get_tenant( + State(state): State, + Path(tenant_id): Path, + user: TenantMembershipUser, +) -> AppResult> { + AuthService::new(&state).get_tenant(user.user_id, tenant_id) +} + +#[utoipa::path( + patch, + path = "/api/tenants/{tenant_id}", + params(("tenant_id" = Uuid, Path, description = "Tenant identifier")), + request_body = UpdateTenantRequest, + responses((status = 200, body = TenantSnippet, description = "Updated tenant")), + tag = "Tenants" +)] +pub async fn update_tenant( + State(state): State, + Path(tenant_id): Path, + user: AuthenticatedUser, + Json(payload): Json, +) -> AppResult> { + TenantApiService::new(&state).update_name(user, tenant_id, payload) +} + +#[utoipa::path( + get, + path = "/api/tenants/{tenant_id}/users", + params(("tenant_id" = Uuid, Path, description = "Tenant ID")), + responses((status = 200, body = [TenantUserSummary], description = "All users for the tenant")), + tag = "Tenants" +)] +pub async fn list_tenant_users( + State(state): State, + Path(tenant_id): Path, + user: AuthenticatedUser, +) -> AppResult>> { + let response = TenantApiService::new(&state).list_users(&user, tenant_id)?; + Ok(Json(response.into_inner().users)) +} + +#[utoipa::path( + get, + path = "/api/tenants/{tenant_id}/users/{user_id}", + params( + ("tenant_id" = Uuid, Path, description = "Tenant ID"), + ("user_id" = Uuid, Path, description = "User ID") + ), + responses((status = 200, body = TenantUserSummary, description = "Tenant user details")), + tag = "Tenants" +)] +pub async fn get_tenant_user( + State(state): State, + Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>, + user: AuthenticatedUser, +) -> AppResult> { + TenantApiService::new(&state).get_user(&user, tenant_id, target_user_id) +} + +#[utoipa::path( + patch, + path = "/api/tenants/{tenant_id}/users/{user_id}", + params( + ("tenant_id" = Uuid, Path, description = "Tenant ID"), + ("user_id" = Uuid, Path, description = "User ID") + ), + request_body = UpdateTenantUserRequest, + responses((status = 200, body = TenantUserSummary, description = "Updated tenant user")), + tag = "Tenants" +)] +pub async fn update_tenant_user( + State(state): State, + Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>, + user: AuthenticatedUser, + Json(payload): Json, +) -> AppResult> { + TenantApiService::new(&state).update_user(&user, tenant_id, target_user_id, payload) +} + +#[utoipa::path( + delete, + path = "/api/tenants/{tenant_id}/users/{user_id}", + params( + ("tenant_id" = Uuid, Path, description = "Tenant ID"), + ("user_id" = Uuid, Path, description = "User ID") + ), + responses((status = 204, description = "Membership removed")), + tag = "Tenants" +)] +pub async fn delete_tenant_user( + State(state): State, + Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>, + user: AuthenticatedUser, +) -> AppResult { + TenantApiService::new(&state).remove_user(&user, tenant_id, target_user_id)?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + list_tenants, + get_tenant, + update_tenant, + list_tenant_users, + get_tenant_user, + update_tenant_user, + delete_tenant_user, + ), + components(schemas( + crate::services::auth::TenantListResponse, + crate::services::auth::TenantSnippet, + UpdateTenantRequest, + UpdateTenantUserRequest, + TenantUserListResponse, + TenantUserSummary, + )) +)] +pub struct TenantsApiDoc; diff --git a/backend/src/services/auth.rs b/backend/src/services/auth.rs index 5680544..bb82af6 100644 --- a/backend/src/services/auth.rs +++ b/backend/src/services/auth.rs @@ -442,15 +442,12 @@ impl<'a> AuthService<'a> { Ok((headers, StatusCode::NO_CONTENT)) } - pub fn list_tenants( - &self, - user: AuthenticatedUser, - ) -> AppResult> { + pub fn list_tenants(&self, user_id: Uuid) -> AppResult> { let mut conn = self.state.db_unscoped()?; - apply_user_guc(&mut conn, user.user_id)?; + apply_user_guc(&mut conn, user_id)?; let tenant_ids: Vec = memberships_dsl::user_memberships - .filter(memberships_dsl::user_id.eq(user.user_id)) + .filter(memberships_dsl::user_id.eq(user_id)) .select(memberships_dsl::tenant_id) .load(&mut conn)?; @@ -475,6 +472,43 @@ impl<'a> AuthService<'a> { ok_json(TenantListResponse { tenants }) } + pub fn get_tenant( + &self, + user_id: Uuid, + tenant_id: Uuid, + ) -> AppResult> { + let mut conn = self.state.db_unscoped()?; + apply_user_guc(&mut conn, user_id)?; + + let is_member: bool = diesel::select(diesel::dsl::exists( + memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user_id)) + .filter(memberships_dsl::tenant_id.eq(tenant_id)), + )) + .get_result(&mut conn)?; + + clear_user_guc(&mut conn)?; + + if !is_member { + return Err(AppError::not_found()); + } + + let (name, status): (String, TenantStatus) = tenant_dsl::tenants + .find(tenant_id) + .select((tenant_dsl::name, tenant_dsl::status)) + .first(&mut conn) + .map_err(AppError::from)?; + + if status != TenantStatus::Active { + return Err(AppError::not_found()); + } + + ok_json(TenantSnippet { + id: tenant_id, + name, + }) + } + pub fn passkey_register_start( &self, user: AuthenticatedUser, diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index bf15cd1..d8a266b 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -6,3 +6,4 @@ pub mod folders; pub mod helpers; pub mod profile; pub mod tags; +pub mod tenants; diff --git a/backend/src/services/tenants.rs b/backend/src/services/tenants.rs new file mode 100644 index 0000000..42109e5 --- /dev/null +++ b/backend/src/services/tenants.rs @@ -0,0 +1,260 @@ +use chrono::Utc; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::auth::AuthenticatedUser; +use crate::error::{AppError, AppResult}; +use crate::http::responders::{ok_json, JsonResponse}; +use crate::models::ApiCapability; +use crate::schema::{ + capability_sets::dsl as cs_dsl, user_memberships::dsl as memberships_dsl, + user_sessions::dsl as session_dsl, users::dsl as users_dsl, +}; +use crate::state::{AppState, PgPooledConnection}; + +#[derive(Deserialize, ToSchema)] +pub struct UpdateTenantRequest { + #[schema(example = "Acme Inc.")] + pub name: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct UpdateTenantUserRequest { + #[schema(example = "a2f1bc73-4c90-4bb9-9da9-1c5d04be12ac")] + pub capability_set_id: Uuid, +} + +#[derive(Serialize, ToSchema)] +#[schema(example = json!({ + "user_id": "11111111-2222-3333-4444-555555555555", + "username": "cfo", + "capability_set_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "capability_set_slug": "owner" +}))] +pub struct TenantUserSummary { + pub user_id: Uuid, + pub username: String, + pub capability_set_id: Option, + pub capability_set_slug: Option, +} + +#[derive(Serialize, ToSchema)] +#[schema(example = json!({ + "users": [ + { + "user_id": "11111111-2222-3333-4444-555555555555", + "username": "alice", + "capability_set_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "capability_set_slug": "owner" + }, + { + "user_id": "66666666-7777-8888-9999-000000000000", + "username": "bob", + "capability_set_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", + "capability_set_slug": "user" + } + ] +}))] +pub struct TenantUserListResponse { + pub users: Vec, +} + +pub struct TenantApiService<'a> { + state: &'a AppState, +} + +impl<'a> TenantApiService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + pub fn update_name( + &self, + user: AuthenticatedUser, + tenant_id: Uuid, + payload: UpdateTenantRequest, + ) -> AppResult> { + self.ensure_can_manage(&user, tenant_id)?; + let tenant = self.state.tenants.update_name(tenant_id, &payload.name)?; + ok_json(crate::services::auth::TenantSnippet { + id: tenant.id, + name: tenant.name, + }) + } + + pub fn list_users( + &self, + user: &AuthenticatedUser, + tenant_id: Uuid, + ) -> AppResult> { + self.ensure_can_manage(user, tenant_id)?; + let mut conn = self.state.db_for_tenant(tenant_id)?; + let rows: Vec<(Uuid, String, Option, Option)> = + memberships_dsl::user_memberships + .inner_join(users_dsl::users.on(users_dsl::id.eq(memberships_dsl::user_id))) + .left_join( + cs_dsl::capability_sets + .on(cs_dsl::id.nullable().eq(memberships_dsl::capability_set_id)), + ) + .select(( + users_dsl::id, + users_dsl::username, + memberships_dsl::capability_set_id, + cs_dsl::slug.nullable(), + )) + .order(users_dsl::username.asc()) + .load(&mut conn)?; + + let users = rows + .into_iter() + .map( + |(user_id, username, capability_set_id, capability_set_slug)| TenantUserSummary { + user_id, + username, + capability_set_id, + capability_set_slug, + }, + ) + .collect(); + + ok_json(TenantUserListResponse { users }) + } + + pub fn get_user( + &self, + user: &AuthenticatedUser, + tenant_id: Uuid, + target_user_id: Uuid, + ) -> AppResult> { + self.ensure_can_manage(user, tenant_id)?; + let mut conn = self.state.db_for_tenant(tenant_id)?; + let summary = self.load_membership_summary(&mut conn, tenant_id, target_user_id)?; + ok_json(summary) + } + + pub fn update_user( + &self, + user: &AuthenticatedUser, + tenant_id: Uuid, + target_user_id: Uuid, + payload: UpdateTenantUserRequest, + ) -> AppResult> { + self.ensure_can_manage(user, tenant_id)?; + let mut conn = self.state.db_for_tenant(tenant_id)?; + + let capability_set_id = self.resolve_capability_set_id(&mut conn, tenant_id, &payload)?; + + let updated = diesel::update( + memberships_dsl::user_memberships + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .filter(memberships_dsl::user_id.eq(target_user_id)), + ) + .set(( + memberships_dsl::capability_set_id.eq(Some(capability_set_id)), + memberships_dsl::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(&mut conn)?; + + if updated == 0 { + return Err(AppError::not_found()); + } + + let summary = self.load_membership_summary(&mut conn, tenant_id, target_user_id)?; + ok_json(summary) + } + + pub fn remove_user( + &self, + user: &AuthenticatedUser, + tenant_id: Uuid, + target_user_id: Uuid, + ) -> AppResult<()> { + self.ensure_can_manage(user, tenant_id)?; + let mut conn = self.state.db_for_tenant(tenant_id)?; + + let removed = diesel::delete( + memberships_dsl::user_memberships + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .filter(memberships_dsl::user_id.eq(target_user_id)), + ) + .execute(&mut conn)?; + + if removed == 0 { + return Err(AppError::not_found()); + } + + diesel::delete( + session_dsl::user_sessions + .filter(session_dsl::tenant_id.eq(tenant_id)) + .filter(session_dsl::user_id.eq(target_user_id)), + ) + .execute(&mut conn)?; + + Ok(()) + } + + fn ensure_can_manage(&self, user: &AuthenticatedUser, tenant_id: Uuid) -> AppResult<()> { + if user.tenant_id != tenant_id { + return Err(AppError::forbidden("cannot manage another tenant")); + } + + if !user.capabilities.contains(&ApiCapability::TenantsWrite) { + return Err(AppError::forbidden("missing tenants:write capability")); + } + + Ok(()) + } + + fn resolve_capability_set_id( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + payload: &UpdateTenantUserRequest, + ) -> AppResult { + let exists = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .filter(cs_dsl::id.eq(payload.capability_set_id)) + .select(cs_dsl::id) + .first::(conn) + .optional()?; + exists.ok_or_else(AppError::not_found) + } + + fn load_membership_summary( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + ) -> AppResult { + let row = memberships_dsl::user_memberships + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .filter(memberships_dsl::user_id.eq(user_id)) + .inner_join(users_dsl::users.on(users_dsl::id.eq(memberships_dsl::user_id))) + .left_join( + cs_dsl::capability_sets + .on(cs_dsl::id.nullable().eq(memberships_dsl::capability_set_id)), + ) + .select(( + users_dsl::id, + users_dsl::username, + memberships_dsl::capability_set_id, + cs_dsl::slug.nullable(), + )) + .first::<(Uuid, String, Option, Option)>(conn) + .optional()?; + + match row { + Some((user_id, username, capability_set_id, capability_set_slug)) => { + Ok(TenantUserSummary { + user_id, + username, + capability_set_id, + capability_set_slug, + }) + } + None => Err(AppError::not_found()), + } + } +} diff --git a/backend/src/tenants.rs b/backend/src/tenants.rs index 8ef9374..8e8cf69 100644 --- a/backend/src/tenants.rs +++ b/backend/src/tenants.rs @@ -24,6 +24,13 @@ impl TenantRepository { .first(conn) .map_err(Into::into) } + + pub fn update_name(conn: &mut PgConnection, tenant_id: Uuid, name: &str) -> AppResult { + diesel::update(dsl::tenants.find(tenant_id)) + .set(dsl::name.eq(name)) + .execute(conn)?; + Self::get_by_id(conn, tenant_id) + } } #[derive(Clone)] @@ -128,6 +135,16 @@ impl TenantService { TenantRepository::get_by_id(conn, id) } + + pub fn update_name(&self, tenant_id: Uuid, name: &str) -> AppResult { + let mut conn = self.pool.get().map_err(|err| { + tracing::error!(error = ?err, "database pool error"); + AppError::internal("database pool error") + })?; + + let normalized = normalize_tenant_name(name)?; + TenantRepository::update_name(&mut conn, tenant_id, &normalized) + } } pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> { diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs index 4eb0052..e3539de 100644 --- a/backend/tests/auth_flow.rs +++ b/backend/tests/auth_flow.rs @@ -546,9 +546,7 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<() let (login, refresh_cookie) = login_with_session(&app, "multipass", password).await?; - let tenants_response = app - .get("/api/auth/tenants", Some(&login.access_token)) - .await?; + let tenants_response = app.get("/api/tenants", Some(&login.access_token)).await?; assert_eq!(tenants_response.status(), StatusCode::OK); let tenants_body = body_to_vec(tenants_response.into_body()).await?; let tenant_list: TenantListResponse = serde_json::from_slice(&tenants_body)?; diff --git a/backend/tests/tenants_flow.rs b/backend/tests/tenants_flow.rs new file mode 100644 index 0000000..c1bd1b1 --- /dev/null +++ b/backend/tests/tenants_flow.rs @@ -0,0 +1,51 @@ +use anyhow::Result; +use axum::http::StatusCode; +use chrono::Utc; +use diesel::prelude::*; +use papercrate::models::TenantStatus; +use papercrate::schema::tenants::dsl as tenants_dsl; +use papercrate::test_support::{acquire_db_lock, TestApp, TestUserRole}; +use serde_json::json; +use uuid::Uuid; + +#[tokio::test] +async fn tenant_management_is_scoped_to_memberships() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "tenant-owner"; + app.insert_user(username, TestUserRole::Owner).await?; + let token = app.login_token(username, "irrelevant").await?; + + let other_tenant_id = app + .with_conn(|conn| { + let other_id = Uuid::new_v4(); + let now = Utc::now().naive_utc(); + diesel::insert_into(tenants_dsl::tenants) + .values(( + tenants_dsl::id.eq(other_id), + tenants_dsl::name.eq(format!("foreign-{other_id}")), + tenants_dsl::storage_root.eq(Some(format!("test-tenants/{other_id}/"))), + tenants_dsl::quickwit_index.eq(None::), + tenants_dsl::config.eq(json!({})), + tenants_dsl::created_at.eq(now), + tenants_dsl::updated_at.eq(now), + tenants_dsl::status.eq(TenantStatus::Active), + tenants_dsl::created_by.eq(None::), + )) + .execute(conn)?; + Ok(other_id) + }) + .await?; + + let response = app + .patch_json( + &format!("/api/tenants/{other_tenant_id}"), + &json!({ "name": "should-not-work" }), + Some(&token), + ) + .await?; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + Ok(()) +}