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