tenants
This commit is contained in:
@@ -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;
|
||||
@@ -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] = [
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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>>>,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -6,3 +6,4 @@ pub mod folders;
|
||||
pub mod helpers;
|
||||
pub mod profile;
|
||||
pub mod tags;
|
||||
pub mod tenants;
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user