Skip to main content

alibi_plugins/organization/
mod.rs

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