This commit is contained in:
2025-11-12 02:16:20 +01:00
parent cf070d35f0
commit 84734cca2f
20 changed files with 719 additions and 29 deletions
@@ -0,0 +1,4 @@
-- diesel:run_in_transaction = false
-- Enum values cannot be removed safely; this down migration intentionally left empty.
SELECT 1;
@@ -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 $$;
@@ -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');
@@ -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;
+4 -1
View File
@@ -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] = [
+4 -2
View File
@@ -42,16 +42,18 @@ pub struct JwtService {
impl JwtService {
pub fn from_config(config: &AppConfig) -> Result<Self> {
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),
})
+38
View File
@@ -28,6 +28,44 @@ use uuid::Uuid;
use crate::auth::jwt::PrincipalKind;
#[derive(Debug, Clone)]
pub struct TenantMembershipUser {
pub user_id: Uuid,
}
impl FromRequestParts<AppState> for TenantMembershipUser {
type Rejection = AppError;
#[allow(refining_impl_trait)]
fn from_request_parts<'a>(
parts: &'a mut Parts,
state: &AppState,
) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send + 'a {
let state = state.clone();
async move {
let TypedHeader(Authorization(bearer)) =
TypedHeader::<Authorization<Bearer>>::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<Mutex<Option<PgPooledConnection>>>,
+7 -2
View File
@@ -40,6 +40,10 @@ impl AppError {
Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
}
pub fn forbidden(message: impl Into<String>) -> 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<diesel::result::Error> 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)
}
}
}
+8
View File
@@ -77,6 +77,14 @@ impl<T> JsonResponse<T> {
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<T> From<T> for JsonResponse<T> {
+18
View File
@@ -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<ApiCapabilitySql, Pg> 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"),
}
}
+8
View File
@@ -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)]
-14
View File
@@ -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<AuthenticatedUser> {
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<AppState>,
user: AuthenticatedUser,
) -> AppResult<JsonResponse<TenantListResponse>> {
AuthService::new(&state).list_tenants(user)
}
#[utoipa::path(
post,
path = "/api/auth/passkeys/register/start",
+27 -1
View File
@@ -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::<AuthenticatedUser, _>(protected_state));
let upload_limit = state.config.upload_body_limit_bytes;
+154
View File
@@ -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<AppState>,
user: TenantMembershipUser,
) -> AppResult<Json<Vec<TenantSnippet>>> {
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<AppState>,
Path(tenant_id): Path<Uuid>,
user: TenantMembershipUser,
) -> AppResult<JsonResponse<TenantSnippet>> {
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<AppState>,
Path(tenant_id): Path<Uuid>,
user: AuthenticatedUser,
Json(payload): Json<UpdateTenantRequest>,
) -> AppResult<JsonResponse<TenantSnippet>> {
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<AppState>,
Path(tenant_id): Path<Uuid>,
user: AuthenticatedUser,
) -> AppResult<Json<Vec<TenantUserSummary>>> {
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<AppState>,
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
user: AuthenticatedUser,
) -> AppResult<JsonResponse<TenantUserSummary>> {
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<AppState>,
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
user: AuthenticatedUser,
Json(payload): Json<UpdateTenantUserRequest>,
) -> AppResult<JsonResponse<TenantUserSummary>> {
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<AppState>,
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
user: AuthenticatedUser,
) -> AppResult<StatusCode> {
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;
+40 -6
View File
@@ -442,15 +442,12 @@ impl<'a> AuthService<'a> {
Ok((headers, StatusCode::NO_CONTENT))
}
pub fn list_tenants(
&self,
user: AuthenticatedUser,
) -> AppResult<JsonResponse<TenantListResponse>> {
pub fn list_tenants(&self, user_id: Uuid) -> AppResult<JsonResponse<TenantListResponse>> {
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<Uuid> = 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<JsonResponse<TenantSnippet>> {
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,
+1
View File
@@ -6,3 +6,4 @@ pub mod folders;
pub mod helpers;
pub mod profile;
pub mod tags;
pub mod tenants;
+260
View File
@@ -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<Uuid>,
pub capability_set_slug: Option<String>,
}
#[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<TenantUserSummary>,
}
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<JsonResponse<crate::services::auth::TenantSnippet>> {
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<JsonResponse<TenantUserListResponse>> {
self.ensure_can_manage(user, tenant_id)?;
let mut conn = self.state.db_for_tenant(tenant_id)?;
let rows: Vec<(Uuid, String, Option<Uuid>, Option<String>)> =
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<JsonResponse<TenantUserSummary>> {
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<JsonResponse<TenantUserSummary>> {
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<Uuid> {
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::<Uuid>(conn)
.optional()?;
exists.ok_or_else(AppError::not_found)
}
fn load_membership_summary(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
) -> AppResult<TenantUserSummary> {
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<Uuid>, Option<String>)>(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()),
}
}
}
+17
View File
@@ -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<Tenant> {
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<Tenant> {
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<()> {
+1 -3
View File
@@ -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)?;
+51
View File
@@ -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::<String>),
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::<Uuid>),
))
.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(())
}