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