Skip to main content

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