1mod endpoint;
2pub mod hooks;
3mod native;
4
5pub mod policy;
6
7pub mod extensions;
8
9pub mod handlers;
10
11pub use hooks::invitation::{
12 InvitationLimit, OrganizationInvitationContext, OrganizationInvitationCreatePatch,
13 OrganizationInvitationCreationContext, OrganizationInvitationDelivery,
14 OrganizationInvitationDraft, OrganizationInvitationEmailSender, OrganizationInvitationHooks,
15 OrganizationInvitationLimitContext, OrganizationInvitationLimitResolver,
16};
17
18pub mod rbac;
19
20pub mod types;
21
22use alibi_core::error::AuthResult;
23use alibi_core::plugin::{AuthContext, AuthPlugin, AuthRoute};
24use alibi_core::types::{AuthRequest, AuthResponse, HttpMethod};
25use async_trait::async_trait;
26pub use extensions::{
27 DefaultTeamContext, DefaultTeamFactory, DynamicAccessControlConfig, OrganizationLimitResolver,
28 OrganizationTeamHooks, TeamsConfig, default_organization_statements,
29};
30pub use hooks::invitation::{
31 OrganizationInvitationAcceptanceContext, OrganizationInvitationAcceptanceHooks,
32 OrganizationInvitationAcceptedContext,
33};
34pub use hooks::member::{
35 OrganizationMemberAddedContext, OrganizationMemberAdditionContext,
36 OrganizationMemberAdditionDraft, OrganizationMemberAdditionHooks,
37};
38pub use hooks::member::{OrganizationMemberRemovalContext, OrganizationMemberRemovalHooks};
39pub use hooks::member::{
40 OrganizationMemberRoleContext, OrganizationMemberRoleHooks, OrganizationMemberRolePatch,
41 OrganizationMemberRoleUpdatedContext,
42};
43pub use hooks::organization::{
44 OrganizationCreatePatch, OrganizationCreatedContext, OrganizationCreationHooks,
45 OrganizationDeleteContext, OrganizationDeletionHooks, OrganizationDraftContext,
46 OrganizationMemberCreatePatch, OrganizationMemberDraftContext,
47};
48pub use hooks::organization::{
49 OrganizationUpdateContext, OrganizationUpdateHooks, OrganizationUpdateInput,
50 OrganizationUpdatePatch, OrganizationUpdatedContext,
51};
52pub use policy::OrganizationCreationPolicy;
53pub use policy::{MembershipLimit, OrganizationMembershipLimitResolver};
54use std::collections::HashMap;
55
56#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
58pub struct RolePermissions {
59 pub organization: Vec<String>,
60 pub member: Vec<String>,
61 pub invitation: Vec<String>,
62 pub api_key: Vec<String>,
66 #[serde(default)]
67 pub team: Vec<String>,
68 #[serde(default)]
69 pub ac: Vec<String>,
70 #[serde(flatten)]
71 pub additional: alibi_core::types::OrganizationPermissions,
72}
73
74#[derive(Debug, Clone, alibi_core::PluginConfig)]
76#[plugin(name = "OrganizationPlugin")]
77pub struct OrganizationConfig {
78 #[config(default = alibi_core::field_policy::SessionFields::default(), skip)]
80 pub organization_fields: alibi_core::field_policy::SessionFields,
81 #[config(default = true)]
83 pub allow_user_to_create_organization: bool,
84 #[config(default = None)]
88 pub organization_limit: Option<f64>,
89 #[config(default = None, skip)]
91 pub creation_policy: Option<std::sync::Arc<dyn OrganizationCreationPolicy>>,
92 #[config(default = None, skip)]
94 pub creation_hooks: Option<std::sync::Arc<dyn OrganizationCreationHooks>>,
95 #[config(default = None, skip)]
97 pub deletion_hooks: Option<std::sync::Arc<dyn OrganizationDeletionHooks>>,
98 #[config(default = None, skip)]
100 pub update_hooks: Option<std::sync::Arc<dyn OrganizationUpdateHooks>>,
101 #[config(default = None, skip)]
103 pub member_role_hooks: Option<std::sync::Arc<dyn OrganizationMemberRoleHooks>>,
104 #[config(default = None, skip)]
106 pub member_removal_hooks: Option<std::sync::Arc<dyn OrganizationMemberRemovalHooks>>,
107 #[config(default = None, skip)]
109 pub member_addition_hooks: Option<std::sync::Arc<dyn OrganizationMemberAdditionHooks>>,
110 #[config(default = None, skip)]
112 pub invitation_acceptance_hooks:
113 Option<std::sync::Arc<dyn OrganizationInvitationAcceptanceHooks>>,
114 #[config(default = Some(MembershipLimit::Fixed(100.0)), skip)]
118 pub membership_limit: Option<MembershipLimit>,
119 #[config(default = "owner".to_owned())]
121 pub creator_role: String,
122 #[config(default = Some(172_800.0))]
124 pub invitation_expires_in: Option<f64>,
125 #[config(default = Some(InvitationLimit::Fixed(100.0)), skip)]
127 pub invitation_limit: Option<InvitationLimit>,
128 #[config(default = false)]
130 pub cancel_pending_invitations_on_reinvite: bool,
131 #[config(default = None, skip)]
132 pub invitation_hooks: Option<std::sync::Arc<dyn OrganizationInvitationHooks>>,
133 #[config(default = None, skip)]
134 pub send_invitation_email: Option<std::sync::Arc<dyn OrganizationInvitationEmailSender>>,
135 #[config(default = false)]
137 pub disable_organization_deletion: bool,
138 #[config(default = None, skip)]
141 pub roles: Option<HashMap<String, RolePermissions>>,
142 #[config(default = None)]
145 pub require_email_verification_on_invitation: Option<bool>,
146 #[config(default = TeamsConfig::default(), skip)]
147 pub teams: TeamsConfig,
148 #[config(default = DynamicAccessControlConfig::default(), skip)]
149 pub dynamic_access_control: DynamicAccessControlConfig,
150 #[config(default = None, skip)]
151 pub access_control: Option<alibi_core::types::OrganizationPermissions>,
152}
153
154impl OrganizationConfig {
155 #[must_use]
157 pub fn effective_creator_role(&self) -> &str {
158 if self.creator_role.is_empty() {
159 "owner"
160 } else {
161 &self.creator_role
162 }
163 }
164}
165
166pub struct OrganizationPlugin {
168 config: OrganizationConfig,
169}
170
171impl OrganizationPlugin {
172 pub async fn add_member_with_headers<S: alibi_core::AuthSchema>(
182 &self,
183 ctx: &AuthContext<S>,
184 headers: &HashMap<String, String>,
185 body: &types::AddOrganizationMemberRequest,
186 ) -> AuthResult<types::BasicMemberResponse> {
187 handlers::member_addition::add_member(body, headers, &self.config, ctx).await
188 }
189
190 pub async fn remove_member_with_headers<S: alibi_core::AuthSchema>(
198 &self,
199 ctx: &AuthContext<S>,
200 headers: &HashMap<String, String>,
201 body: &types::RemoveMemberRequest,
202 ) -> AuthResult<types::RemovedMemberResponse<types::OrganizationMemberRemovalSnapshot>> {
203 let mut resolution = AuthRequest::new(HttpMethod::Post, "/organization/remove-member");
204 resolution.headers = headers
205 .iter()
206 .map(|(name, value)| (name.to_ascii_lowercase(), value.clone()))
207 .collect();
208 let (user, session) = handlers::extension_common::session(&resolution, ctx).await?;
209 handlers::member::remove_member_core(body, &user, &session, &self.config, ctx).await
210 }
211
212 pub async fn delete_organization_with_headers<S: alibi_core::AuthSchema>(
222 &self,
223 ctx: &AuthContext<S>,
224 headers: &HashMap<String, String>,
225 body: &types::DeleteOrganizationRequest,
226 ) -> AuthResult<Option<types::OrganizationResponse>> {
227 if self.config.disable_organization_deletion {
228 return Err(handlers::extension_common::org_error(
229 404,
230 "ORGANIZATION_DELETION_DISABLED",
231 ));
232 }
233 let mut resolution = AuthRequest::new(HttpMethod::Post, "/organization/delete");
234 resolution.headers = headers
235 .iter()
236 .map(|(name, value)| (name.to_ascii_lowercase(), value.clone()))
237 .collect();
238 let (user, session) = ctx.require_cached_session(&resolution).await?;
239 handlers::org::delete_organization_core(
240 body,
241 &user,
242 &session,
243 hooks::organization::DeleteInvocation {
244 headers,
245 request: None,
246 },
247 &self.config,
248 ctx,
249 )
250 .await
251 }
252
253 pub async fn create_organization_for_user<S: alibi_core::AuthSchema>(
262 &self,
263 ctx: &AuthContext<S>,
264 user_id: &str,
265 body: &types::CreateOrganizationRequest,
266 ) -> AuthResult<
267 types::CreateOrganizationResponse<
268 types::CreatedOrganizationResponse,
269 types::BasicMemberResponse,
270 >,
271 > {
272 handlers::org_input::validate_trusted_create(body)?;
273 let user = ctx
274 .database
275 .get_user_by_id(user_id)
276 .await?
277 .ok_or(alibi_core::AuthError::Unauthenticated)?;
278 handlers::org::create_organization_core(body, &user, None, None, &self.config, ctx).await
279 }
280}
281
282pub(crate) const METADATA_ENABLED: &str = "organization.enabled";
284
285pub(crate) const METADATA_ROLES: &str = "organization.roles";
288
289pub(crate) const METADATA_CREATOR_ROLE: &str = "organization.creator_role";
291
292#[async_trait]
293impl<S: alibi_core::AuthSchema> AuthPlugin<S> for OrganizationPlugin {
294 route_openapi_metadata!(S);
295
296 fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
297 endpoint::definitions()
298 }
299 fn validate_endpoint(
300 &self,
301 call: &alibi_core::endpoint::EndpointCall,
302 _ctx: &AuthContext<S>,
303 ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
304 endpoint::validate(call, &self.config.organization_fields)
305 }
306 async fn on_endpoint(
307 &self,
308 call: &alibi_core::endpoint::EndpointCall,
309 ctx: &AuthContext<S>,
310 ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
311 self.call_endpoint(call, ctx).await
312 }
313
314 fn name(&self) -> &'static str {
315 "organization"
316 }
317
318 fn session_fields(&self) -> alibi_core::field_policy::FieldConfigs {
319 let mut fields = alibi_core::field_policy::FieldConfigs::new();
320 _ = fields.insert(
321 "activeOrganizationId".into(),
322 alibi_core::field_policy::FieldConfig::new(serde_json::json!({"type":"string"}))
323 .read_only(),
324 );
325 if self.config.teams.enabled {
326 _ = fields.insert(
327 "activeTeamId".into(),
328 alibi_core::field_policy::FieldConfig::new(serde_json::json!({"type":"string"}))
329 .read_only(),
330 );
331 }
332 fields
333 }
334
335 async fn on_init(&self, ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
336 ctx.set_metadata(METADATA_ENABLED, serde_json::Value::Bool(true));
337 ctx.set_metadata(
338 "organization.teams.enabled",
339 serde_json::Value::Bool(self.config.teams.enabled),
340 );
341 ctx.set_metadata(
342 "organization.dynamic_roles.enabled",
343 serde_json::Value::Bool(self.config.dynamic_access_control.enabled),
344 );
345 ctx.set_metadata(
346 "organization.access_control",
347 serde_json::to_value(&self.config.access_control)?,
348 );
349 ctx.set_metadata(
350 METADATA_ROLES,
351 serde_json::to_value(&self.config.roles).unwrap_or_default(),
352 );
353 ctx.set_metadata(
354 METADATA_CREATOR_ROLE,
355 serde_json::Value::String(self.config.effective_creator_role().to_owned()),
356 );
357 Ok(())
358 }
359
360 fn routes(&self) -> Vec<AuthRoute> {
361 let mut routes = vec![
362 AuthRoute::post("/organization/create", "create_organization"),
363 AuthRoute::post("/organization/update", "update_organization"),
364 AuthRoute::post("/organization/delete", "delete_organization"),
365 AuthRoute::get("/organization/list", "list_organizations"),
366 AuthRoute::get("/organization/get-organization", "get_organization"),
367 AuthRoute::get(
368 "/organization/get-full-organization",
369 "get_full_organization",
370 ),
371 AuthRoute::post("/organization/check-slug", "check_slug"),
372 AuthRoute::post("/organization/set-active", "set_active_organization"),
373 AuthRoute::post("/organization/leave", "leave_organization"),
374 AuthRoute::get("/organization/get-active-member", "get_active_member"),
375 AuthRoute::get(
376 "/organization/get-active-member-role",
377 "get_active_member_role",
378 ),
379 AuthRoute::get("/organization/list-members", "list_members"),
380 AuthRoute::post("/organization/remove-member", "remove_member"),
381 AuthRoute::post("/organization/update-member-role", "update_member_role"),
382 AuthRoute::post("/organization/invite-member", "invite_member"),
383 AuthRoute::get("/organization/get-invitation", "get_invitation"),
384 AuthRoute::get("/organization/list-invitations", "list_invitations"),
385 AuthRoute::get(
386 "/organization/list-user-invitations",
387 "list_user_invitations",
388 ),
389 AuthRoute::post("/organization/accept-invitation", "accept_invitation"),
390 AuthRoute::post("/organization/reject-invitation", "reject_invitation"),
391 AuthRoute::post("/organization/cancel-invitation", "cancel_invitation"),
392 AuthRoute::post("/organization/has-permission", "has_permission"),
393 ];
394 if self.config.teams.enabled {
395 routes.extend([
396 AuthRoute::post("/organization/create-team", "create_team"),
397 AuthRoute::post("/organization/update-team", "update_team"),
398 AuthRoute::post("/organization/remove-team", "remove_team"),
399 AuthRoute::get("/organization/list-teams", "list_teams"),
400 AuthRoute::get("/organization/list-user-teams", "list_user_teams"),
401 AuthRoute::get("/organization/list-team-members", "list_team_members"),
402 AuthRoute::post("/organization/set-active-team", "set_active_team"),
403 AuthRoute::post("/organization/add-team-member", "add_team_member"),
404 AuthRoute::post("/organization/remove-team-member", "remove_team_member"),
405 ]);
406 }
407 if self.config.dynamic_access_control.enabled {
408 routes.extend([
409 AuthRoute::post("/organization/create-role", "create_role"),
410 AuthRoute::post("/organization/update-role", "update_role"),
411 AuthRoute::post("/organization/delete-role", "delete_role"),
412 AuthRoute::get("/organization/get-role", "get_role"),
413 AuthRoute::get("/organization/list-roles", "list_roles"),
414 ]);
415 }
416 routes
417 }
418
419 async fn on_request(
420 &self,
421 req: &AuthRequest,
422 ctx: &AuthContext<S>,
423 ) -> AuthResult<Option<AuthResponse>> {
424 match (req.method(), req.path()) {
425 (HttpMethod::Post, "/organization/create") => Ok(Some(
426 Box::pin(handlers::org::handle_create_organization(
427 req,
428 ctx,
429 &self.config,
430 ))
431 .await?,
432 )),
433 (HttpMethod::Post, "/organization/update") => Ok(Some(
434 Box::pin(handlers::org::handle_update_organization(
435 req,
436 ctx,
437 &self.config,
438 ))
439 .await?,
440 )),
441 (HttpMethod::Post, "/organization/delete") => Ok(Some(
442 Box::pin(handlers::org::handle_delete_organization(
443 req,
444 ctx,
445 &self.config,
446 ))
447 .await?,
448 )),
449 (HttpMethod::Get, "/organization/list") => Ok(Some(
450 Box::pin(handlers::org::handle_list_organizations(req, ctx)).await?,
451 )),
452 (HttpMethod::Get, "/organization/get-organization") => Ok(Some(
453 Box::pin(handlers::org::handle_get_organization(req, ctx)).await?,
454 )),
455 (HttpMethod::Get, "/organization/get-full-organization") => Ok(Some(
456 Box::pin(handlers::org::handle_get_full_organization(
457 req,
458 ctx,
459 &self.config,
460 ))
461 .await?,
462 )),
463 (HttpMethod::Post, "/organization/check-slug") => Ok(Some(
464 Box::pin(handlers::org::handle_check_slug(req, ctx)).await?,
465 )),
466 (HttpMethod::Post, "/organization/set-active") => Ok(Some(
467 Box::pin(handlers::org::handle_set_active_organization(req, ctx)).await?,
468 )),
469 (HttpMethod::Post, "/organization/leave") => Ok(Some(
470 Box::pin(handlers::org::handle_leave_organization(
471 req,
472 ctx,
473 &self.config,
474 ))
475 .await?,
476 )),
477 (HttpMethod::Get, "/organization/get-active-member") => Ok(Some(
478 Box::pin(handlers::member::handle_get_active_member(req, ctx)).await?,
479 )),
480 (HttpMethod::Get, "/organization/get-active-member-role") => Ok(Some(
481 Box::pin(handlers::member::handle_get_active_member_role(req, ctx)).await?,
482 )),
483 (HttpMethod::Get, "/organization/list-members") => Ok(Some(
484 Box::pin(handlers::member::handle_list_members(
485 req,
486 ctx,
487 &self.config,
488 ))
489 .await?,
490 )),
491 (HttpMethod::Post, "/organization/remove-member") => Ok(Some(
492 Box::pin(handlers::member::handle_remove_member(
493 req,
494 ctx,
495 &self.config,
496 ))
497 .await?,
498 )),
499 (HttpMethod::Post, "/organization/update-member-role") => Ok(Some(
500 Box::pin(handlers::member::handle_update_member_role(
501 req,
502 ctx,
503 &self.config,
504 ))
505 .await?,
506 )),
507 (HttpMethod::Post, "/organization/invite-member") => Ok(Some(
508 Box::pin(handlers::invitation::handle_invite_member(
509 req,
510 ctx,
511 &self.config,
512 ))
513 .await?,
514 )),
515 (HttpMethod::Get, "/organization/get-invitation") => Ok(Some(
516 Box::pin(handlers::invitation::handle_get_invitation(
517 req,
518 ctx,
519 &self.config,
520 ))
521 .await?,
522 )),
523 (HttpMethod::Get, "/organization/list-invitations") => Ok(Some(
524 Box::pin(handlers::invitation::handle_list_invitations(req, ctx)).await?,
525 )),
526 (HttpMethod::Get, "/organization/list-user-invitations") => Ok(Some(
527 Box::pin(handlers::invitation::handle_list_user_invitations(req, ctx)).await?,
528 )),
529 (HttpMethod::Post, "/organization/accept-invitation") => Ok(Some(
530 Box::pin(handlers::invitation::handle_accept_invitation(
531 req,
532 ctx,
533 &self.config,
534 ))
535 .await?,
536 )),
537 (HttpMethod::Post, "/organization/reject-invitation") => Ok(Some(
538 Box::pin(handlers::invitation::handle_reject_invitation(
539 req,
540 ctx,
541 &self.config,
542 ))
543 .await?,
544 )),
545 (HttpMethod::Post, "/organization/cancel-invitation") => Ok(Some(
546 Box::pin(handlers::invitation::handle_cancel_invitation(
547 req,
548 ctx,
549 &self.config,
550 ))
551 .await?,
552 )),
553 (HttpMethod::Post, "/organization/has-permission") => Ok(Some(
554 Box::pin(handlers::handle_has_permission(req, ctx, &self.config)).await?,
555 )),
556 _ => {
557 if let Some(response) =
558 Box::pin(handlers::team::handle_team_request(req, ctx, &self.config)).await?
559 {
560 return Ok(Some(response));
561 }
562 Box::pin(handlers::role::handle_role_request(req, ctx, &self.config)).await
563 }
564 }
565 }
566}
567
568impl std::fmt::Debug for OrganizationPlugin {
569 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
570 f.debug_struct("OrganizationPlugin").finish_non_exhaustive()
571 }
572}
573
574#[cfg(test)]
576mod extension_tests {
577 use super::*;
578 use crate::test_helpers::{create_test_config, create_user_and_session};
579 use alibi_core::types::{CreateMember, CreateTeam, Team, TeamMember};
580 use alibi_core::wire::{SessionView, UserView};
581 use alibi_core::{AuthError, AuthInitContext, AuthSession, CreateUser};
582 use alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
583 use chrono::Duration;
584 use serde::de::DeserializeOwned;
585 use serde_json::{Value, json};
586
587 type TestResult = Result<(), Box<dyn std::error::Error>>;
588
589 #[derive(Debug)]
590 struct RequestTeamLimits;
591
592 #[async_trait]
593 impl OrganizationLimitResolver for RequestTeamLimits {
594 async fn maximum_teams(
595 &self,
596 context: &extensions::TeamLimitContext,
597 ) -> AuthResult<Option<f64>> {
598 let authenticated = context
599 .user
600 .as_ref()
601 .zip(context.session.as_ref())
602 .is_some_and(|(user, session)| {
603 user.id == session.user_id && user.name.as_deref() == Some("limit-owner")
604 });
605 let expanded = context
606 .request
607 .as_ref()
608 .and_then(|request| request.header("x-team-policy").map(String::as_str))
609 == Some("expanded");
610 Ok(Some(if authenticated && expanded { 3.0 } else { 1.0 }))
611 }
612
613 async fn maximum_team_members(
614 &self,
615 context: &extensions::TeamLimitContext,
616 ) -> AuthResult<Option<f64>> {
617 let authenticated = context
618 .user
619 .as_ref()
620 .zip(context.session.as_ref())
621 .is_some_and(|(user, session)| {
622 user.id == session.user_id && user.name.as_deref() == Some("limit-owner")
623 });
624 Ok(Some(f64::from(authenticated && context.team_id.is_some())))
625 }
626 }
627
628 #[derive(Debug)]
629 struct LifecycleHooks {
630 events: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
631 }
632
633 impl LifecycleHooks {
634 fn record(&self, name: &str) -> AuthResult<()> {
635 self.events
636 .lock()
637 .map_err(|_error| AuthError::internal("Hook event lock poisoned"))?
638 .push(name.to_owned());
639 Ok(())
640 }
641 }
642
643 #[async_trait]
644 impl OrganizationTeamHooks for LifecycleHooks {
645 async fn before_create(
646 &self,
647 data: &mut CreateTeam,
648 _context: &extensions::TeamHookContext,
649 ) -> AuthResult<()> {
650 self.record("before-create")?;
651 data.name = format!("hook:{}", data.name);
652 Ok(())
653 }
654 async fn after_create(
655 &self,
656 _team: &Team,
657 _context: &extensions::TeamHookContext,
658 ) -> AuthResult<()> {
659 self.record("after-create")
660 }
661 async fn before_update(
662 &self,
663 _team: &Team,
664 update: &mut alibi_core::types::UpdateTeam,
665 _context: &extensions::TeamHookContext,
666 ) -> AuthResult<()> {
667 self.record("before-update")?;
668 update.name = update.name.take().map(|name| format!("updated:{name}"));
669 Ok(())
670 }
671 async fn after_update(
672 &self,
673 _team: &Team,
674 _context: &extensions::TeamHookContext,
675 ) -> AuthResult<()> {
676 self.record("after-update")
677 }
678 async fn before_add_member(
679 &self,
680 _team: &Team,
681 user: &UserView,
682 _context: &extensions::TeamHookContext,
683 ) -> AuthResult<()> {
684 self.record("before-add")?;
685 if user.email.as_deref() == Some("hook-denied@example.com") {
686 return Err(AuthError::forbidden("Callback refused team membership"));
687 }
688 Ok(())
689 }
690 async fn after_add_member(
691 &self,
692 _member: &TeamMember,
693 _team: &Team,
694 _user: &UserView,
695 _context: &extensions::TeamHookContext,
696 ) -> AuthResult<()> {
697 self.record("after-add")
698 }
699 async fn before_remove_member(
700 &self,
701 _member: &TeamMember,
702 _team: &Team,
703 _user: &UserView,
704 _context: &extensions::TeamHookContext,
705 ) -> AuthResult<()> {
706 self.record("before-remove")
707 }
708 async fn after_remove_member(
709 &self,
710 _member: &TeamMember,
711 _team: &Team,
712 _user: &UserView,
713 _context: &extensions::TeamHookContext,
714 ) -> AuthResult<()> {
715 self.record("after-remove")
716 }
717 async fn before_delete(
718 &self,
719 _team: &Team,
720 _context: &extensions::TeamHookContext,
721 ) -> AuthResult<()> {
722 self.record("before-delete")
723 }
724 async fn after_delete(
725 &self,
726 _team: &Team,
727 _context: &extensions::TeamHookContext,
728 ) -> AuthResult<()> {
729 self.record("after-delete")
730 }
731 }
732
733 #[derive(Debug)]
734 struct CustomDefaultTeam;
735
736 #[async_trait]
737 impl DefaultTeamFactory for CustomDefaultTeam {
738 async fn create(
739 &self,
740 organization: &alibi_core::types::Organization,
741 context: &DefaultTeamContext,
742 store: &dyn alibi_core::store::TeamStore,
743 ) -> AuthResult<Option<Team>> {
744 let request = context.request.as_ref().ok_or_else(|| {
745 AuthError::bad_request(
746 "Default team callback did not receive the organization request",
747 )
748 })?;
749 if context
750 .session
751 .as_ref()
752 .map(|session| session.user_id.as_str())
753 != Some(context.user.id.as_str())
754 {
755 return Err(AuthError::bad_request(
756 "Factory did not receive the authenticated principal",
757 ));
758 }
759 if context.config.base_path != "/api/auth" {
760 return Err(AuthError::bad_request(
761 "Factory did not receive the configured base path",
762 ));
763 }
764 if request.path() != "/organization/create" {
765 return Err(AuthError::bad_request("Unexpected default team request"));
766 }
767 store
768 .create_team(CreateTeam {
769 name: format!("Factory:{}", organization.name),
770 organization_id: organization.id.clone(),
771 updated_at: None,
772 })
773 .await
774 .map(Some)
775 }
776 }
777
778 #[tokio::test]
779 #[expect(
780 clippy::panic_in_result_fn,
781 reason = "Assertions report test failures; Result propagates setup and fixture errors"
782 )]
783 #[expect(
784 clippy::too_many_lines,
785 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
786 )]
787 async fn asynchronous_team_limits_use_the_actual_request_and_session_principal() -> TestResult {
788 let plugin = OrganizationPlugin::with_config(OrganizationConfig {
789 teams: TeamsConfig {
790 enabled: true,
791 maximum_teams: Some(99.0),
792 maximum_members_per_team: Some(99.0),
793 limit_resolver: Some(std::sync::Arc::new(RequestTeamLimits)),
794 ..Default::default()
795 },
796 ..Default::default()
797 });
798 let ctx = context(&plugin).await?;
799 let (_, owner_session) = actor(&ctx, "limit-owner").await;
800 let created = call(
801 &plugin,
802 &ctx,
803 Some(&owner_session.token),
804 HttpMethod::Post,
805 "/organization/create",
806 Some(json!({"name":"Limited","slug":"native-async-limits"})),
807 &[],
808 )
809 .await?;
810 let organization: Value = body(&created)?;
811 let organization_id = id(&organization)?;
812 assert_eq!(ctx.database.list_teams(organization_id).await?.len(), 1);
813 for (name, expanded, expected) in [
814 ("Default denied", false, 400),
815 ("Expanded first", true, 200),
816 ("Expanded second", true, 200),
817 ("Expanded full", true, 400),
818 ] {
819 let mut request = AuthRequest::new(HttpMethod::Post, "/organization/create-team");
820 let cookie = alibi_core::utils::cookie_utils::create_session_cookie(
821 &owner_session.token,
822 &ctx.config,
823 )
824 .unwrap();
825 request.headers.insert(
826 "cookie".to_owned(),
827 cookie
828 .split(';')
829 .next()
830 .ok_or("Session cookie pair missing")?
831 .to_owned(),
832 );
833 if expanded {
834 request
835 .headers
836 .insert("x-team-policy".to_owned(), "expanded".to_owned());
837 }
838 request.body = Some(serde_json::to_vec(
839 &json!({"organizationId":organization_id,"name":name}),
840 )?);
841 let response = match plugin.on_request(&request, &ctx).await {
842 Ok(Some(response)) => response,
843 Ok(None) => return Err("Team route was not handled".into()),
844 Err(error) => error.to_auth_response(),
845 };
846 assert_eq!(response.status, expected);
847 if expected == 400 {
848 assert_error(
849 &response,
850 400,
851 "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS",
852 )?;
853 }
854 }
855 let teams = ctx.database.list_teams(organization_id).await?;
856 assert_eq!(teams.len(), 3);
857 let team = teams
858 .iter()
859 .find(|team| team.name == "Expanded first")
860 .ok_or("Allowed team was not persisted")?;
861 let (first, _) = actor(&ctx, "limit-first").await;
862 let (second, _) = actor(&ctx, "limit-second").await;
863 for user in [&first, &second] {
864 ctx.database
865 .create_member(CreateMember {
866 organization_id: organization_id.to_owned(),
867 user_id: user.id.clone(),
868 role: "member".to_owned(),
869 })
870 .await?;
871 }
872 let added = call(
873 &plugin,
874 &ctx,
875 Some(&owner_session.token),
876 HttpMethod::Post,
877 "/organization/add-team-member",
878 Some(json!({"organizationId":organization_id,"teamId":team.id,"userId":first.id})),
879 &[],
880 )
881 .await?;
882 assert_eq!(added.status, 200);
883 assert_error(
884 &call(
885 &plugin,
886 &ctx,
887 Some(&owner_session.token),
888 HttpMethod::Post,
889 "/organization/add-team-member",
890 Some(json!({"organizationId":organization_id,"teamId":team.id,"userId":second.id})),
891 &[],
892 )
893 .await?,
894 403,
895 "TEAM_MEMBER_LIMIT_REACHED",
896 )?;
897 let members = ctx.database.list_team_members(&team.id).await?;
898 assert_eq!(members.len(), 1);
899 assert_eq!(
900 (members)
901 .first()
902 .expect("fixture contains the requested index")
903 .user_id,
904 first.id
905 );
906 Ok(())
907 }
908
909 #[tokio::test]
910 #[expect(
911 clippy::panic_in_result_fn,
912 reason = "Assertions report test failures; Result propagates setup and fixture errors"
913 )]
914 #[expect(
915 clippy::too_many_lines,
916 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
917 )]
918 async fn team_member_requests_coerce_custom_user_ids_and_keep_permission_and_tenant_guards()
919 -> TestResult {
920 let plugin = OrganizationPlugin::with_config(configuration());
921 let ctx = context(&plugin).await?;
922 let (owner, owner_session) = actor(&ctx, "coercion-owner").await;
923 let organization = ctx
924 .database
925 .create_organization(alibi_core::types::CreateOrganization::new(
926 "Custom IDs",
927 "native-coercion",
928 ))
929 .await?;
930 ctx.database
931 .create_member(CreateMember {
932 organization_id: organization.id.clone(),
933 user_id: owner.id.clone(),
934 role: "owner".to_owned(),
935 })
936 .await?;
937 let team = plugin
938 .create_team(
939 &ctx,
940 CreateTeam {
941 organization_id: organization.id.clone(),
942 name: "Custom ID team".to_owned(),
943 updated_at: None,
944 },
945 )
946 .await?;
947 let other_org = ctx
948 .database
949 .create_organization(alibi_core::types::CreateOrganization::new(
950 "Other tenant",
951 "native-coercion-other",
952 ))
953 .await?;
954 let other_team = plugin
955 .create_team(
956 &ctx,
957 CreateTeam {
958 organization_id: other_org.id,
959 name: "Other team".to_owned(),
960 updated_at: None,
961 },
962 )
963 .await?;
964 for (index, (input, expected_id)) in [
965 (Some(json!(42)), "42"),
966 (Some(json!(1.0)), "1"),
967 (Some(json!(1e21)), "1e+21"),
968 (Some(json!(9_007_199_254_740_993_u64)), "9007199254740992"),
969 (Some(json!(true)), "true"),
970 (Some(Value::Null), "null"),
971 (
972 Some(json!(["42", null, {"key":"value"}])),
973 "42,,[object Object]",
974 ),
975 (Some(json!({"key":"value"})), "[object Object]"),
976 (None, "undefined"),
977 ]
978 .into_iter()
979 .enumerate()
980 {
981 let (target, target_session) = create_user_and_session(
982 &ctx,
983 CreateUser {
984 id: Some(expected_id.to_owned()),
985 email: Some(format!("coercion-{index}@example.com")),
986 ..Default::default()
987 },
988 Duration::hours(1),
989 )
990 .await;
991 ctx.database
992 .create_member(CreateMember {
993 organization_id: organization.id.clone(),
994 user_id: target.id.clone(),
995 role: "member".to_owned(),
996 })
997 .await?;
998 let mut request = json!({"organizationId":organization.id,"teamId":team.id});
999 if let Some(input) = input {
1000 _ = request
1001 .as_object_mut()
1002 .expect("request is an object")
1003 .insert("userId".to_owned(), input);
1004 }
1005 let added = call(
1006 &plugin,
1007 &ctx,
1008 Some(&owner_session.token),
1009 HttpMethod::Post,
1010 "/organization/add-team-member",
1011 Some(request.clone()),
1012 &[],
1013 )
1014 .await?;
1015 assert_eq!(added.status, 200, "add-member must coerce {expected_id}");
1016 let added: TeamMember = body(&added)?;
1017 assert_eq!(added.user_id, expected_id);
1018 let persisted = ctx.database.list_team_members(&team.id).await?;
1019 assert_eq!(persisted.len(), 1);
1020 assert_eq!(
1021 (persisted)
1022 .first()
1023 .expect("fixture contains the requested index")
1024 .id,
1025 added.id
1026 );
1027 assert_eq!(
1028 (persisted)
1029 .first()
1030 .expect("fixture contains the requested index")
1031 .user_id,
1032 expected_id
1033 );
1034 if expected_id == "42" {
1035 assert_error(
1036 &call(
1037 &plugin,
1038 &ctx,
1039 Some(&target_session.token),
1040 HttpMethod::Post,
1041 "/organization/add-team-member",
1042 Some(request.clone()),
1043 &[],
1044 )
1045 .await?,
1046 403,
1047 "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER",
1048 )?;
1049 assert_error(
1050 &call(
1051 &plugin,
1052 &ctx,
1053 Some(&target_session.token),
1054 HttpMethod::Post,
1055 "/organization/remove-team-member",
1056 Some(request.clone()),
1057 &[],
1058 )
1059 .await?,
1060 403,
1061 "YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER",
1062 )?;
1063 let mut wrong_tenant = request.clone();
1064 _ = wrong_tenant
1065 .as_object_mut()
1066 .expect("request is an object")
1067 .insert("teamId".to_owned(), json!(other_team.id));
1068 assert_error(
1069 &call(
1070 &plugin,
1071 &ctx,
1072 Some(&owner_session.token),
1073 HttpMethod::Post,
1074 "/organization/add-team-member",
1075 Some(wrong_tenant),
1076 &[],
1077 )
1078 .await?,
1079 400,
1080 "TEAM_NOT_FOUND",
1081 )?;
1082 assert_eq!(ctx.database.list_team_members(&team.id).await?.len(), 1);
1083 assert_eq!(
1084 ctx.database.list_team_members(&other_team.id).await?,
1085 vec![]
1086 );
1087 }
1088 let removed = call(
1089 &plugin,
1090 &ctx,
1091 Some(&owner_session.token),
1092 HttpMethod::Post,
1093 "/organization/remove-team-member",
1094 Some(request),
1095 &[],
1096 )
1097 .await?;
1098 assert_eq!(removed.status, 200);
1099 assert_eq!(ctx.database.list_team_members(&team.id).await?, vec![]);
1100 }
1101 Ok(())
1102 }
1103
1104 fn configuration() -> OrganizationConfig {
1105 OrganizationConfig {
1106 teams: TeamsConfig {
1107 enabled: true,
1108 ..Default::default()
1109 },
1110 ..Default::default()
1111 }
1112 }
1113
1114 async fn context(plugin: &OrganizationPlugin) -> AuthResult<AuthContext<BundledSchema>> {
1115 configured_context(plugin, create_test_config()).await
1116 }
1117
1118 pub(super) async fn configured_context(
1119 plugin: &OrganizationPlugin,
1120 config: alibi_core::AuthConfig,
1121 ) -> AuthResult<AuthContext<BundledSchema>> {
1122 configured_context_with_connection(plugin, config)
1123 .await
1124 .map(|(context, _)| context)
1125 }
1126
1127 async fn configured_context_with_connection(
1128 plugin: &OrganizationPlugin,
1129 config: alibi_core::AuthConfig,
1130 ) -> AuthResult<(AuthContext<BundledSchema>, alibi_seaorm::DatabaseConnection)> {
1131 let database = alibi_seaorm::Database::connect("sqlite::memory:")
1132 .await
1133 .map_err(|error| AuthError::internal(error.to_string()))?;
1134 alibi_seaorm::store::__private_test_support::migrator::run_migrations(&database)
1135 .await
1136 .map_err(|error| AuthError::internal(error.to_string()))?;
1137 let config = std::sync::Arc::new(config);
1138 let store = std::sync::Arc::new(alibi_seaorm::SeaOrmStore::<BundledSchema>::new(
1139 std::sync::Arc::clone(&config),
1140 database.clone(),
1141 ));
1142 let mut ctx = AuthContext::new(config, store);
1143 let mut init = AuthInitContext::new(
1144 std::sync::Arc::clone(&ctx.config),
1145 std::sync::Arc::clone(&ctx.database),
1146 );
1147 plugin.on_init(&mut init).await?;
1148 let parts = init.into_parts();
1149 ctx.metadata = parts.metadata;
1150 Ok((ctx, database))
1151 }
1152
1153 pub(super) async fn actor(
1154 ctx: &AuthContext<BundledSchema>,
1155 name: &str,
1156 ) -> (UserView, SessionView) {
1157 create_user_and_session(
1158 ctx,
1159 CreateUser {
1160 email: Some(format!("{name}@example.com")),
1161 name: Some(name.to_owned()),
1162 email_verified: Some(true),
1163 ..Default::default()
1164 },
1165 Duration::hours(1),
1166 )
1167 .await
1168 }
1169
1170 pub(super) async fn call(
1171 plugin: &OrganizationPlugin,
1172 ctx: &AuthContext<BundledSchema>,
1173 token: Option<&str>,
1174 method: HttpMethod,
1175 path: &str,
1176 body: Option<Value>,
1177 query: &[(&str, &str)],
1178 ) -> AuthResult<AuthResponse> {
1179 let mut req = AuthRequest::new(method, path);
1180 if let Some(token) = token {
1181 let cookie =
1182 alibi_core::utils::cookie_utils::create_session_cookie(token, &ctx.config).unwrap();
1183 let pair = cookie
1184 .split(';')
1185 .next()
1186 .ok_or_else(|| AuthError::internal("Session cookie missing pair"))?;
1187 _ = req.headers.insert("cookie".to_owned(), pair.to_owned());
1188 }
1189 for (key, value) in query {
1190 _ = req.query.insert((*key).to_owned(), (*value).to_owned());
1191 }
1192 if let Some(body) = body {
1193 req.body = Some(serde_json::to_vec(&body)?);
1194 _ = req
1195 .headers
1196 .insert("content-type".to_owned(), "application/json".to_owned());
1197 }
1198 match plugin.on_request(&req, ctx).await {
1199 Ok(Some(response)) => Ok(response),
1200 Ok(None) => Err(AuthError::internal("Organization route was not handled")),
1201 Err(error) => Ok(error.to_auth_response()),
1202 }
1203 }
1204
1205 pub(super) fn body<T: DeserializeOwned>(
1206 response: &AuthResponse,
1207 ) -> Result<T, serde_json::Error> {
1208 serde_json::from_slice(&response.body)
1209 }
1210
1211 pub(super) fn id(value: &Value) -> Result<&str, std::io::Error> {
1212 value
1213 .get("id")
1214 .and_then(Value::as_str)
1215 .ok_or_else(|| std::io::Error::other("Response is missing ID"))
1216 }
1217
1218 #[expect(
1219 clippy::panic_in_result_fn,
1220 reason = "Assertions report test failures; Result propagates setup and fixture errors"
1221 )]
1222 pub(super) fn assert_error(response: &AuthResponse, status: u16, code: &str) -> TestResult {
1223 assert_eq!(response.status, status);
1224 assert_eq!(
1225 body::<Value>(response)?.get("code").and_then(Value::as_str),
1226 Some(code)
1227 );
1228 Ok(())
1229 }
1230
1231 #[tokio::test]
1232 #[expect(
1233 clippy::panic_in_result_fn,
1234 reason = "Assertions report test failures; Result propagates setup and fixture errors"
1235 )]
1236 #[expect(
1237 clippy::too_many_lines,
1238 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1239 )]
1240 async fn team_routes_enforce_principal_membership_scope_and_persist_session_changes()
1241 -> TestResult {
1242 let plugin = OrganizationPlugin::with_config(configuration());
1243 let ctx = context(&plugin).await?;
1244 let (owner, owner_session) = actor(&ctx, "team-owner").await;
1245 let (member, member_session) = actor(&ctx, "team-member").await;
1246 let (stranger, stranger_session) = actor(&ctx, "team-stranger").await;
1247 let created = call(
1248 &plugin,
1249 &ctx,
1250 Some(&owner_session.token),
1251 HttpMethod::Post,
1252 "/organization/create",
1253 Some(json!({"name":"Teams", "slug":"native-teams"})),
1254 &[],
1255 )
1256 .await?;
1257 assert_eq!(created.status, 200);
1258 let organization: Value = body(&created)?;
1259 let org_id = id(&organization)?;
1260 assert!(organization.get("teams").is_none());
1261 let defaults = ctx.database.list_teams(org_id).await?;
1262 let default_team = defaults
1263 .first()
1264 .ok_or_else(|| std::io::Error::other("Default team not created"))?;
1265 assert_eq!(defaults.len(), 1);
1266 assert_eq!(default_team.name, "Teams");
1267 assert!(default_team.updated_at.is_none());
1268 assert!(
1269 ctx.database
1270 .get_team_member(&default_team.id, &owner.id)
1271 .await?
1272 .is_some()
1273 );
1274 let persisted = ctx
1275 .database
1276 .get_session(&owner_session.token)
1277 .await?
1278 .ok_or_else(|| std::io::Error::other("Owner session missing"))?;
1279 assert_eq!(persisted.active_team_id(), Some(default_team.id.as_str()));
1280 _ = ctx
1281 .database
1282 .create_member(CreateMember::new(org_id, &member.id, "member"))
1283 .await?;
1284 _ = ctx
1285 .database
1286 .update_session_active_organization(&member_session.token, Some(org_id))
1287 .await?;
1288
1289 assert_error(
1290 &call(
1291 &plugin,
1292 &ctx,
1293 None,
1294 HttpMethod::Post,
1295 "/organization/create-team",
1296 Some(json!({"name":"Unauthenticated", "organizationId":org_id})),
1297 &[],
1298 )
1299 .await?,
1300 401,
1301 "UNAUTHORIZED",
1302 )?;
1303 assert_error(
1304 &call(
1305 &plugin,
1306 &ctx,
1307 Some(&member_session.token),
1308 HttpMethod::Post,
1309 "/organization/create-team",
1310 Some(json!({"name":"Forbidden"})),
1311 &[],
1312 )
1313 .await?,
1314 403,
1315 "YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION",
1316 )?;
1317 assert_error(
1318 &call(
1319 &plugin,
1320 &ctx,
1321 Some(&stranger_session.token),
1322 HttpMethod::Get,
1323 "/organization/list-teams",
1324 None,
1325 &[("organizationId", org_id)],
1326 )
1327 .await?,
1328 403,
1329 "YOU_ARE_NOT_ALLOWED_TO_ACCESS_THIS_ORGANIZATION",
1330 )?;
1331
1332 let created_team = call(
1333 &plugin,
1334 &ctx,
1335 Some(&owner_session.token),
1336 HttpMethod::Post,
1337 "/organization/create-team",
1338 Some(json!({"name":"Engineering"})),
1339 &[],
1340 )
1341 .await?;
1342 assert_eq!(created_team.status, 200);
1343 let team: Team = body(&created_team)?;
1344 assert_eq!(team.organization_id, org_id);
1345 assert_eq!(team.updated_at, Some(team.created_at));
1346 let updated = call(
1347 &plugin,
1348 &ctx,
1349 Some(&owner_session.token),
1350 HttpMethod::Post,
1351 "/organization/update-team",
1352 Some(json!({"teamId":team.id,"data":{"name":"Platform"}})),
1353 &[],
1354 )
1355 .await?;
1356 assert_eq!(updated.status, 200);
1357 assert_eq!(body::<Team>(&updated)?.name, "Platform");
1358 assert_eq!(
1359 ctx.database
1360 .get_team(Some(org_id), &team.id)
1361 .await?
1362 .map(|row| row.name),
1363 Some("Platform".to_owned())
1364 );
1365 assert_error(
1366 &call(
1367 &plugin,
1368 &ctx,
1369 Some(&owner_session.token),
1370 HttpMethod::Post,
1371 "/organization/add-team-member",
1372 Some(json!({"teamId":team.id,"userId":stranger.id})),
1373 &[],
1374 )
1375 .await?,
1376 400,
1377 "USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION",
1378 )?;
1379 let added = call(
1380 &plugin,
1381 &ctx,
1382 Some(&owner_session.token),
1383 HttpMethod::Post,
1384 "/organization/add-team-member",
1385 Some(json!({"teamId":team.id,"userId":member.id})),
1386 &[],
1387 )
1388 .await?;
1389 assert_eq!(added.status, 200);
1390 let membership: TeamMember = body(&added)?;
1391 assert_eq!(membership.user_id, member.id);
1392 assert_eq!(membership.team_id, team.id);
1393 let active = call(
1394 &plugin,
1395 &ctx,
1396 Some(&member_session.token),
1397 HttpMethod::Post,
1398 "/organization/set-active-team",
1399 Some(json!({"teamId":team.id})),
1400 &[],
1401 )
1402 .await?;
1403 assert_eq!(active.status, 200);
1404 assert!(active.headers.get("set-cookie").is_some());
1405 let persisted_2 = ctx
1406 .database
1407 .get_session(&member_session.token)
1408 .await?
1409 .ok_or_else(|| std::io::Error::other("Member session missing"))?;
1410 assert_eq!(persisted_2.active_team_id(), Some(team.id.as_str()));
1411 assert_eq!(persisted_2.token(), member_session.token);
1412 let listed = call(
1413 &plugin,
1414 &ctx,
1415 Some(&member_session.token),
1416 HttpMethod::Get,
1417 "/organization/list-team-members",
1418 None,
1419 &[],
1420 )
1421 .await?;
1422 assert_eq!(listed.status, 200);
1423 let rows: Vec<TeamMember> = body(&listed)?;
1424 assert_eq!(rows.len(), 1);
1425 assert_eq!(
1426 rows.first().map(|row| row.id.as_str()),
1427 Some(membership.id.as_str())
1428 );
1429 let own_teams = call(
1430 &plugin,
1431 &ctx,
1432 Some(&member_session.token),
1433 HttpMethod::Get,
1434 "/organization/list-user-teams",
1435 None,
1436 &[],
1437 )
1438 .await?;
1439 assert_eq!(body::<Vec<Team>>(&own_teams)?.len(), 1);
1440 let other_teams = call(
1441 &plugin,
1442 &ctx,
1443 Some(&owner_session.token),
1444 HttpMethod::Get,
1445 "/organization/list-user-teams",
1446 None,
1447 &[("userId", &member.id), ("organizationId", org_id)],
1448 )
1449 .await?;
1450 assert_eq!(
1451 body::<Vec<Team>>(&other_teams)?
1452 .first()
1453 .map(|row| row.id.as_str()),
1454 Some(team.id.as_str())
1455 );
1456 assert_error(
1457 &call(
1458 &plugin,
1459 &ctx,
1460 Some(&member_session.token),
1461 HttpMethod::Get,
1462 "/organization/list-user-teams",
1463 None,
1464 &[("userId", &owner.id), ("organizationId", org_id)],
1465 )
1466 .await?,
1467 403,
1468 "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER",
1469 )?;
1470 let clear = call(
1471 &plugin,
1472 &ctx,
1473 Some(&member_session.token),
1474 HttpMethod::Post,
1475 "/organization/set-active-team",
1476 Some(json!({"teamId":null})),
1477 &[],
1478 )
1479 .await?;
1480 assert_eq!(body::<Value>(&clear)?, Value::Null);
1481 assert!(
1482 ctx.database
1483 .get_session(&member_session.token)
1484 .await?
1485 .is_some_and(|row| row.active_team_id().is_none())
1486 );
1487 let removed = call(
1488 &plugin,
1489 &ctx,
1490 Some(&owner_session.token),
1491 HttpMethod::Post,
1492 "/organization/remove-team-member",
1493 Some(json!({"teamId":team.id,"userId":member.id})),
1494 &[],
1495 )
1496 .await?;
1497 assert_eq!(removed.status, 200);
1498 assert!(
1499 ctx.database
1500 .get_team_member(&team.id, &member.id)
1501 .await?
1502 .is_none()
1503 );
1504 let deleted = call(
1505 &plugin,
1506 &ctx,
1507 Some(&owner_session.token),
1508 HttpMethod::Post,
1509 "/organization/remove-team",
1510 Some(json!({"teamId":team.id})),
1511 &[],
1512 )
1513 .await?;
1514 assert_eq!(deleted.status, 200);
1515 assert!(
1516 ctx.database
1517 .get_team(Some(org_id), &team.id)
1518 .await?
1519 .is_none()
1520 );
1521 assert_error(
1522 &call(
1523 &plugin,
1524 &ctx,
1525 Some(&owner_session.token),
1526 HttpMethod::Post,
1527 "/organization/remove-team",
1528 Some(json!({"teamId":default_team.id})),
1529 &[],
1530 )
1531 .await?,
1532 403,
1533 "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM",
1534 )?;
1535 _ = call(
1536 &plugin,
1537 &ctx,
1538 Some(&owner_session.token),
1539 HttpMethod::Post,
1540 "/organization/set-active-team",
1541 Some(json!({"teamId":null})),
1542 &[],
1543 )
1544 .await?;
1545 assert_error(
1546 &call(
1547 &plugin,
1548 &ctx,
1549 Some(&owner_session.token),
1550 HttpMethod::Post,
1551 "/organization/remove-team",
1552 Some(json!({"teamId":default_team.id})),
1553 &[],
1554 )
1555 .await?,
1556 400,
1557 "UNABLE_TO_REMOVE_LAST_TEAM",
1558 )?;
1559 Ok(())
1560 }
1561
1562 #[tokio::test]
1563 #[expect(
1564 clippy::panic_in_result_fn,
1565 reason = "Assertions report test failures; Result propagates setup and fixture errors"
1566 )]
1567 #[expect(
1568 clippy::too_many_lines,
1569 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1570 )]
1571 async fn lifecycle_hooks_change_persisted_team_data_and_veto_membership_before_writing()
1572 -> TestResult {
1573 let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1574 let plugin = OrganizationPlugin::with_config(OrganizationConfig {
1575 teams: TeamsConfig {
1576 enabled: true,
1577 hooks: Some(std::sync::Arc::new(LifecycleHooks {
1578 events: std::sync::Arc::clone(&events),
1579 })),
1580 ..Default::default()
1581 },
1582 ..Default::default()
1583 });
1584 let ctx = context(&plugin).await?;
1585 let (owner, session) = actor(&ctx, "hook-owner").await;
1586 let (denied, _) = actor(&ctx, "hook-denied").await;
1587 let created = call(
1588 &plugin,
1589 &ctx,
1590 Some(&session.token),
1591 HttpMethod::Post,
1592 "/organization/create",
1593 Some(json!({"name":"Callbacks","slug":"native-callbacks"})),
1594 &[],
1595 )
1596 .await?;
1597 let created: Value = body(&created)?;
1598 let org_id = id(&created)?;
1599 assert_eq!(
1600 ctx.database
1601 .list_teams(org_id)
1602 .await?
1603 .first()
1604 .map(|team| team.name.as_str()),
1605 Some("hook:Callbacks")
1606 );
1607 _ = ctx
1608 .database
1609 .create_member(CreateMember::new(org_id, &denied.id, "member"))
1610 .await?;
1611 let created_2 = call(
1612 &plugin,
1613 &ctx,
1614 Some(&session.token),
1615 HttpMethod::Post,
1616 "/organization/create-team",
1617 Some(json!({"name":"Custom"})),
1618 &[],
1619 )
1620 .await?;
1621 let team: Team = body(&created_2)?;
1622 assert_eq!(team.name, "hook:Custom");
1623 let updated = call(
1624 &plugin,
1625 &ctx,
1626 Some(&session.token),
1627 HttpMethod::Post,
1628 "/organization/update-team",
1629 Some(json!({"teamId":team.id,"data":{"name":"Changed"}})),
1630 &[],
1631 )
1632 .await?;
1633 assert_eq!(body::<Team>(&updated)?.name, "updated:Changed");
1634 assert_eq!(
1635 ctx.database
1636 .get_team(Some(org_id), &team.id)
1637 .await?
1638 .map(|team| team.name),
1639 Some("updated:Changed".to_owned())
1640 );
1641 let refused = call(
1642 &plugin,
1643 &ctx,
1644 Some(&session.token),
1645 HttpMethod::Post,
1646 "/organization/add-team-member",
1647 Some(json!({"teamId":team.id,"userId":denied.id})),
1648 &[],
1649 )
1650 .await?;
1651 assert_eq!(refused.status, 403);
1652 assert!(
1653 ctx.database
1654 .get_team_member(&team.id, &denied.id)
1655 .await?
1656 .is_none()
1657 );
1658 let admitted = call(
1659 &plugin,
1660 &ctx,
1661 Some(&session.token),
1662 HttpMethod::Post,
1663 "/organization/add-team-member",
1664 Some(json!({"teamId":team.id,"userId":owner.id})),
1665 &[],
1666 )
1667 .await?;
1668 assert_eq!(admitted.status, 200);
1669 let removed = call(
1670 &plugin,
1671 &ctx,
1672 Some(&session.token),
1673 HttpMethod::Post,
1674 "/organization/remove-team-member",
1675 Some(json!({"teamId":team.id,"userId":owner.id})),
1676 &[],
1677 )
1678 .await?;
1679 assert_eq!(removed.status, 200);
1680 let deleted = call(
1681 &plugin,
1682 &ctx,
1683 Some(&session.token),
1684 HttpMethod::Post,
1685 "/organization/remove-team",
1686 Some(json!({"teamId":team.id})),
1687 &[],
1688 )
1689 .await?;
1690 assert_eq!(deleted.status, 200);
1691 assert!(ctx.database.get_team(None, &team.id).await?.is_none());
1692 assert_eq!(
1693 *events
1694 .lock()
1695 .map_err(|_error| std::io::Error::other("Hook event lock poisoned"))?,
1696 vec![
1697 "before-create",
1698 "after-create",
1699 "before-create",
1700 "after-create",
1701 "before-update",
1702 "after-update",
1703 "before-add",
1704 "before-add",
1705 "after-add",
1706 "before-remove",
1707 "after-remove",
1708 "before-delete",
1709 "after-delete"
1710 ]
1711 );
1712 Ok(())
1713 }
1714
1715 #[tokio::test]
1716 #[expect(
1717 clippy::panic_in_result_fn,
1718 reason = "Assertions report test failures; Result propagates setup and fixture errors"
1719 )]
1720 #[expect(
1721 clippy::too_many_lines,
1722 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1723 )]
1724 async fn default_team_factory_receives_request_and_its_persisted_team_becomes_active()
1725 -> TestResult {
1726 use alibi_seaorm::sea_orm::{ConnectionTrait, DbBackend, Statement};
1727
1728 let plugin = OrganizationPlugin::with_config(OrganizationConfig {
1729 teams: TeamsConfig {
1730 enabled: true,
1731 default_team_factory: Some(std::sync::Arc::new(CustomDefaultTeam)),
1732 ..Default::default()
1733 },
1734 ..Default::default()
1735 });
1736 let mut config = create_test_config();
1737 config.session.update_age = None;
1738 let (ctx, database) = configured_context_with_connection(&plugin, config).await?;
1739 let (owner, session) = actor(&ctx, "factory-owner").await;
1740 for statement in [
1744 "CREATE TABLE session_refresh_audit (refreshes INTEGER NOT NULL)",
1745 "INSERT INTO session_refresh_audit (refreshes) VALUES (0)",
1746 "CREATE TRIGGER record_session_refresh AFTER UPDATE OF expires_at ON sessions BEGIN UPDATE session_refresh_audit SET refreshes = refreshes + 1; END",
1747 ] {
1748 database
1749 .execute_raw(Statement::from_string(DbBackend::Sqlite, statement))
1750 .await?;
1751 }
1752 let response = call(
1753 &plugin,
1754 &ctx,
1755 Some(&session.token),
1756 HttpMethod::Post,
1757 "/organization/create",
1758 Some(json!({"name":"FactoryOrg","slug":"native-factory"})),
1759 &[],
1760 )
1761 .await?;
1762 assert_eq!(response.status, 200);
1763 let refreshes: i64 = database
1764 .query_one_raw(Statement::from_string(
1765 DbBackend::Sqlite,
1766 "SELECT refreshes FROM session_refresh_audit",
1767 ))
1768 .await?
1769 .ok_or("Refresh audit row missing")?
1770 .try_get("", "refreshes")?;
1771 assert_eq!(refreshes, 1, "One request must refresh the session once");
1772 let organization: Value = body(&response)?;
1773 let teams = ctx.database.list_teams(id(&organization)?).await?;
1774 assert_eq!(teams.len(), 1);
1775 let team = teams
1776 .first()
1777 .ok_or_else(|| std::io::Error::other("Factory returned no team"))?;
1778 assert_eq!(team.name, "Factory:FactoryOrg");
1779 assert!(
1780 ctx.database
1781 .get_team_member(&team.id, &owner.id)
1782 .await?
1783 .is_some()
1784 );
1785 assert_eq!(
1786 ctx.database
1787 .get_session(&session.token)
1788 .await?
1789 .and_then(|session| session.active_team_id),
1790 Some(team.id.clone())
1791 );
1792 let kept = call(&plugin, &ctx, Some(&session.token), HttpMethod::Post, "/organization/create", Some(json!({"name":"KeptFactory","slug":"native-factory-kept","keepCurrentActiveOrganization":true})), &[]).await?;
1793 assert_eq!(kept.status, 200);
1794 let kept: Value = body(&kept)?;
1795 let refreshes_2: i64 = database
1796 .query_one_raw(Statement::from_string(
1797 DbBackend::Sqlite,
1798 "SELECT refreshes FROM session_refresh_audit",
1799 ))
1800 .await?
1801 .ok_or("Refresh audit row missing")?
1802 .try_get("", "refreshes")?;
1803 assert_eq!(
1804 refreshes_2, 2,
1805 "Two requests must refresh the session twice"
1806 );
1807 let kept_teams = ctx.database.list_teams(id(&kept)?).await?;
1808 assert_eq!(kept_teams.len(), 1);
1809 assert_eq!(
1810 (kept_teams)
1811 .first()
1812 .expect("fixture contains the requested index")
1813 .name,
1814 "Factory:KeptFactory"
1815 );
1816 assert!(
1817 ctx.database
1818 .get_team_member(
1819 &(kept_teams)
1820 .first()
1821 .expect("fixture contains the requested index")
1822 .id,
1823 &owner.id
1824 )
1825 .await?
1826 .is_some()
1827 );
1828 let preserved = ctx
1829 .database
1830 .get_session(&session.token)
1831 .await?
1832 .ok_or("Session must remain active")?;
1833 assert_eq!(preserved.active_organization_id(), Some(id(&organization)?));
1834 assert_eq!(preserved.active_team_id(), Some(team.id.as_str()));
1835
1836 Ok(())
1837 }
1838
1839 #[tokio::test]
1840 #[expect(
1841 clippy::panic_in_result_fn,
1842 reason = "Assertions report test failures; Result propagates setup and fixture errors"
1843 )]
1844 async fn configured_team_limits_apply_to_server_and_http_creation() -> TestResult {
1845 let plugin = OrganizationPlugin::with_config(OrganizationConfig {
1846 teams: TeamsConfig {
1847 enabled: true,
1848 create_default_team: false,
1849 maximum_teams: Some(1.0),
1850 maximum_members_per_team: Some(1.0),
1851 ..Default::default()
1852 },
1853 ..Default::default()
1854 });
1855 let mut config = create_test_config();
1856 config.advanced.database.default_find_many_limit = 1;
1857 let ctx = configured_context(&plugin, config).await?;
1858 let (owner, session) = actor(&ctx, "limited-owner").await;
1859 let (_, target_session) = actor(&ctx, "limited-target").await;
1860 let created = call(
1861 &plugin,
1862 &ctx,
1863 Some(&session.token),
1864 HttpMethod::Post,
1865 "/organization/create",
1866 Some(json!({"name":"Limits","slug":"native-limits"})),
1867 &[],
1868 )
1869 .await?;
1870 let organization: Value = body(&created)?;
1871 let org_id = id(&organization)?;
1872 assert_eq!(ctx.database.list_teams(org_id).await?, vec![]);
1873 let team = plugin
1874 .create_team(
1875 &ctx,
1876 CreateTeam {
1877 name: "Server team".to_owned(),
1878 organization_id: org_id.to_owned(),
1879 updated_at: Some(chrono::Utc::now()),
1880 },
1881 )
1882 .await?;
1883 assert_error(
1884 &call(
1885 &plugin,
1886 &ctx,
1887 Some(&session.token),
1888 HttpMethod::Post,
1889 "/organization/create-team",
1890 Some(json!({"name":"Second"})),
1891 &[],
1892 )
1893 .await?,
1894 400,
1895 "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS",
1896 )?;
1897 _ = ctx
1898 .database
1899 .create_member(CreateMember::new(org_id, &target_session.user_id, "member"))
1900 .await?;
1901 let added = call(
1902 &plugin,
1903 &ctx,
1904 Some(&session.token),
1905 HttpMethod::Post,
1906 "/organization/add-team-member",
1907 Some(json!({"teamId":team.id,"userId":owner.id})),
1908 &[],
1909 )
1910 .await?;
1911 assert_eq!(added.status, 200);
1912 assert_error(
1913 &call(
1914 &plugin,
1915 &ctx,
1916 Some(&session.token),
1917 HttpMethod::Post,
1918 "/organization/add-team-member",
1919 Some(json!({"teamId":team.id,"userId":target_session.user_id})),
1920 &[],
1921 )
1922 .await?,
1923 403,
1924 "TEAM_MEMBER_LIMIT_REACHED",
1925 )?;
1926 assert!(plugin.remove_team(&ctx, org_id, &team.id).await.is_err());
1927 let allowed = OrganizationPlugin::with_config(OrganizationConfig {
1928 teams: TeamsConfig {
1929 enabled: true,
1930 allow_removing_all_teams: true,
1931 ..Default::default()
1932 },
1933 ..Default::default()
1934 });
1935 allowed.remove_team(&ctx, org_id, &team.id).await?;
1936 assert_eq!(ctx.database.list_teams(org_id).await?, vec![]);
1937 Ok(())
1938 }
1939
1940 #[tokio::test]
1941 #[expect(
1942 clippy::panic_in_result_fn,
1943 reason = "Assertions report test failures; Result propagates setup and fixture errors"
1944 )]
1945 #[expect(
1946 clippy::too_many_lines,
1947 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1948 )]
1949 async fn custom_role_configuration_replaces_defaults_and_whole_permission_checks_do_not_union_roles()
1950 -> TestResult {
1951 let plugin = OrganizationPlugin::with_config(OrganizationConfig {
1952 roles: Some(HashMap::from([
1953 (
1954 "delegate".to_owned(),
1955 RolePermissions {
1956 team: vec!["create".to_owned()],
1957 ac: vec!["create".to_owned(), "read".to_owned()],
1958 ..Default::default()
1959 },
1960 ),
1961 (
1962 "member-editor".to_owned(),
1963 RolePermissions {
1964 member: vec!["update".to_owned()],
1965 ..Default::default()
1966 },
1967 ),
1968 ])),
1969 ..configuration()
1970 });
1971 let ctx = context(&plugin).await?;
1972 let (_, session) = actor(&ctx, "custom-role-owner").await;
1973 let created = call(
1974 &plugin,
1975 &ctx,
1976 Some(&session.token),
1977 HttpMethod::Post,
1978 "/organization/create",
1979 Some(json!({"name":"Custom roles","slug":"native-custom-roles"})),
1980 &[],
1981 )
1982 .await?;
1983 let created: Value = body(&created)?;
1984 _ = id(&created)?;
1985 let member_id = created
1986 .get("members")
1987 .and_then(Value::as_array)
1988 .and_then(|rows| rows.first())
1989 .and_then(|member| member.get("id"))
1990 .and_then(Value::as_str)
1991 .ok_or_else(|| {
1992 std::io::Error::other("Created organization has no creator membership")
1993 })?;
1994 assert_error(
1995 &call(
1996 &plugin,
1997 &ctx,
1998 Some(&session.token),
1999 HttpMethod::Post,
2000 "/organization/create-team",
2001 Some(json!({"name":"Owner defaults must not leak"})),
2002 &[],
2003 )
2004 .await?,
2005 403,
2006 "YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION",
2007 )?;
2008 let assigned = call(
2011 &plugin,
2012 &ctx,
2013 Some(&session.token),
2014 HttpMethod::Post,
2015 "/organization/update-member-role",
2016 Some(json!({"memberId":member_id,"role":["owner","delegate","member-editor"]})),
2017 &[],
2018 )
2019 .await?;
2020 assert_eq!(assigned.status, 200);
2021 let allowed = call(
2022 &plugin,
2023 &ctx,
2024 Some(&session.token),
2025 HttpMethod::Post,
2026 "/organization/create-team",
2027 Some(json!({"name":"Delegated"})),
2028 &[],
2029 )
2030 .await?;
2031 assert_eq!(allowed.status, 200);
2032 let together = call(
2033 &plugin,
2034 &ctx,
2035 Some(&session.token),
2036 HttpMethod::Post,
2037 "/organization/has-permission",
2038 Some(json!({"permissions":{"team":["create"],"member":["update"]}})),
2039 &[],
2040 )
2041 .await?;
2042 assert_eq!(
2043 (*(body::<Value>(&together)?)
2044 .get("success")
2045 .unwrap_or(&Value::Null)),
2046 false
2047 );
2048 let empty = OrganizationPlugin::with_config(OrganizationConfig {
2049 roles: Some(HashMap::new()),
2050 ..configuration()
2051 });
2052 assert_error(
2053 &call(
2054 &empty,
2055 &ctx,
2056 Some(&session.token),
2057 HttpMethod::Post,
2058 "/organization/create-team",
2059 Some(json!({"name":"Explicitly no roles"})),
2060 &[],
2061 )
2062 .await?,
2063 403,
2064 "YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION",
2065 )?;
2066
2067 let founder = OrganizationPlugin::with_config(OrganizationConfig {
2068 creator_role: "founder".to_owned(),
2069 roles: Some(HashMap::from([
2070 (
2071 "founder".to_owned(),
2072 RolePermissions {
2073 invitation: vec!["create".to_owned()],
2074 ..Default::default()
2075 },
2076 ),
2077 (
2078 "inviter".to_owned(),
2079 RolePermissions {
2080 invitation: vec!["create".to_owned()],
2081 ..Default::default()
2082 },
2083 ),
2084 ])),
2085 ..configuration()
2086 });
2087 let founder_ctx = context(&founder).await?;
2088 let (_, founder_session) = actor(&founder_ctx, "configured-founder").await;
2089 let created_2 = call(
2090 &founder,
2091 &founder_ctx,
2092 Some(&founder_session.token),
2093 HttpMethod::Post,
2094 "/organization/create",
2095 Some(json!({"name":"Founder roles","slug":"native-founder-roles"})),
2096 &[],
2097 )
2098 .await?;
2099 let created_2_3: Value = body(&created_2)?;
2100 let founder_org = id(&created_2_3)?;
2101 let invited = call(
2104 &founder,
2105 &founder_ctx,
2106 Some(&founder_session.token),
2107 HttpMethod::Post,
2108 "/organization/invite-member",
2109 Some(json!({"email":"builtin-owner@example.com","role":"owner"})),
2110 &[],
2111 )
2112 .await?;
2113 assert_eq!(invited.status, 200);
2114 let invitation: Value = body(&invited)?;
2115 let saved = founder_ctx
2116 .database
2117 .get_invitation_by_id(id(&invitation)?)
2118 .await?
2119 .ok_or_else(|| std::io::Error::other("Owner-role invitation was not persisted"))?;
2120 assert_eq!(saved.organization_id, founder_org);
2121 assert_eq!(saved.role.as_deref(), Some("owner"));
2122 assert_eq!(saved.status, alibi_core::types::InvitationStatus::Pending);
2123
2124 let (sender, inviter_session) = actor(&founder_ctx, "configured-inviter").await;
2125 _ = founder_ctx
2126 .database
2127 .create_member(CreateMember::new(founder_org, &sender.id, "inviter"))
2128 .await?;
2129 assert_error(
2130 &call(
2131 &founder,
2132 &founder_ctx,
2133 Some(&inviter_session.token),
2134 HttpMethod::Post,
2135 "/organization/invite-member",
2136 Some(json!({"organizationId":founder_org,"email":"protected-founder@example.com","role":"founder"})),
2137 &[],
2138 )
2139 .await?,
2140 403,
2141 "YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE",
2142 )?;
2143 assert!(
2144 founder_ctx
2145 .database
2146 .get_pending_invitation(founder_org, "protected-founder@example.com")
2147 .await?
2148 .is_none()
2149 );
2150 Ok(())
2151 }
2152
2153 #[tokio::test]
2154 #[expect(
2155 clippy::panic_in_result_fn,
2156 reason = "Assertions report test failures; Result propagates setup and fixture errors"
2157 )]
2158 #[expect(
2159 clippy::too_many_lines,
2160 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
2161 )]
2162 async fn invitation_email_policy_guards_id_actions_and_preserves_rejection_state() -> TestResult
2163 {
2164 let plugin = OrganizationPlugin::with_config(OrganizationConfig {
2165 require_email_verification_on_invitation: Some(true),
2166 ..configuration()
2167 });
2168 let mut ctx = context(&plugin).await?;
2169 let (owner, owner_session) = actor(&ctx, "verified-inviter").await;
2170 let (recipient, recipient_session) = actor(&ctx, "unverified-recipient").await;
2171 _ = ctx
2172 .database
2173 .update_user(
2174 &recipient.id,
2175 alibi_core::UpdateUser {
2176 email_verified: Some(false),
2177 ..Default::default()
2178 },
2179 )
2180 .await?;
2181 let created = call(
2182 &plugin,
2183 &ctx,
2184 Some(&owner_session.token),
2185 HttpMethod::Post,
2186 "/organization/create",
2187 Some(json!({"name":"Verified invitation","slug":"verified-invitation"})),
2188 &[],
2189 )
2190 .await?;
2191 let created: Value = body(&created)?;
2192 let organization_id = id(&created)?;
2193 let invitation = ctx
2194 .database
2195 .create_invitation(alibi_core::CreateInvitation {
2196 email: recipient
2197 .email
2198 .clone()
2199 .ok_or_else(|| std::io::Error::other("Recipient has no email"))?,
2200 role: "member".to_owned(),
2201 organization_id: organization_id.to_owned(),
2202 inviter_id: owner.id.clone(),
2203 expires_at: chrono::Utc::now() + Duration::hours(1),
2204 team_id: None,
2205 })
2206 .await?;
2207 let forbidden_get = call(
2208 &plugin,
2209 &ctx,
2210 Some(&recipient_session.token),
2211 HttpMethod::Get,
2212 "/organization/get-invitation",
2213 None,
2214 &[("id", &invitation.id)],
2215 )
2216 .await?;
2217 assert_error(
2218 &forbidden_get,
2219 403,
2220 "EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION",
2221 )?;
2222 for path in [
2223 "/organization/accept-invitation",
2224 "/organization/reject-invitation",
2225 ] {
2226 assert_error(
2227 &call(
2228 &plugin,
2229 &ctx,
2230 Some(&recipient_session.token),
2231 HttpMethod::Post,
2232 path,
2233 Some(json!({"invitationId":invitation.id})),
2234 &[],
2235 )
2236 .await?,
2237 403,
2238 "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION",
2239 )?;
2240 }
2241 let untouched = ctx
2242 .database
2243 .get_invitation_by_id(&invitation.id)
2244 .await?
2245 .ok_or_else(|| std::io::Error::other("Rejected invitation disappeared"))?;
2246 assert_eq!(untouched.status, alibi_core::InvitationStatus::Pending);
2247 assert!(
2248 ctx.database
2249 .get_member(organization_id, &recipient.id)
2250 .await?
2251 .is_none()
2252 );
2253 assert_eq!(ctx.database.list_user_teams(&recipient.id).await?, vec![]);
2254
2255 let automatic = OrganizationPlugin::with_config(configuration());
2257 assert_eq!(
2258 call(
2259 &automatic,
2260 &ctx,
2261 Some(&recipient_session.token),
2262 HttpMethod::Get,
2263 "/organization/get-invitation",
2264 None,
2265 &[("id", &invitation.id)]
2266 )
2267 .await?
2268 .status,
2269 200
2270 );
2271 let mut numeric_config = ctx.config.as_ref().clone();
2272 numeric_config.advanced.database.use_number_id = true;
2273 ctx.config = std::sync::Arc::new(numeric_config);
2274 assert_error(
2275 &call(
2276 &automatic,
2277 &ctx,
2278 Some(&recipient_session.token),
2279 HttpMethod::Get,
2280 "/organization/get-invitation",
2281 None,
2282 &[("id", &invitation.id)],
2283 )
2284 .await?,
2285 403,
2286 "EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION",
2287 )?;
2288 let explicit_false = OrganizationPlugin::with_config(OrganizationConfig {
2289 require_email_verification_on_invitation: Some(false),
2290 ..configuration()
2291 });
2292 assert_eq!(
2293 call(
2294 &explicit_false,
2295 &ctx,
2296 Some(&recipient_session.token),
2297 HttpMethod::Get,
2298 "/organization/get-invitation",
2299 None,
2300 &[("id", &invitation.id)]
2301 )
2302 .await?
2303 .status,
2304 200
2305 );
2306 let rejected = call(
2307 &explicit_false,
2308 &ctx,
2309 Some(&recipient_session.token),
2310 HttpMethod::Post,
2311 "/organization/reject-invitation",
2312 Some(json!({"invitationId":invitation.id})),
2313 &[],
2314 )
2315 .await?;
2316 assert_eq!(rejected.status, 200);
2317 assert_eq!(
2318 ctx.database
2319 .get_invitation_by_id(&invitation.id)
2320 .await?
2321 .ok_or_else(|| std::io::Error::other("Processed invitation disappeared"))?
2322 .status,
2323 alibi_core::InvitationStatus::Rejected
2324 );
2325 let processed = call(
2326 &explicit_false,
2327 &ctx,
2328 Some(&recipient_session.token),
2329 HttpMethod::Get,
2330 "/organization/get-invitation",
2331 None,
2332 &[("id", &invitation.id)],
2333 )
2334 .await?;
2335 assert_eq!(processed.status, 400);
2336 assert_eq!(
2337 body::<Value>(&processed)?,
2338 json!({"message":"Invitation not found!"})
2339 );
2340
2341 let expired = ctx
2342 .database
2343 .create_invitation(alibi_core::CreateInvitation {
2344 email: invitation.email.clone(),
2345 role: "member".to_owned(),
2346 organization_id: organization_id.to_owned(),
2347 inviter_id: owner.id.clone(),
2348 expires_at: chrono::Utc::now() - Duration::hours(1),
2349 team_id: None,
2350 })
2351 .await?;
2352 let expired_get = call(
2353 &plugin,
2354 &ctx,
2355 Some(&recipient_session.token),
2356 HttpMethod::Get,
2357 "/organization/get-invitation",
2358 None,
2359 &[("id", &expired.id)],
2360 )
2361 .await?;
2362 assert_eq!(expired_get.status, 400);
2363 assert_eq!(
2364 body::<Value>(&expired_get)?,
2365 json!({"message":"Invitation not found!"})
2366 );
2367 assert_error(
2368 &call(
2369 &plugin,
2370 &ctx,
2371 Some(&recipient_session.token),
2372 HttpMethod::Post,
2373 "/organization/accept-invitation",
2374 Some(json!({"invitationId":expired.id})),
2375 &[],
2376 )
2377 .await?,
2378 400,
2379 "INVITATION_NOT_FOUND",
2380 )?;
2381 _ = ctx
2382 .database
2383 .update_user(
2384 &recipient.id,
2385 alibi_core::UpdateUser {
2386 email_verified: Some(true),
2387 ..Default::default()
2388 },
2389 )
2390 .await?;
2391 assert_eq!(
2393 call(
2394 &plugin,
2395 &ctx,
2396 Some(&recipient_session.token),
2397 HttpMethod::Post,
2398 "/organization/reject-invitation",
2399 Some(json!({"invitationId":expired.id})),
2400 &[]
2401 )
2402 .await?
2403 .status,
2404 200
2405 );
2406 let pending = ctx
2407 .database
2408 .create_invitation(alibi_core::CreateInvitation {
2409 email: invitation.email,
2410 role: "member".to_owned(),
2411 organization_id: organization_id.to_owned(),
2412 inviter_id: owner.id.clone(),
2413 expires_at: chrono::Utc::now() + Duration::hours(1),
2414 team_id: None,
2415 })
2416 .await?;
2417 let owner_member = ctx
2418 .database
2419 .get_member(organization_id, &owner.id)
2420 .await?
2421 .ok_or_else(|| std::io::Error::other("Creator membership missing"))?;
2422 ctx.database.delete_member(&owner_member.id).await?;
2423 assert_error(
2424 &call(
2425 &plugin,
2426 &ctx,
2427 Some(&recipient_session.token),
2428 HttpMethod::Get,
2429 "/organization/get-invitation",
2430 None,
2431 &[("id", &pending.id)],
2432 )
2433 .await?,
2434 400,
2435 "INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION",
2436 )?;
2437 Ok(())
2438 }
2439}
2440#[cfg(test)]
2444mod dynamic_role_tests {
2445 use super::extension_tests::{actor, assert_error, body, call, configured_context, id};
2446 use super::*;
2447 use crate::test_helpers::create_test_config;
2448 use alibi_core::types::{
2449 CreateMember, CreateOrganizationRole, OrganizationPermissions, OrganizationRoleSelector,
2450 };
2451 use alibi_core::wire::SessionView;
2452 use alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
2453 use serde_json::{Value, json};
2454
2455 type TestResult = Result<(), Box<dyn std::error::Error>>;
2456
2457 #[derive(Debug, Default)]
2458 struct PausingRoleLimits {
2459 pause: std::sync::atomic::AtomicBool,
2460 entered: tokio::sync::Notify,
2461 release: tokio::sync::Notify,
2462 }
2463
2464 #[async_trait]
2465 impl OrganizationLimitResolver for PausingRoleLimits {
2466 async fn maximum_roles(&self, _organization_id: &str) -> AuthResult<Option<f64>> {
2467 if self.pause.swap(false, std::sync::atomic::Ordering::SeqCst) {
2468 self.entered.notify_one();
2469 self.release.notified().await;
2470 }
2471 Ok(Some(100.0))
2472 }
2473 }
2474
2475 #[derive(Debug)]
2476 struct RoleLimits(std::sync::Arc<std::sync::Mutex<HashMap<String, usize>>>);
2477
2478 #[async_trait]
2479 impl OrganizationLimitResolver for RoleLimits {
2480 #[expect(
2481 clippy::cast_precision_loss,
2482 reason = "Migrate the existing integer application policy to a Source Number"
2483 )]
2484 async fn maximum_roles(&self, organization_id: &str) -> AuthResult<Option<f64>> {
2485 let policies = self
2486 .0
2487 .lock()
2488 .map_err(|_error| alibi_core::AuthError::internal("Role policy unavailable"))?;
2489 Ok(Some(
2490 policies.get(organization_id).copied().unwrap_or(0) as f64
2491 ))
2492 }
2493 }
2494
2495 fn configuration() -> OrganizationConfig {
2496 OrganizationConfig {
2497 teams: TeamsConfig {
2498 enabled: true,
2499 create_default_team: false,
2500 ..Default::default()
2501 },
2502 access_control: Some(default_organization_statements()),
2503 dynamic_access_control: DynamicAccessControlConfig {
2504 enabled: true,
2505 ..Default::default()
2506 },
2507 ..Default::default()
2508 }
2509 }
2510
2511 async fn organization(
2512 plugin: &OrganizationPlugin,
2513 ctx: &AuthContext<BundledSchema>,
2514 session: &SessionView,
2515 slug: &str,
2516 ) -> Result<String, Box<dyn std::error::Error>> {
2517 let response = call(
2518 plugin,
2519 ctx,
2520 Some(&session.token),
2521 HttpMethod::Post,
2522 "/organization/create",
2523 Some(json!({"name":slug,"slug":slug})),
2524 &[],
2525 )
2526 .await?;
2527 assert_eq!(response.status, 200);
2528 Ok(id(&body::<Value>(&response)?)?.to_owned())
2529 }
2530
2531 fn permission(resource: &str, action: &str) -> OrganizationPermissions {
2532 [(resource.to_owned(), vec![action.to_owned()])].into()
2533 }
2534
2535 #[tokio::test]
2536 #[expect(
2537 clippy::panic_in_result_fn,
2538 reason = "Assertions report test failures; Result propagates setup and fixture errors"
2539 )]
2540 #[expect(
2541 clippy::too_many_lines,
2542 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
2543 )]
2544 async fn dynamic_role_mutations_scope_the_tenant_and_apply_name_selectors_to_all_legacy_rows()
2545 -> TestResult {
2546 let plugin = OrganizationPlugin::with_config(configuration());
2547 let ctx = configured_context(&plugin, create_test_config()).await?;
2548 let (_, owner) = actor(&ctx, "role-owner").await;
2549 let (_, foreign) = actor(&ctx, "role-foreign").await;
2550 let org = organization(&plugin, &ctx, &owner, "native-role-mutations").await?;
2551 let other = organization(&plugin, &ctx, &foreign, "native-role-foreign").await?;
2552 let mut rows = Vec::new();
2553 for organization_id in [&org, &org, &other] {
2554 rows.push(
2555 ctx.database
2556 .create_organization_role(CreateOrganizationRole {
2557 organization_id: organization_id.clone(),
2558 role: "legacy-editor".to_owned(),
2559 permission: permission("team", "create"),
2560 })
2561 .await?,
2562 );
2563 }
2564 let wrong_scope = call(
2565 &plugin,
2566 &ctx,
2567 Some(&foreign.token),
2568 HttpMethod::Get,
2569 "/organization/get-role",
2570 None,
2571 &[
2572 ("organizationId", &other),
2573 (
2574 "roleId",
2575 &(rows)
2576 .first()
2577 .expect("fixture contains the requested index")
2578 .id,
2579 ),
2580 ],
2581 )
2582 .await?;
2583 assert_error(&wrong_scope, 400, "ROLE_NOT_FOUND")?;
2584 let unauthorized_update = call(&plugin, &ctx, Some(&foreign.token), HttpMethod::Post,
2585 "/organization/update-role", Some(json!({"organizationId":org,"roleId":(rows).first().expect("fixture contains the requested index").id,"data":{"permission":{"team":["delete"]}}})), &[]).await?;
2586 assert_error(
2587 &unauthorized_update,
2588 403,
2589 "YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION",
2590 )?;
2591 assert_eq!(
2592 serde_json::from_str::<OrganizationPermissions>(
2593 ctx.database
2594 .get_organization_role(
2595 &org,
2596 &OrganizationRoleSelector::Id(
2597 (rows)
2598 .first()
2599 .expect("fixture contains the requested index")
2600 .id
2601 .clone()
2602 )
2603 )
2604 .await?
2605 .ok_or("Role missing")?
2606 .permission
2607 .as_str()
2608 )?,
2609 permission("team", "create")
2610 );
2611
2612 let by_id = call(&plugin, &ctx, Some(&owner.token), HttpMethod::Post,
2613 "/organization/update-role", Some(json!({"organizationId":org,"roleId":(rows).first().expect("fixture contains the requested index").id,"data":{"permission":{"team":["update"]}}})), &[]).await?;
2614 assert_eq!(by_id.status, 200);
2615 assert_eq!(
2616 (*(*(body::<Value>(&by_id)?)
2617 .get("roleData")
2618 .unwrap_or(&Value::Null))
2619 .get("updatedAt")
2620 .unwrap_or(&Value::Null)),
2621 Value::Null
2622 );
2623 let first_snapshot = ctx
2624 .database
2625 .get_organization_role(
2626 &org,
2627 &OrganizationRoleSelector::Id(
2628 (rows)
2629 .first()
2630 .expect("fixture contains the requested index")
2631 .id
2632 .clone(),
2633 ),
2634 )
2635 .await?
2636 .ok_or("Role missing")?;
2637 assert_eq!(
2638 serde_json::from_str::<OrganizationPermissions>(first_snapshot.permission.as_str())?,
2639 permission("team", "update")
2640 );
2641 assert!(first_snapshot.updated_at.is_some());
2642 assert_eq!(
2643 serde_json::from_str::<OrganizationPermissions>(
2644 ctx.database
2645 .get_organization_role(
2646 &org,
2647 &OrganizationRoleSelector::Id(
2648 (rows)
2649 .get(1)
2650 .expect("fixture contains the requested index")
2651 .id
2652 .clone()
2653 )
2654 )
2655 .await?
2656 .ok_or("Role missing")?
2657 .permission
2658 .as_str()
2659 )?,
2660 permission("team", "create")
2661 );
2662
2663 let by_name = call(&plugin, &ctx, Some(&owner.token), HttpMethod::Post,
2664 "/organization/update-role", Some(json!({"organizationId":org,"roleName":"legacy-editor","data":{"permission":{"member":["update"]}}})), &[]).await?;
2665 assert_eq!(by_name.status, 200);
2666 assert_eq!(
2667 (*(*(body::<Value>(&by_name)?)
2668 .get("roleData")
2669 .unwrap_or(&Value::Null))
2670 .get("id")
2671 .unwrap_or(&Value::Null)),
2672 (rows)
2673 .first()
2674 .expect("fixture contains the requested index")
2675 .id
2676 );
2677 assert_eq!(
2678 (*(*(body::<Value>(&by_name)?)
2679 .get("roleData")
2680 .unwrap_or(&Value::Null))
2681 .get("updatedAt")
2682 .unwrap_or(&Value::Null)),
2683 (*(serde_json::to_value(first_snapshot)?)
2684 .get("updatedAt")
2685 .unwrap_or(&Value::Null))
2686 );
2687 let updated = ctx.database.list_organization_roles(&org).await?;
2688 assert_eq!(updated.len(), 2);
2689 for row in &updated {
2690 assert_eq!(
2691 serde_json::from_str::<OrganizationPermissions>(row.permission.as_str())?,
2692 permission("member", "update")
2693 );
2694 }
2695 assert!(
2696 (updated)
2697 .first()
2698 .expect("persisted rows contain the requested index")
2699 .updated_at
2700 .is_some()
2701 );
2702 assert_eq!(
2703 (updated)
2704 .first()
2705 .expect("persisted rows contain the requested index")
2706 .updated_at,
2707 (updated)
2708 .get(1)
2709 .expect("persisted rows contain the requested index")
2710 .updated_at
2711 );
2712 let unaffected = ctx
2713 .database
2714 .get_organization_role(
2715 &other,
2716 &OrganizationRoleSelector::Id(
2717 (rows)
2718 .get(2)
2719 .expect("fixture contains the requested index")
2720 .id
2721 .clone(),
2722 ),
2723 )
2724 .await?
2725 .ok_or("Foreign role missing")?;
2726 assert_eq!(
2727 serde_json::from_str::<OrganizationPermissions>(unaffected.permission.as_str())?,
2728 permission("team", "create")
2729 );
2730 assert_eq!(unaffected.updated_at, None);
2731
2732 let deleted = call(
2733 &plugin,
2734 &ctx,
2735 Some(&owner.token),
2736 HttpMethod::Post,
2737 "/organization/delete-role",
2738 Some(json!({"organizationId":org,"roleName":"legacy-editor"})),
2739 &[],
2740 )
2741 .await?;
2742 assert_eq!(deleted.status, 200);
2743 assert_eq!(body::<Value>(&deleted)?, json!({"success":true}));
2744 assert_eq!(ctx.database.count_organization_roles(&org).await?, 0);
2745 assert_eq!(ctx.database.count_organization_roles(&other).await?, 1);
2746 let replay = call(
2747 &plugin,
2748 &ctx,
2749 Some(&owner.token),
2750 HttpMethod::Post,
2751 "/organization/delete-role",
2752 Some(json!({"organizationId":org,"roleId":(rows).first().expect("fixture contains the requested index").id})),
2753 &[],
2754 )
2755 .await?;
2756 assert_error(&replay, 400, "ROLE_NOT_FOUND")?;
2757
2758 let id_only = ctx
2759 .database
2760 .create_organization_role(CreateOrganizationRole {
2761 organization_id: org.clone(),
2762 role: "another-duplicate".to_owned(),
2763 permission: permission("team", "create"),
2764 })
2765 .await?;
2766 let retained = ctx
2767 .database
2768 .create_organization_role(CreateOrganizationRole {
2769 organization_id: org.clone(),
2770 role: "another-duplicate".to_owned(),
2771 permission: permission("team", "create"),
2772 })
2773 .await?;
2774 let deleted_2 = call(
2775 &plugin,
2776 &ctx,
2777 Some(&owner.token),
2778 HttpMethod::Post,
2779 "/organization/delete-role",
2780 Some(json!({"organizationId":org,"roleId":id_only.id})),
2781 &[],
2782 )
2783 .await?;
2784 assert_eq!(deleted_2.status, 200);
2785 assert_eq!(ctx.database.count_organization_roles(&org).await?, 1);
2786 assert!(
2787 ctx.database
2788 .get_organization_role(&org, &OrganizationRoleSelector::Id(retained.id))
2789 .await?
2790 .is_some()
2791 );
2792 Ok(())
2793 }
2794
2795 #[tokio::test]
2796 #[expect(
2797 clippy::panic_in_result_fn,
2798 reason = "Assertions report test failures; Result propagates setup and fixture errors"
2799 )]
2800 async fn dynamic_role_creation_preserves_the_membership_resource_and_name_check_order()
2801 -> TestResult {
2802 let plugin = OrganizationPlugin::with_config(configuration());
2803 let ctx = configured_context(&plugin, create_test_config()).await?;
2804 let (_, owner) = actor(&ctx, "name-owner").await;
2805 let (_, outsider) = actor(&ctx, "name-outsider").await;
2806 let (member, member_session) = actor(&ctx, "name-member").await;
2807 let org = organization(&plugin, &ctx, &owner, "native-role-name-priority").await?;
2808 ctx.database
2809 .create_member(CreateMember {
2810 organization_id: org.clone(),
2811 user_id: member.id,
2812 role: "member".to_owned(),
2813 })
2814 .await?;
2815 ctx.database
2816 .create_organization_role(CreateOrganizationRole {
2817 organization_id: org.clone(),
2818 role: "existing".to_owned(),
2819 permission: permission("team", "create"),
2820 })
2821 .await?;
2822 for (session, name, requested, status, code) in [
2823 (
2824 &outsider,
2825 "existing",
2826 json!({}),
2827 403,
2828 "YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION",
2829 ),
2830 (
2831 &outsider,
2832 "OWNER",
2833 json!({}),
2834 400,
2835 "ROLE_NAME_IS_ALREADY_TAKEN",
2836 ),
2837 (
2838 &member_session,
2839 "existing",
2840 json!({}),
2841 403,
2842 "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE",
2843 ),
2844 (
2845 &owner,
2846 "existing",
2847 json!({"invented":["read"]}),
2848 400,
2849 "INVALID_RESOURCE",
2850 ),
2851 (
2852 &owner,
2853 "existing",
2854 json!({}),
2855 400,
2856 "ROLE_NAME_IS_ALREADY_TAKEN",
2857 ),
2858 ] {
2859 let denied = call(
2860 &plugin,
2861 &ctx,
2862 Some(&session.token),
2863 HttpMethod::Post,
2864 "/organization/create-role",
2865 Some(json!({"organizationId":org,"role":name,"permission":requested})),
2866 &[],
2867 )
2868 .await?;
2869 assert_error(&denied, status, code)?;
2870 assert_eq!(ctx.database.count_organization_roles(&org).await?, 1);
2871 }
2872 Ok(())
2873 }
2874
2875 #[tokio::test]
2876 #[expect(
2877 clippy::panic_in_result_fn,
2878 reason = "Assertions report test failures; Result propagates setup and fixture errors"
2879 )]
2880 async fn assigned_role_checks_filter_the_tenant_before_the_configured_adapter_page()
2881 -> TestResult {
2882 let plugin = OrganizationPlugin::with_config(configuration());
2883 let mut config = create_test_config();
2884 config.advanced.database.default_find_many_limit = 1;
2885 let ctx = configured_context(&plugin, config).await?;
2886 let (_, owner) = actor(&ctx, "page-owner").await;
2887 let (assigned, assigned_session) = actor(&ctx, "page-assigned").await;
2888 let (prefix, _) = actor(&ctx, "page-prefix").await;
2889 let org = organization(&plugin, &ctx, &owner, "native-assigned-role-page").await?;
2890 let role = ctx
2891 .database
2892 .create_organization_role(CreateOrganizationRole {
2893 organization_id: org.clone(),
2894 role: "editor".to_owned(),
2895 permission: permission("team", "create"),
2896 })
2897 .await?;
2898 let member = ctx
2899 .database
2900 .create_member(CreateMember {
2901 organization_id: org.clone(),
2902 user_id: assigned.id.clone(),
2903 role: " member, editor ".to_owned(),
2904 })
2905 .await?;
2906 let denied = call(
2907 &plugin,
2908 &ctx,
2909 Some(&owner.token),
2910 HttpMethod::Post,
2911 "/organization/delete-role",
2912 Some(json!({"organizationId":org,"roleId":role.id})),
2913 &[],
2914 )
2915 .await?;
2916 assert_error(&denied, 400, "ROLE_IS_ASSIGNED_TO_MEMBERS")?;
2917 assert!(
2918 ctx.database
2919 .get_organization_role(&org, &OrganizationRoleSelector::Id(role.id.clone()))
2920 .await?
2921 .is_some()
2922 );
2923
2924 ctx.database.delete_member(&member.id).await?;
2925 ctx.database
2926 .create_member(CreateMember {
2927 organization_id: org.clone(),
2928 user_id: prefix.id,
2929 role: "prefixeditor".to_owned(),
2930 })
2931 .await?;
2932 ctx.database
2933 .create_member(CreateMember {
2934 organization_id: org.clone(),
2935 user_id: assigned.id.clone(),
2936 role: "editor".to_owned(),
2937 })
2938 .await?;
2939 let deleted = call(
2942 &plugin,
2943 &ctx,
2944 Some(&owner.token),
2945 HttpMethod::Post,
2946 "/organization/delete-role",
2947 Some(json!({"organizationId":org,"roleId":role.id})),
2948 &[],
2949 )
2950 .await?;
2951 assert_eq!(deleted.status, 200);
2952 assert_eq!(body::<Value>(&deleted)?, json!({"success":true}));
2953 assert!(
2954 ctx.database
2955 .get_organization_role(&org, &OrganizationRoleSelector::Id(role.id))
2956 .await?
2957 .is_none()
2958 );
2959 assert_eq!(
2960 ctx.database
2961 .get_member(&org, &assigned.id)
2962 .await?
2963 .ok_or("Assigned member missing")?
2964 .role,
2965 "editor"
2966 );
2967 let revoked = call(
2968 &plugin,
2969 &ctx,
2970 Some(&assigned_session.token),
2971 HttpMethod::Post,
2972 "/organization/create-team",
2973 Some(json!({"organizationId":org,"name":"Unavailable"})),
2974 &[],
2975 )
2976 .await?;
2977 assert_error(
2978 &revoked,
2979 403,
2980 "YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION",
2981 )?;
2982 assert_eq!(ctx.database.list_teams(&org).await?, vec![]);
2983 Ok(())
2984 }
2985
2986 #[tokio::test]
2987 #[expect(
2988 clippy::panic_in_result_fn,
2989 reason = "Assertions report test failures; Result propagates setup and fixture errors"
2990 )]
2991 async fn permission_requests_preserve_empty_legacy_xor_and_membership_wire_behavior()
2992 -> TestResult {
2993 let plugin = OrganizationPlugin::with_config(configuration());
2994 let ctx = configured_context(&plugin, create_test_config()).await?;
2995 let (_, owner) = actor(&ctx, "permission-owner").await;
2996 let (_, outsider) = actor(&ctx, "permission-outsider").await;
2997 let org = organization(&plugin, &ctx, &owner, "native-role-permission-shapes").await?;
2998 for (request, success) in [
2999 (json!({"permissions":{}}), false),
3000 (json!({"permissions":{"team":[]}}), false),
3001 (json!({"permissions":{"invented":[]}}), false),
3002 (json!({"permission":{"team":["create"]}}), false),
3003 (
3004 json!({"permissions":{"team":["create"]},"permission":null}),
3005 true,
3006 ),
3007 (
3008 json!({"permissions":null,"permission":{"team":["create"]}}),
3009 false,
3010 ),
3011 (
3012 json!({"organizationId":"","permissions":{"team":["create"]}}),
3013 true,
3014 ),
3015 ] {
3016 let response = call(
3017 &plugin,
3018 &ctx,
3019 Some(&owner.token),
3020 HttpMethod::Post,
3021 "/organization/has-permission",
3022 Some(request),
3023 &[],
3024 )
3025 .await?;
3026 assert_eq!(response.status, 200);
3027 assert_eq!(
3028 body::<Value>(&response)?,
3029 json!({"error":null,"success":success})
3030 );
3031 }
3032 let both = call(
3033 &plugin,
3034 &ctx,
3035 Some(&owner.token),
3036 HttpMethod::Post,
3037 "/organization/has-permission",
3038 Some(json!({"permissions":{"team":["create"]},"permission":{"team":["create"]}})),
3039 &[],
3040 )
3041 .await?;
3042 assert_error(&both, 400, "VALIDATION_ERROR")?;
3043 assert_eq!(
3044 (*(body::<Value>(&both)?)
3045 .get("message")
3046 .unwrap_or(&Value::Null)),
3047 "[body] Invalid input: more than one option matched"
3048 );
3049 let denied = call(
3050 &plugin,
3051 &ctx,
3052 Some(&outsider.token),
3053 HttpMethod::Post,
3054 "/organization/has-permission",
3055 Some(json!({"organizationId":org,"permissions":{"team":["create"]}})),
3056 &[],
3057 )
3058 .await?;
3059 assert_error(&denied, 401, "USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION")?;
3060 let invalid_without_authentication = call(
3061 &plugin,
3062 &ctx,
3063 None,
3064 HttpMethod::Post,
3065 "/organization/create-role",
3066 Some(json!({"role":123,"permission":{}})),
3067 &[],
3068 )
3069 .await?;
3070 assert_error(&invalid_without_authentication, 400, "VALIDATION_ERROR")?;
3071 assert_eq!(
3072 (*(body::<Value>(&invalid_without_authentication)?)
3073 .get("message")
3074 .unwrap_or(&Value::Null)),
3075 "[body.role] Invalid input: expected string, received number"
3076 );
3077 assert_eq!(ctx.database.count_organization_roles(&org).await?, 0);
3078 Ok(())
3079 }
3080
3081 #[tokio::test]
3082 #[expect(
3083 clippy::panic_in_result_fn,
3084 reason = "Assertions report test failures; Result propagates setup and fixture errors"
3085 )]
3086 async fn role_wire_validation_preserves_union_selection_and_rejects_null_updates_without_mutation()
3087 -> TestResult {
3088 let plugin = OrganizationPlugin::with_config(configuration());
3089 let ctx = configured_context(&plugin, create_test_config()).await?;
3090 let (_, owner) = actor(&ctx, "validation-owner").await;
3091 let org = organization(&plugin, &ctx, &owner, "native-role-validation").await?;
3092 let role = ctx
3093 .database
3094 .create_organization_role(CreateOrganizationRole {
3095 organization_id: org.clone(),
3096 role: "editor".to_owned(),
3097 permission: permission("team", "create"),
3098 })
3099 .await?;
3100 for (request, message) in [
3101 (
3102 json!({"roleName":"","data":{}}),
3103 "[body.roleName] Too small: expected string to have >=1 characters",
3104 ),
3105 (
3106 json!({"roleId":"","data":{}}),
3107 "[body.roleId] Too small: expected string to have >=1 characters",
3108 ),
3109 (
3110 json!({"roleName":"","roleId":"","data":{}}),
3111 "[body] Invalid input",
3112 ),
3113 (json!({"roleName":123,"data":{}}), "[body] Invalid input"),
3114 (
3115 json!({"roleId":role.id,"data":{"permission":null}}),
3116 "[body.data.permission] Invalid input: expected record, received null",
3117 ),
3118 (
3119 json!({"roleId":role.id,"data":{"roleName":null}}),
3120 "[body.data.roleName] Invalid input: expected string, received null",
3121 ),
3122 (
3123 json!({"organizationId":null,"roleId":role.id,"data":{}}),
3124 "[body.organizationId] Invalid input: expected string, received null",
3125 ),
3126 ] {
3127 let response = call(
3128 &plugin,
3129 &ctx,
3130 Some(&owner.token),
3131 HttpMethod::Post,
3132 "/organization/update-role",
3133 Some(request),
3134 &[],
3135 )
3136 .await?;
3137 assert_error(&response, 400, "VALIDATION_ERROR")?;
3138 assert_eq!(
3139 (*(body::<Value>(&response)?)
3140 .get("message")
3141 .unwrap_or(&Value::Null)),
3142 message
3143 );
3144 let persisted = ctx
3145 .database
3146 .get_organization_role(&org, &OrganizationRoleSelector::Id(role.id.clone()))
3147 .await?
3148 .ok_or("Role missing")?;
3149 assert_eq!(
3150 serde_json::from_str::<OrganizationPermissions>(persisted.permission.as_str())?,
3151 permission("team", "create")
3152 );
3153 assert_eq!(persisted.updated_at, None);
3154 }
3155 let selected = call(
3156 &plugin,
3157 &ctx,
3158 Some(&owner.token),
3159 HttpMethod::Post,
3160 "/organization/update-role",
3161 Some(
3162 json!({"roleName":null,"roleId":role.id,"data":{"permission":{"team":["update"]}}}),
3163 ),
3164 &[],
3165 )
3166 .await?;
3167 assert_eq!(selected.status, 200);
3168 assert_eq!(
3169 (*(*(body::<Value>(&selected)?)
3170 .get("roleData")
3171 .unwrap_or(&Value::Null))
3172 .get("id")
3173 .unwrap_or(&Value::Null)),
3174 role.id
3175 );
3176 assert_eq!(
3177 serde_json::from_str::<OrganizationPermissions>(
3178 ctx.database
3179 .get_organization_role(&org, &OrganizationRoleSelector::Id(role.id.clone()))
3180 .await?
3181 .ok_or("Role missing")?
3182 .permission
3183 .as_str()
3184 )?,
3185 permission("team", "update")
3186 );
3187 Ok(())
3188 }
3189
3190 fn delegated_configuration() -> OrganizationConfig {
3191 let mut config = configuration();
3192 _ = config
3193 .access_control
3194 .as_mut()
3195 .expect("Access control configured")
3196 .insert(
3197 "apiKey".to_owned(),
3198 vec![
3199 "create".to_owned(),
3200 "read".to_owned(),
3201 "update".to_owned(),
3202 "delete".to_owned(),
3203 ],
3204 );
3205 config.roles = Some(
3206 [
3207 (
3208 "owner".to_owned(),
3209 RolePermissions {
3210 organization: vec!["update".to_owned(), "delete".to_owned()],
3211 member: vec![
3212 "create".to_owned(),
3213 "update".to_owned(),
3214 "delete".to_owned(),
3215 ],
3216 invitation: vec!["create".to_owned(), "cancel".to_owned()],
3217 team: vec![
3218 "create".to_owned(),
3219 "update".to_owned(),
3220 "delete".to_owned(),
3221 ],
3222 ac: vec![
3223 "create".to_owned(),
3224 "read".to_owned(),
3225 "update".to_owned(),
3226 "delete".to_owned(),
3227 ],
3228 api_key: vec![
3229 "create".to_owned(),
3230 "read".to_owned(),
3231 "update".to_owned(),
3232 "delete".to_owned(),
3233 ],
3234 ..Default::default()
3235 },
3236 ),
3237 (
3238 "delegator".to_owned(),
3239 RolePermissions {
3240 team: vec!["create".to_owned()],
3241 ac: vec!["create".to_owned(), "read".to_owned(), "update".to_owned()],
3242 ..Default::default()
3243 },
3244 ),
3245 (
3246 "auditor".to_owned(),
3247 RolePermissions {
3248 member: vec!["update".to_owned()],
3249 ..Default::default()
3250 },
3251 ),
3252 ("member".to_owned(), RolePermissions::default()),
3253 ]
3254 .into(),
3255 );
3256 config
3257 }
3258
3259 #[tokio::test]
3260 #[expect(
3261 clippy::panic_in_result_fn,
3262 reason = "Assertions report test failures; Result propagates setup and fixture errors"
3263 )]
3264 #[expect(
3265 clippy::too_many_lines,
3266 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
3267 )]
3268 async fn delegation_checks_each_grant_without_unioning_full_requests_and_revokes_api_key_authority()
3269 -> TestResult {
3270 let plugin = OrganizationPlugin::with_config(delegated_configuration());
3271 let ctx = configured_context(&plugin, create_test_config()).await?;
3272 let (_, owner) = actor(&ctx, "delegation-owner").await;
3273 let (delegate, delegate_session) = actor(&ctx, "delegation-member").await;
3274 let org = organization(&plugin, &ctx, &owner, "native-role-delegation").await?;
3275 let member = ctx
3276 .database
3277 .create_member(CreateMember {
3278 organization_id: org.clone(),
3279 user_id: delegate.id.clone(),
3280 role: "delegator,auditor".to_owned(),
3281 })
3282 .await?;
3283 let combined = call(
3284 &plugin,
3285 &ctx,
3286 Some(&delegate_session.token),
3287 HttpMethod::Post,
3288 "/organization/has-permission",
3289 Some(json!({"permissions":{"team":["create"],"member":["update"]}})),
3290 &[],
3291 )
3292 .await?;
3293 assert_error(&combined, 400, "NO_ACTIVE_ORGANIZATION")?;
3296 let combined_2 = call(
3297 &plugin,
3298 &ctx,
3299 Some(&delegate_session.token),
3300 HttpMethod::Post,
3301 "/organization/has-permission",
3302 Some(
3303 json!({"organizationId":org,"permissions":{"team":["create"],"member":["update"]}}),
3304 ),
3305 &[],
3306 )
3307 .await?;
3308 assert_eq!(
3309 body::<Value>(&combined_2)?,
3310 json!({"error":null,"success":false})
3311 );
3312 let denied = call(&plugin, &ctx, Some(&delegate_session.token), HttpMethod::Post,
3313 "/organization/create-role", Some(json!({"organizationId":org,"role":"escalated","permission":{"team":["delete","delete"],"apiKey":["create"]}})), &[]).await?;
3314 assert_error(&denied, 403, "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE")?;
3315 assert_eq!(
3316 (*(body::<Value>(&denied)?)
3317 .get("missingPermissions")
3318 .unwrap_or(&Value::Null)),
3319 json!(["team:delete", "team:delete", "apiKey:create"])
3320 );
3321 assert_eq!(ctx.database.count_organization_roles(&org).await?, 0);
3322 let allowed = call(&plugin, &ctx, Some(&delegate_session.token), HttpMethod::Post,
3323 "/organization/create-role", Some(json!({"organizationId":org,"role":"combined","permission":{"team":["create"],"member":["update"]}})), &[]).await?;
3324 assert_eq!(allowed.status, 200);
3325 assert_eq!(
3326 (*(body::<Value>(&allowed)?)
3327 .get("statements")
3328 .unwrap_or(&Value::Null)),
3329 json!({"team":["create"],"member":["update"]})
3330 );
3331
3332 let key_role = call(&plugin, &ctx, Some(&owner.token), HttpMethod::Post,
3333 "/organization/create-role", Some(json!({"organizationId":org,"role":"key-editor","permission":{"apiKey":["create","read"]}})), &[]).await?;
3334 assert_eq!(key_role.status, 200);
3335 let role_id = (*(*(body::<Value>(&key_role)?)
3336 .get("roleData")
3337 .unwrap_or(&Value::Null))
3338 .get("id")
3339 .unwrap_or(&Value::Null))
3340 .as_str()
3341 .ok_or("Role ID missing")?
3342 .to_owned();
3343 _ = ctx
3344 .database
3345 .update_member_role(&member.id, "key-editor")
3346 .await?;
3347 crate::helpers::require_org_api_key_permission(&ctx, &delegate.id, &org, "create").await?;
3348 crate::helpers::require_org_api_key_permission(&ctx, &delegate.id, &org, "read").await?;
3349 let error =
3350 crate::helpers::require_org_api_key_permission(&ctx, &delegate.id, &org, "delete")
3351 .await
3352 .err()
3353 .ok_or("Unowned key permission granted")?;
3354 assert_error(
3355 &error.to_auth_response(),
3356 403,
3357 "INSUFFICIENT_API_KEY_PERMISSIONS",
3358 )?;
3359 let changed = call(
3360 &plugin,
3361 &ctx,
3362 Some(&owner.token),
3363 HttpMethod::Post,
3364 "/organization/update-role",
3365 Some(
3366 json!({"organizationId":org,"roleId":role_id,"data":{"permission":{"apiKey":[]}}}),
3367 ),
3368 &[],
3369 )
3370 .await?;
3371 assert_eq!(changed.status, 200);
3372 let error_2 =
3373 crate::helpers::require_org_api_key_permission(&ctx, &delegate.id, &org, "read")
3374 .await
3375 .err()
3376 .ok_or("Revoked key permission granted")?;
3377 assert_error(
3378 &error_2.to_auth_response(),
3379 403,
3380 "INSUFFICIENT_API_KEY_PERMISSIONS",
3381 )?;
3382 _ = ctx
3383 .database
3384 .update_member_role(&member.id, " owner ")
3385 .await?;
3386 let error_3 =
3387 crate::helpers::require_org_api_key_permission(&ctx, &delegate.id, &org, "read")
3388 .await
3389 .err()
3390 .ok_or("Whitespace role bypassed creator check")?;
3391 assert_error(
3392 &error_3.to_auth_response(),
3393 403,
3394 "INSUFFICIENT_API_KEY_PERMISSIONS",
3395 )?;
3396 _ = ctx.database.update_member_role(&member.id, "owner").await?;
3397 crate::helpers::require_org_api_key_permission(&ctx, &delegate.id, &org, "delete").await?;
3398 Ok(())
3399 }
3400
3401 #[tokio::test]
3402 #[expect(
3403 clippy::panic_in_result_fn,
3404 reason = "Assertions report test failures; Result propagates setup and fixture errors"
3405 )]
3406 async fn callback_role_limits_are_scoped_and_count_rows_beyond_the_list_page() -> TestResult {
3407 let policies = std::sync::Arc::new(std::sync::Mutex::new(HashMap::new()));
3408 let mut config = configuration();
3409 config.dynamic_access_control.maximum_roles_per_organization = Some(99.0);
3410 config.dynamic_access_control.limit_resolver = Some(std::sync::Arc::new(RoleLimits(
3411 std::sync::Arc::clone(&policies),
3412 )));
3413 let plugin = OrganizationPlugin::with_config(config);
3414 let mut config_2 = create_test_config();
3415 config_2.advanced.database.default_find_many_limit = 1;
3416 let ctx = configured_context(&plugin, config_2).await?;
3417 let (_, owner) = actor(&ctx, "quota-owner").await;
3418 let first = organization(&plugin, &ctx, &owner, "native-role-quota-one").await?;
3419 let second = organization(&plugin, &ctx, &owner, "native-role-quota-two").await?;
3420 _ = policies
3421 .lock()
3422 .map_err(|_error| "Role policy unavailable")?
3423 .insert(first.clone(), 1);
3424 let _ignored_clone_2 = policies
3425 .lock()
3426 .map_err(|_error| "Role policy unavailable")?
3427 .insert(second.clone(), 2);
3428 for (org, names) in [(&first, vec!["one"]), (&second, vec!["one", "two"])] {
3429 for name in names {
3430 let created = call(
3431 &plugin,
3432 &ctx,
3433 Some(&owner.token),
3434 HttpMethod::Post,
3435 "/organization/create-role",
3436 Some(json!({"organizationId":org,"role":name,"permission":{}})),
3437 &[],
3438 )
3439 .await?;
3440 assert_eq!(created.status, 200);
3441 }
3442 let listed = call(
3443 &plugin,
3444 &ctx,
3445 Some(&owner.token),
3446 HttpMethod::Get,
3447 "/organization/list-roles",
3448 None,
3449 &[("organizationId", org)],
3450 )
3451 .await?;
3452 assert_eq!(body::<Vec<Value>>(&listed)?.len(), 1);
3453 let rejected = call(
3454 &plugin,
3455 &ctx,
3456 Some(&owner.token),
3457 HttpMethod::Post,
3458 "/organization/create-role",
3459 Some(json!({"organizationId":org,"role":"overflow","permission":{}})),
3460 &[],
3461 )
3462 .await?;
3463 assert_error(&rejected, 400, "TOO_MANY_ROLES")?;
3464 }
3465 assert_eq!(ctx.database.count_organization_roles(&first).await?, 1);
3466 assert_eq!(ctx.database.count_organization_roles(&second).await?, 2);
3467 let deleted = call(
3468 &plugin,
3469 &ctx,
3470 Some(&owner.token),
3471 HttpMethod::Post,
3472 "/organization/delete-role",
3473 Some(json!({"organizationId":second,"roleName":"two"})),
3474 &[],
3475 )
3476 .await?;
3477 assert_eq!(deleted.status, 200);
3478 let created = call(
3479 &plugin,
3480 &ctx,
3481 Some(&owner.token),
3482 HttpMethod::Post,
3483 "/organization/create-role",
3484 Some(json!({"organizationId":second,"role":"replacement","permission":{}})),
3485 &[],
3486 )
3487 .await?;
3488 assert_eq!(created.status, 200);
3489 assert_eq!(ctx.database.count_organization_roles(&second).await?, 2);
3490 Ok(())
3491 }
3492
3493 #[tokio::test]
3494 #[expect(
3495 clippy::panic_in_result_fn,
3496 reason = "Assertions report test failures; Result propagates setup and fixture errors"
3497 )]
3498 #[expect(
3499 clippy::too_many_lines,
3500 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
3501 )]
3502 async fn overlapping_permission_reloads_change_pending_delegation_only_in_the_selected_organization()
3503 -> TestResult {
3504 for refreshed_organization in [None, Some(false), Some(true)] {
3505 let policy = std::sync::Arc::new(PausingRoleLimits::default());
3506 let mut config = configuration();
3507 config.dynamic_access_control.limit_resolver =
3508 Some(std::sync::Arc::<PausingRoleLimits>::clone(&policy));
3509 let plugin = OrganizationPlugin::with_config(config);
3510 let ctx = configured_context(&plugin, create_test_config()).await?;
3511 let (_, owner) = actor(&ctx, "cache-owner").await;
3512 let (delegate, delegated_session) = actor(&ctx, "cache-delegate").await;
3513 let org = organization(&plugin, &ctx, &owner, "native-pending-role-cache").await?;
3514 let other = organization(&plugin, &ctx, &owner, "native-unrelated-role-cache").await?;
3515 let manager = call(
3516 &plugin,
3517 &ctx,
3518 Some(&owner.token),
3519 HttpMethod::Post,
3520 "/organization/create-role",
3521 Some(json!({"organizationId":org,"role":"manager","permission":{"ac":["create"],"team":["create"]}})),
3522 &[],
3523 ).await?;
3524 assert_eq!(manager.status, 200);
3525 let manager_id = (*(*(body::<Value>(&manager)?)
3526 .get("roleData")
3527 .unwrap_or(&Value::Null))
3528 .get("id")
3529 .unwrap_or(&Value::Null))
3530 .as_str()
3531 .ok_or("Manager role id missing")?
3532 .to_owned();
3533 ctx.database
3534 .create_member(CreateMember {
3535 organization_id: org.clone(),
3536 user_id: delegate.id,
3537 role: "manager".to_owned(),
3538 })
3539 .await?;
3540 policy
3541 .pause
3542 .store(true, std::sync::atomic::Ordering::SeqCst);
3543 let pending = call(
3544 &plugin,
3545 &ctx,
3546 Some(&delegated_session.token),
3547 HttpMethod::Post,
3548 "/organization/create-role",
3549 Some(
3550 json!({"organizationId":org,"role":"delegated","permission":{"team":["create"]}}),
3551 ),
3552 &[],
3553 );
3554 let revoke = async {
3555 policy.entered.notified().await;
3556 let changed = call(
3557 &plugin,
3558 &ctx,
3559 Some(&owner.token),
3560 HttpMethod::Post,
3561 "/organization/update-role",
3562 Some(json!({"organizationId":org,"roleId":manager_id,"data":{"permission":{"ac":["create"]}}})),
3563 &[],
3564 ).await?;
3565 assert_eq!(changed.status, 200);
3566 if let Some(same_organization) = refreshed_organization {
3567 let refreshed = call(
3568 &plugin,
3569 &ctx,
3570 Some(&owner.token),
3571 HttpMethod::Post,
3572 "/organization/has-permission",
3573 Some(json!({"organizationId":if same_organization { &org } else { &other },"permissions":{"team":["create"]}})),
3574 &[],
3575 ).await?;
3576 assert_eq!(refreshed.status, 200);
3577 assert_eq!(
3578 (*(body::<Value>(&refreshed)?)
3579 .get("success")
3580 .unwrap_or(&Value::Null)),
3581 true
3582 );
3583 }
3584 policy.release.notify_one();
3585 Ok::<_, Box<dyn std::error::Error>>(())
3586 };
3587 let (pending, revoked) =
3588 tokio::time::timeout(std::time::Duration::from_secs(10), async {
3589 tokio::join!(pending, revoke)
3590 })
3591 .await?;
3592 revoked?;
3593 let pending = pending?;
3594 if refreshed_organization == Some(true) {
3595 assert_error(&pending, 403, "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE")?;
3596 assert_eq!(
3597 (*(body::<Value>(&pending)?)
3598 .get("missingPermissions")
3599 .unwrap_or(&Value::Null)),
3600 json!(["team:create"])
3601 );
3602 } else {
3603 assert_eq!(pending.status, 200);
3604 }
3605 let manager_2 = ctx
3606 .database
3607 .get_organization_role(&org, &OrganizationRoleSelector::Id(manager_id))
3608 .await?
3609 .ok_or("Updated manager role missing")?;
3610 assert_eq!(
3611 serde_json::from_str::<OrganizationPermissions>(manager_2.permission.as_str())?,
3612 permission("ac", "create")
3613 );
3614 let created = ctx
3615 .database
3616 .get_organization_role(
3617 &org,
3618 &OrganizationRoleSelector::Name("delegated".to_owned()),
3619 )
3620 .await?;
3621 if refreshed_organization == Some(true) {
3622 assert!(created.is_none());
3623 assert_eq!(ctx.database.count_organization_roles(&org).await?, 1);
3624 } else {
3625 assert_eq!(
3626 serde_json::from_str::<OrganizationPermissions>(
3627 created.ok_or("Delegated role missing")?.permission.as_str()
3628 )?,
3629 permission("team", "create")
3630 );
3631 assert_eq!(ctx.database.count_organization_roles(&org).await?, 2);
3632 }
3633 assert_eq!(ctx.database.count_organization_roles(&other).await?, 0);
3634 }
3635 Ok(())
3636 }
3637
3638 #[tokio::test]
3639 #[expect(
3640 clippy::panic_in_result_fn,
3641 reason = "Assertions report test failures; Result propagates setup and fixture errors"
3642 )]
3643 #[expect(
3644 clippy::too_many_lines,
3645 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
3646 )]
3647 async fn default_role_validation_collects_ordered_issues_before_authorization_and_persistence()
3648 -> TestResult {
3649 let plugin = OrganizationPlugin::with_config(configuration());
3650 let ctx = configured_context(&plugin, create_test_config()).await?;
3651 let (_, owner) = actor(&ctx, "default-validation-owner").await;
3652 let org = organization(&plugin, &ctx, &owner, "native-default-role-validation").await?;
3653 for (transfer_encoding, length, bytes, code, message) in [
3654 (
3655 Some("chunked"),
3656 None,
3657 None,
3658 "VALIDATION_ERROR",
3659 "[body] Invalid input: expected object, received null",
3660 ),
3661 (
3662 None,
3663 Some("0"),
3664 None,
3665 "VALIDATION_ERROR",
3666 "[body] Invalid input: expected object, received undefined",
3667 ),
3668 (
3669 None,
3670 None,
3671 None,
3672 "VALIDATION_ERROR",
3673 "[body] Invalid input: expected object, received undefined",
3674 ),
3675 (
3676 None,
3677 None,
3678 Some(Vec::new()),
3679 "BAD_REQUEST",
3680 "Invalid JSON in request body",
3681 ),
3682 (
3683 None,
3684 None,
3685 Some(b"{malformed".to_vec()),
3686 "BAD_REQUEST",
3687 "Invalid JSON in request body",
3688 ),
3689 ] {
3690 let mut request = AuthRequest::new(HttpMethod::Post, "/organization/create-role");
3691 _ = request
3692 .headers
3693 .insert("content-type".to_owned(), "application/json".to_owned());
3694 if let Some(value) = transfer_encoding {
3695 _ = request
3696 .headers
3697 .insert("transfer-encoding".to_owned(), value.to_owned());
3698 }
3699 if let Some(value) = length {
3700 _ = request
3701 .headers
3702 .insert("content-length".to_owned(), value.to_owned());
3703 }
3704 request.body = bytes;
3705 let response = plugin
3706 .on_request(&request, &ctx)
3707 .await?
3708 .ok_or("Role route missing")?;
3709 assert_error(&response, 400, code)?;
3710 assert_eq!(
3711 (*(body::<Value>(&response)?)
3712 .get("message")
3713 .unwrap_or(&Value::Null)),
3714 message
3715 );
3716 assert_eq!(ctx.database.count_organization_roles(&org).await?, 0);
3717 }
3718 for (path, input, expected) in [
3719 (
3720 "/organization/create-role",
3721 json!({"role":"extra","permission":{},"additionalFields":null}),
3722 "[body.additionalFields] Invalid input: expected object, received null",
3723 ),
3724 (
3725 "/organization/create-role",
3726 json!({"role":"extra","permission":{},"additionalFields":1}),
3727 "[body.additionalFields] Invalid input: expected object, received number",
3728 ),
3729 (
3730 "/organization/create-role",
3731 json!({"role":"extra","permission":{},"additionalFields":[]}),
3732 "[body.additionalFields] Invalid input: expected object, received array",
3733 ),
3734 (
3735 "/organization/create-role",
3736 json!({"organizationId":null,"role":1,"permission":null,"additionalFields":1}),
3737 "[body.organizationId] Invalid input: expected string, received null; [body.role] Invalid input: expected string, received number; [body.permission] Invalid input: expected record, received null; [body.additionalFields] Invalid input: expected object, received number",
3738 ),
3739 (
3740 "/organization/create-role",
3741 json!({"role":"extra","permission":{"team":[null,1],"member":1}}),
3742 "[body.permission.team.0] Invalid input: expected string, received null; [body.permission.team.1] Invalid input: expected string, received number; [body.permission.member] Invalid input: expected array, received number",
3743 ),
3744 (
3745 "/organization/create-role",
3746 json!({"role":"extra","permission":{"2":[null],"1":[false],"team":[1]}}),
3747 "[body.permission.1.0] Invalid input: expected string, received boolean; [body.permission.2.0] Invalid input: expected string, received null; [body.permission.team.0] Invalid input: expected string, received number",
3748 ),
3749 (
3750 "/organization/update-role",
3751 json!({"organizationId":1,"roleName":"","data":{"permission":null,"roleName":1}}),
3752 "[body.organizationId] Invalid input: expected string, received number; [body.data.permission] Invalid input: expected record, received null; [body.data.roleName] Invalid input: expected string, received number; [body.roleName] Too small: expected string to have >=1 characters",
3753 ),
3754 (
3755 "/organization/has-permission",
3756 json!({"organizationId":null,"permissions":null,"permission":null}),
3757 "[body.organizationId] Invalid input: expected string, received null; [body] Invalid input",
3758 ),
3759 ] {
3760 for token in [Some(owner.token.as_str()), None] {
3761 let response = call(
3762 &plugin,
3763 &ctx,
3764 token,
3765 HttpMethod::Post,
3766 path,
3767 Some(input.clone()),
3768 &[],
3769 )
3770 .await?;
3771 assert_error(&response, 400, "VALIDATION_ERROR")?;
3772 assert_eq!(
3773 (*(body::<Value>(&response)?)
3774 .get("message")
3775 .unwrap_or(&Value::Null)),
3776 expected
3777 );
3778 assert_eq!(ctx.database.count_organization_roles(&org).await?, 0);
3779 }
3780 }
3781 let created = call(
3782 &plugin,
3783 &ctx,
3784 Some(&owner.token),
3785 HttpMethod::Post,
3786 "/organization/create-role",
3787 Some(json!({"role":"ΟΣ","permission":{},"additionalFields":{"ignored":"value"}})),
3788 &[],
3789 )
3790 .await?;
3791 assert_eq!(created.status, 200);
3792 let value = body::<Value>(&created)?;
3793 assert_eq!(
3794 (*(*(value).get("roleData").unwrap_or(&Value::Null))
3795 .get("role")
3796 .unwrap_or(&Value::Null)),
3797 "ος"
3798 );
3799 assert!(
3800 (*(value).get("roleData").unwrap_or(&Value::Null))
3801 .get("ignored")
3802 .is_none()
3803 );
3804 assert_eq!(
3805 (ctx.database.list_organization_roles(&org).await?)
3806 .first()
3807 .expect("fixture contains the requested index")
3808 .role,
3809 "ος"
3810 );
3811 let duplicate = call(
3812 &plugin,
3813 &ctx,
3814 Some(&owner.token),
3815 HttpMethod::Post,
3816 "/organization/create-role",
3817 Some(json!({"role":"ος","permission":{}})),
3818 &[],
3819 )
3820 .await?;
3821 assert_error(&duplicate, 400, "ROLE_NAME_IS_ALREADY_TAKEN")?;
3822 assert_eq!(ctx.database.count_organization_roles(&org).await?, 1);
3823 Ok(())
3824 }
3825
3826 #[tokio::test]
3827 #[expect(
3828 clippy::panic_in_result_fn,
3829 reason = "Assertions report test failures; Result propagates setup and fixture errors"
3830 )]
3831 async fn expired_and_revoked_role_sessions_cannot_mutate_existing_permissions() -> TestResult {
3832 let plugin = OrganizationPlugin::with_config(configuration());
3833 let ctx = configured_context(&plugin, create_test_config()).await?;
3834 let (user, owner) = actor(&ctx, "session-role-owner").await;
3835 let org = organization(&plugin, &ctx, &owner, "native-role-session-guards").await?;
3836 let role = ctx
3837 .database
3838 .create_organization_role(CreateOrganizationRole {
3839 organization_id: org.clone(),
3840 role: "retained".to_owned(),
3841 permission: permission("team", "create"),
3842 })
3843 .await?;
3844 for expired in [true, false] {
3845 let session = ctx
3846 .session_manager()
3847 .create_session(&user, None, None)
3848 .await?;
3849 if expired {
3850 ctx.database
3851 .update_session_expiry(
3852 &session.token,
3853 chrono::Utc::now() - chrono::Duration::seconds(1),
3854 )
3855 .await?;
3856 } else {
3857 ctx.database.delete_session(&session.token).await?;
3858 }
3859 let denied = call(&plugin, &ctx, Some(&session.token), HttpMethod::Post, "/organization/update-role",
3860 Some(json!({"organizationId":org,"roleId":role.id,"data":{"permission":{"team":["delete"]}}})), &[]).await?;
3861 assert_error(&denied, 401, "UNAUTHORIZED")?;
3862 let persisted = ctx
3863 .database
3864 .get_organization_role(&org, &OrganizationRoleSelector::Id(role.id.clone()))
3865 .await?
3866 .ok_or("Role missing")?;
3867 assert_eq!(
3868 serde_json::from_str::<OrganizationPermissions>(persisted.permission.as_str())?,
3869 permission("team", "create")
3870 );
3871 assert_eq!(persisted.updated_at, None);
3872 assert_eq!(ctx.database.count_organization_roles(&org).await?, 1);
3873 }
3874 let allowed = call(&plugin, &ctx, Some(&owner.token), HttpMethod::Post, "/organization/update-role",
3875 Some(json!({"organizationId":org,"roleId":role.id,"data":{"permission":{"team":["update"]}}})), &[]).await?;
3876 assert_eq!(allowed.status, 200);
3877 assert_eq!(
3878 serde_json::from_str::<OrganizationPermissions>(
3879 ctx.database
3880 .get_organization_role(&org, &OrganizationRoleSelector::Id(role.id))
3881 .await?
3882 .ok_or("Role missing")?
3883 .permission
3884 .as_str()
3885 )?,
3886 permission("team", "update")
3887 );
3888 Ok(())
3889 }
3890}
3891