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