Skip to main content

alibi_plugins/organization/
mod.rs

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