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