Skip to main content

alibi_plugins/organization/
mod.rs

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