Skip to main content

alibi_plugins/organization/
mod.rs

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