Skip to main content

lenso_organization_postgres_plugin/
lib.rs

1//! PostgreSQL-backed Organization behavior for Lenso vNext.
2
3mod operator;
4mod schema;
5
6use std::{cell::RefCell, fmt, fmt::Write as _, rc::Rc, time::Duration};
7
8use lenso_capability_organization_admin::{
9    CreateOrganizationError, CreateOrganizationRequest, CreateOrganizationResponse,
10    OrganizationAdmin, OrganizationAdminEndpoint, OrganizationAdminProvider,
11};
12use lenso_capability_organization_directory::{
13    GetOrganizationError, GetOrganizationRequest, GetOrganizationResponse, OrganizationDirectory,
14    OrganizationDirectoryEndpoint, OrganizationDirectoryProvider,
15};
16use lenso_capability_organization_membership::{
17    CheckMembershipError, CheckMembershipRequest, CheckMembershipResponse, OrganizationMembership,
18    OrganizationMembershipEndpoint, OrganizationMembershipProvider,
19};
20use lenso_capability_organization_membership_admin::{
21    AddMemberError, AddMemberRequest, AddMemberResponse, OrganizationMembershipAdminAddMember,
22    OrganizationMembershipAdminEndpoint, OrganizationMembershipAdminProvider,
23    OrganizationMembershipAdminRemoveMember, RemoveMemberError, RemoveMemberRequest,
24    RemoveMemberResponse,
25};
26use lenso_capability_secrets::{ResolveRequest, SecretsClient, SecretsInvocationError};
27use lenso_kernel::{
28    DeactivateContext, InvocationContext, NativeRequestEndpoint, NativeRequestFuture, PluginFuture,
29    PluginLifecycle, PrepareContext, RuntimeFailure,
30};
31use lenso_native_adapter::{NativePluginFactory, NativePluginFactoryContext, NativePluginInstance};
32use lenso_postgres_kit::OwnedPostgres;
33use serde::{Deserialize, Serialize};
34use sqlx::Row;
35use thiserror::Error;
36use zeroize::Zeroizing;
37
38use crate::schema::schema_plan;
39
40pub use operator::{OrganizationOperator, OrganizationOperatorError};
41
42pub const PACKAGE_ID: &str = "lenso.organization.postgres";
43pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
44const DEPENDENCY_TIMEOUT: Duration = Duration::from_secs(10);
45#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
46#[serde(deny_unknown_fields)]
47pub struct OrganizationConfig {
48    schema: String,
49    database_url_secret: String,
50    #[serde(default)]
51    admin_callers: Vec<String>,
52    #[serde(default)]
53    directory_callers: Vec<String>,
54    #[serde(default)]
55    membership_admin_callers: Vec<String>,
56}
57
58impl OrganizationConfig {
59    pub fn new(
60        schema: impl Into<String>,
61        database_url_secret: impl Into<String>,
62        admin_callers: Vec<String>,
63    ) -> Result<Self, OrganizationConfigError> {
64        let value = Self {
65            schema: schema.into(),
66            database_url_secret: database_url_secret.into(),
67            admin_callers,
68            directory_callers: Vec::new(),
69            membership_admin_callers: Vec::new(),
70        };
71        value.validate()?;
72        Ok(value)
73    }
74
75    pub fn with_membership_admin_callers(
76        mut self,
77        membership_admin_callers: Vec<String>,
78    ) -> Result<Self, OrganizationConfigError> {
79        self.membership_admin_callers = membership_admin_callers;
80        self.validate()?;
81        Ok(self)
82    }
83
84    pub fn with_directory_callers(
85        mut self,
86        directory_callers: Vec<String>,
87    ) -> Result<Self, OrganizationConfigError> {
88        self.directory_callers = directory_callers;
89        self.validate()?;
90        Ok(self)
91    }
92
93    fn validate(&self) -> Result<(), OrganizationConfigError> {
94        schema_plan(self.schema.clone()).map_err(|_| OrganizationConfigError::InvalidSchema)?;
95        if !valid_secret_reference(&self.database_url_secret) {
96            return Err(OrganizationConfigError::InvalidSecretReference);
97        }
98        if self.admin_callers.is_empty()
99            || self
100                .admin_callers
101                .iter()
102                .any(|value| !valid_name(value, 256))
103        {
104            return Err(OrganizationConfigError::InvalidAdminCallers);
105        }
106        if self
107            .directory_callers
108            .iter()
109            .any(|value| !valid_name(value, 256))
110        {
111            return Err(OrganizationConfigError::InvalidDirectoryCallers);
112        }
113        if self
114            .membership_admin_callers
115            .iter()
116            .any(|value| !valid_name(value, 256))
117        {
118            return Err(OrganizationConfigError::InvalidMembershipAdminCallers);
119        }
120        Ok(())
121    }
122}
123
124#[derive(Clone, Debug, Error, Eq, PartialEq)]
125pub enum OrganizationConfigError {
126    #[error("invalid owned PostgreSQL schema")]
127    InvalidSchema,
128    #[error("invalid database URL secret reference")]
129    InvalidSecretReference,
130    #[error("at least one valid Organization Admin caller is required")]
131    InvalidAdminCallers,
132    #[error("every Organization Directory caller must be a valid Instance key")]
133    InvalidDirectoryCallers,
134    #[error("every Organization Membership Admin caller must be a valid Instance key")]
135    InvalidMembershipAdminCallers,
136}
137
138#[derive(Clone, Copy, Debug, Default)]
139pub struct OrganizationFactory;
140
141impl NativePluginFactory for OrganizationFactory {
142    fn package_id(&self) -> &'static str {
143        PACKAGE_ID
144    }
145
146    fn package_version(&self) -> &'static str {
147        PACKAGE_VERSION
148    }
149
150    fn instantiate(
151        &self,
152        context: NativePluginFactoryContext<'_>,
153    ) -> Result<NativePluginInstance, RuntimeFailure> {
154        if context.entrypoint() != "default" {
155            return Err(RuntimeFailure::InvalidResolvedPlan {
156                detail: format!(
157                    "unsupported Organization entrypoint `{}`",
158                    context.entrypoint()
159                ),
160            });
161        }
162        let config: OrganizationConfig =
163            serde_json::from_str(context.configuration()).map_err(|error| {
164                RuntimeFailure::InvalidResolvedPlan {
165                    detail: format!("Organization configuration is invalid: {error}"),
166                }
167            })?;
168        config
169            .validate()
170            .map_err(|error| RuntimeFailure::InvalidResolvedPlan {
171                detail: error.to_string(),
172            })?;
173
174        let state = Rc::new(RefCell::new(None));
175        let provider = OrganizationProvider {
176            state: state.clone(),
177            admin_callers: config.admin_callers.clone(),
178            directory_callers: config.directory_callers.clone(),
179            membership_admin_callers: config.membership_admin_callers.clone(),
180        };
181        let endpoints: Vec<Rc<dyn NativeRequestEndpoint>> = vec![
182            Rc::new(OrganizationAdminEndpoint::new(provider.clone())),
183            Rc::new(OrganizationDirectoryEndpoint::new(provider.clone())),
184            Rc::new(OrganizationMembershipEndpoint::new(provider.clone())),
185            Rc::new(OrganizationMembershipAdminEndpoint::new(provider)),
186        ];
187        Ok(NativePluginInstance::with_lifecycle(
188            endpoints,
189            OrganizationLifecycle { config, state },
190        ))
191    }
192}
193
194#[derive(Clone)]
195struct PreparedOrganization {
196    postgres: OwnedPostgres,
197}
198
199impl fmt::Debug for PreparedOrganization {
200    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201        formatter
202            .debug_struct("PreparedOrganization")
203            .field("schema", &self.postgres.schema())
204            .finish()
205    }
206}
207
208#[derive(Clone)]
209struct OrganizationProvider {
210    state: Rc<RefCell<Option<PreparedOrganization>>>,
211    admin_callers: Vec<String>,
212    directory_callers: Vec<String>,
213    membership_admin_callers: Vec<String>,
214}
215
216impl fmt::Debug for OrganizationProvider {
217    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
218        formatter
219            .debug_struct("OrganizationProvider")
220            .field("prepared", &self.state.borrow().is_some())
221            .field("admin_caller_count", &self.admin_callers.len())
222            .field("directory_caller_count", &self.directory_callers.len())
223            .field(
224                "membership_admin_caller_count",
225                &self.membership_admin_callers.len(),
226            )
227            .finish()
228    }
229}
230
231impl OrganizationProvider {
232    fn prepared(&self) -> Result<PreparedOrganization, RuntimeFailure> {
233        self.state
234            .borrow()
235            .clone()
236            .ok_or(RuntimeFailure::PluginFailure {
237                detail: "Organization Plugin is not prepared".to_owned(),
238            })
239    }
240
241    fn authorized_admin_caller<'a>(&self, context: &'a InvocationContext) -> Option<&'a str> {
242        context
243            .caller_instance()
244            .filter(|caller| self.admin_callers.iter().any(|allowed| allowed == *caller))
245    }
246
247    fn authorized_membership_admin_caller<'a>(
248        &self,
249        context: &'a InvocationContext,
250    ) -> Option<&'a str> {
251        context.caller_instance().filter(|caller| {
252            self.membership_admin_callers
253                .iter()
254                .any(|allowed| allowed == *caller)
255        })
256    }
257
258    fn authorized_directory_caller<'a>(&self, context: &'a InvocationContext) -> Option<&'a str> {
259        context.caller_instance().filter(|caller| {
260            self.directory_callers
261                .iter()
262                .any(|allowed| allowed == *caller)
263        })
264    }
265}
266
267impl OrganizationAdminProvider for OrganizationProvider {
268    fn create_organization(
269        &self,
270        context: InvocationContext,
271        request: CreateOrganizationRequest,
272    ) -> NativeRequestFuture<OrganizationAdmin> {
273        let caller_instance = self.authorized_admin_caller(&context).map(str::to_owned);
274        let prepared = self.prepared();
275        Box::pin(async move {
276            let Some(caller_instance) = caller_instance else {
277                return Ok(Err(CreateOrganizationError::Forbidden));
278            };
279            let name = request.name.trim().to_owned();
280            if !valid_name(&request.idempotency_key, 256)
281                || !valid_organization_name(&request.name)
282                || !valid_slug(&request.slug)
283                || !valid_name(&request.owner_subject, 256)
284            {
285                return Ok(Err(CreateOrganizationError::InvalidOrganization));
286            }
287            let prepared = prepared?;
288            create_organization_in_postgres(prepared, caller_instance, request, name).await
289        })
290    }
291}
292
293impl OrganizationMembershipAdminProvider for OrganizationProvider {
294    fn add_member(
295        &self,
296        context: InvocationContext,
297        request: AddMemberRequest,
298    ) -> NativeRequestFuture<OrganizationMembershipAdminAddMember> {
299        let caller_instance = self
300            .authorized_membership_admin_caller(&context)
301            .map(str::to_owned);
302        let prepared = self.prepared();
303        Box::pin(async move {
304            let Some(caller_instance) = caller_instance else {
305                return Ok(Err(AddMemberError::Forbidden));
306            };
307            if !valid_membership_request(
308                &request.idempotency_key,
309                &request.organization_id,
310                &request.subject,
311            ) {
312                return Ok(Err(AddMemberError::InvalidRequest));
313            }
314            let prepared = prepared?;
315            add_member_in_postgres(prepared, caller_instance, request).await
316        })
317    }
318
319    fn remove_member(
320        &self,
321        context: InvocationContext,
322        request: RemoveMemberRequest,
323    ) -> NativeRequestFuture<OrganizationMembershipAdminRemoveMember> {
324        let caller_instance = self
325            .authorized_membership_admin_caller(&context)
326            .map(str::to_owned);
327        let prepared = self.prepared();
328        Box::pin(async move {
329            let Some(caller_instance) = caller_instance else {
330                return Ok(Err(RemoveMemberError::Forbidden));
331            };
332            if !valid_membership_request(
333                &request.idempotency_key,
334                &request.organization_id,
335                &request.subject,
336            ) {
337                return Ok(Err(RemoveMemberError::InvalidRequest));
338            }
339            let prepared = prepared?;
340            remove_member_in_postgres(prepared, caller_instance, request).await
341        })
342    }
343}
344
345impl OrganizationDirectoryProvider for OrganizationProvider {
346    fn get_organization(
347        &self,
348        context: InvocationContext,
349        request: GetOrganizationRequest,
350    ) -> NativeRequestFuture<OrganizationDirectory> {
351        let authorized = self.authorized_directory_caller(&context).is_some();
352        let prepared = self.prepared();
353        Box::pin(async move {
354            if !authorized {
355                return Ok(Err(GetOrganizationError::Forbidden));
356            }
357            if !valid_name(&request.organization_id, 256) {
358                return Ok(Err(GetOrganizationError::InvalidRequest));
359            }
360            let prepared = prepared?;
361            let row: Option<(String, String, bool, i64)> = sqlx::query_as(
362                "SELECT name,slug,archived_at IS NULL,revision FROM organizations WHERE organization_id=$1",
363            )
364            .bind(&request.organization_id)
365            .fetch_optional(prepared.postgres.pool())
366            .await
367            .map_err(|source| {
368                runtime(OrganizationError::Database {
369                    operation: "get organization directory entry",
370                    source,
371                })
372            })?;
373            let Some((name, slug, active, revision)) = row else {
374                return Ok(Err(GetOrganizationError::OrganizationNotFound));
375            };
376            Ok(Ok(GetOrganizationResponse {
377                active,
378                name,
379                organization_id: request.organization_id,
380                revision: revision.to_string(),
381                slug,
382            }))
383        })
384    }
385}
386
387#[derive(Debug)]
388enum MembershipCommandReplay {
389    Exact {
390        membership_id: String,
391        revision: i64,
392    },
393    Conflict,
394}
395
396async fn add_member_in_postgres(
397    prepared: PreparedOrganization,
398    caller_instance: String,
399    request: AddMemberRequest,
400) -> Result<Result<AddMemberResponse, AddMemberError>, RuntimeFailure> {
401    let generated_membership_id = random_id("member_").map_err(runtime)?;
402    let mut transaction = prepared
403        .postgres
404        .pool()
405        .begin()
406        .await
407        .map_err(|source| database_runtime("begin member addition", source))?;
408    let reserved = reserve_membership_command(
409        &mut transaction,
410        &caller_instance,
411        &request.idempotency_key,
412        "add_member",
413        &request.organization_id,
414        &request.subject,
415    )
416    .await?;
417    if !reserved {
418        let replay = read_membership_command_replay(
419            &mut transaction,
420            &caller_instance,
421            &request.idempotency_key,
422            "add_member",
423            &request.organization_id,
424            &request.subject,
425        )
426        .await?;
427        let MembershipCommandReplay::Exact {
428            membership_id,
429            revision,
430        } = replay
431        else {
432            return Ok(Err(AddMemberError::IdempotencyConflict));
433        };
434        transaction
435            .commit()
436            .await
437            .map_err(|source| database_runtime("commit member addition replay", source))?;
438        return Ok(Ok(AddMemberResponse {
439            created: false,
440            membership_id,
441            revision: revision.to_string(),
442        }));
443    }
444    if !lock_active_organization(&mut transaction, &request.organization_id).await? {
445        return Ok(Err(AddMemberError::OrganizationNotFound));
446    }
447    let existing: Option<(String, i64)> = sqlx::query_as(
448        "SELECT membership_id,revision FROM organization_memberships WHERE organization_id=$1 AND subject=$2 AND removed_at IS NULL FOR UPDATE",
449    )
450    .bind(&request.organization_id)
451    .bind(&request.subject)
452    .fetch_optional(&mut *transaction)
453    .await
454    .map_err(|source| {
455        runtime(OrganizationError::Database {
456            operation: "read active member for addition",
457            source,
458        })
459    })?;
460    let (membership_id, revision, created) = if let Some((membership_id, revision)) = existing {
461        (membership_id, revision, false)
462    } else {
463        sqlx::query(
464            "INSERT INTO organization_memberships (membership_id,organization_id,subject,is_owner,revision) VALUES ($1,$2,$3,false,1)",
465        )
466        .bind(&generated_membership_id)
467        .bind(&request.organization_id)
468        .bind(&request.subject)
469        .execute(&mut *transaction)
470        .await
471        .map_err(|source| {
472            runtime(OrganizationError::Database {
473                operation: "insert organization member",
474                source,
475            })
476        })?;
477        (generated_membership_id, 1, true)
478    };
479    complete_membership_command(
480        &mut transaction,
481        &caller_instance,
482        &request.idempotency_key,
483        &membership_id,
484        revision,
485        created,
486    )
487    .await?;
488    transaction
489        .commit()
490        .await
491        .map_err(|source| database_runtime("commit member addition", source))?;
492    Ok(Ok(AddMemberResponse {
493        created,
494        membership_id,
495        revision: revision.to_string(),
496    }))
497}
498
499async fn remove_member_in_postgres(
500    prepared: PreparedOrganization,
501    caller_instance: String,
502    request: RemoveMemberRequest,
503) -> Result<Result<RemoveMemberResponse, RemoveMemberError>, RuntimeFailure> {
504    let mut transaction = prepared
505        .postgres
506        .pool()
507        .begin()
508        .await
509        .map_err(|source| database_runtime("begin member removal", source))?;
510    let reserved = reserve_membership_command(
511        &mut transaction,
512        &caller_instance,
513        &request.idempotency_key,
514        "remove_member",
515        &request.organization_id,
516        &request.subject,
517    )
518    .await?;
519    if !reserved {
520        let replay = read_membership_command_replay(
521            &mut transaction,
522            &caller_instance,
523            &request.idempotency_key,
524            "remove_member",
525            &request.organization_id,
526            &request.subject,
527        )
528        .await?;
529        let MembershipCommandReplay::Exact {
530            membership_id,
531            revision,
532        } = replay
533        else {
534            return Ok(Err(RemoveMemberError::IdempotencyConflict));
535        };
536        transaction
537            .commit()
538            .await
539            .map_err(|source| database_runtime("commit member removal replay", source))?;
540        return Ok(Ok(RemoveMemberResponse {
541            membership_id,
542            removed: false,
543            revision: revision.to_string(),
544        }));
545    }
546    if !lock_active_organization(&mut transaction, &request.organization_id).await? {
547        return Ok(Err(RemoveMemberError::OrganizationNotFound));
548    }
549    let existing: Option<(String, bool, i64)> = sqlx::query_as(
550        "SELECT membership_id,is_owner,revision FROM organization_memberships WHERE organization_id=$1 AND subject=$2 AND removed_at IS NULL FOR UPDATE",
551    )
552    .bind(&request.organization_id)
553    .bind(&request.subject)
554    .fetch_optional(&mut *transaction)
555    .await
556    .map_err(|source| {
557        runtime(OrganizationError::Database {
558            operation: "read active member for removal",
559            source,
560        })
561    })?;
562    let Some((membership_id, is_owner, revision)) = existing else {
563        return Ok(Err(RemoveMemberError::MembershipNotFound));
564    };
565    if is_owner {
566        return Ok(Err(RemoveMemberError::OwnerProtected));
567    }
568    let next_revision = next_membership_revision(revision)?;
569    sqlx::query(
570        "UPDATE organization_memberships SET removed_at=transaction_timestamp(),updated_at=transaction_timestamp(),revision=$3 WHERE organization_id=$1 AND membership_id=$2 AND removed_at IS NULL",
571    )
572    .bind(&request.organization_id)
573    .bind(&membership_id)
574    .bind(next_revision)
575    .execute(&mut *transaction)
576    .await
577    .map_err(|source| {
578        runtime(OrganizationError::Database {
579            operation: "remove organization member",
580            source,
581        })
582    })?;
583    complete_membership_command(
584        &mut transaction,
585        &caller_instance,
586        &request.idempotency_key,
587        &membership_id,
588        next_revision,
589        true,
590    )
591    .await?;
592    transaction
593        .commit()
594        .await
595        .map_err(|source| database_runtime("commit member removal", source))?;
596    Ok(Ok(RemoveMemberResponse {
597        membership_id,
598        removed: true,
599        revision: next_revision.to_string(),
600    }))
601}
602
603async fn reserve_membership_command(
604    transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
605    caller_instance: &str,
606    idempotency_key: &str,
607    operation: &str,
608    organization_id: &str,
609    subject: &str,
610) -> Result<bool, RuntimeFailure> {
611    sqlx::query(
612        "INSERT INTO organization_membership_commands (caller_instance,idempotency_key,operation,organization_id,subject) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (caller_instance,idempotency_key) DO NOTHING",
613    )
614    .bind(caller_instance)
615    .bind(idempotency_key)
616    .bind(operation)
617    .bind(organization_id)
618    .bind(subject)
619    .execute(&mut **transaction)
620    .await
621    .map(|result| result.rows_affected() == 1)
622    .map_err(|source| {
623        runtime(OrganizationError::Database {
624            operation: "reserve membership command",
625            source,
626        })
627    })
628}
629
630async fn read_membership_command_replay(
631    transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
632    caller_instance: &str,
633    idempotency_key: &str,
634    operation: &str,
635    organization_id: &str,
636    subject: &str,
637) -> Result<MembershipCommandReplay, RuntimeFailure> {
638    let row: (
639        String,
640        String,
641        String,
642        Option<String>,
643        Option<i64>,
644        Option<bool>,
645    ) = sqlx::query_as(
646        "SELECT operation,organization_id,subject,membership_id,result_revision,changed FROM organization_membership_commands WHERE caller_instance=$1 AND idempotency_key=$2 FOR UPDATE",
647    )
648    .bind(caller_instance)
649    .bind(idempotency_key)
650    .fetch_one(&mut **transaction)
651    .await
652    .map_err(|source| {
653        runtime(OrganizationError::Database {
654            operation: "read membership command replay",
655            source,
656        })
657    })?;
658    if row.0 != operation || row.1 != organization_id || row.2 != subject {
659        return Ok(MembershipCommandReplay::Conflict);
660    }
661    let (Some(membership_id), Some(revision), Some(_changed)) = (row.3, row.4, row.5) else {
662        return Err(runtime(OrganizationError::Invariant {
663            detail: "committed membership command has no result",
664        }));
665    };
666    Ok(MembershipCommandReplay::Exact {
667        membership_id,
668        revision,
669    })
670}
671
672async fn complete_membership_command(
673    transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
674    caller_instance: &str,
675    idempotency_key: &str,
676    membership_id: &str,
677    revision: i64,
678    changed: bool,
679) -> Result<(), RuntimeFailure> {
680    sqlx::query(
681        "UPDATE organization_membership_commands SET membership_id=$3,result_revision=$4,changed=$5,completed_at=transaction_timestamp() WHERE caller_instance=$1 AND idempotency_key=$2 AND completed_at IS NULL",
682    )
683    .bind(caller_instance)
684    .bind(idempotency_key)
685    .bind(membership_id)
686    .bind(revision)
687    .bind(changed)
688    .execute(&mut **transaction)
689    .await
690    .and_then(|result| {
691        if result.rows_affected() == 1 {
692            Ok(result)
693        } else {
694            Err(sqlx::Error::RowNotFound)
695        }
696    })
697    .map(|_| ())
698    .map_err(|source| {
699        runtime(OrganizationError::Database {
700            operation: "complete membership command",
701            source,
702        })
703    })
704}
705
706async fn lock_active_organization(
707    transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
708    organization_id: &str,
709) -> Result<bool, RuntimeFailure> {
710    sqlx::query_scalar::<_, bool>(
711        "SELECT archived_at IS NULL FROM organizations WHERE organization_id=$1 FOR UPDATE",
712    )
713    .bind(organization_id)
714    .fetch_optional(&mut **transaction)
715    .await
716    .map(|value| value.unwrap_or(false))
717    .map_err(|source| {
718        runtime(OrganizationError::Database {
719            operation: "lock organization for membership command",
720            source,
721        })
722    })
723}
724
725async fn create_organization_in_postgres(
726    prepared: PreparedOrganization,
727    caller_instance: String,
728    request: CreateOrganizationRequest,
729    name: String,
730) -> Result<Result<CreateOrganizationResponse, CreateOrganizationError>, RuntimeFailure> {
731    let organization_id = random_id("org_").map_err(runtime)?;
732    let owner_membership_id = random_id("member_").map_err(runtime)?;
733    let mut transaction = prepared.postgres.pool().begin().await.map_err(|source| {
734        runtime(OrganizationError::Database {
735            operation: "begin organization creation",
736            source,
737        })
738    })?;
739    if !reserve_creation(
740        &mut transaction,
741        &caller_instance,
742        &request,
743        &name,
744        &organization_id,
745        &owner_membership_id,
746    )
747    .await?
748    {
749        let response = match read_creation_replay(
750            &mut transaction,
751            &caller_instance,
752            &request,
753            &name,
754        )
755        .await?
756        {
757            Ok(response) => response,
758            Err(error) => return Ok(Err(error)),
759        };
760        transaction.commit().await.map_err(|source| {
761            runtime(OrganizationError::Database {
762                operation: "commit organization creation replay",
763                source,
764            })
765        })?;
766        return Ok(Ok(response));
767    }
768    if let Err(error) = insert_organization_and_owner(
769        &mut transaction,
770        &request,
771        &name,
772        &organization_id,
773        &owner_membership_id,
774    )
775    .await?
776    {
777        return Ok(Err(error));
778    }
779    transaction.commit().await.map_err(|source| {
780        runtime(OrganizationError::Database {
781            operation: "commit organization creation",
782            source,
783        })
784    })?;
785    Ok(Ok(CreateOrganizationResponse {
786        created: true,
787        organization_id,
788        owner_membership_id,
789    }))
790}
791
792async fn reserve_creation(
793    transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
794    caller_instance: &str,
795    request: &CreateOrganizationRequest,
796    name: &str,
797    organization_id: &str,
798    owner_membership_id: &str,
799) -> Result<bool, RuntimeFailure> {
800    sqlx::query(
801        "INSERT INTO organization_creation_requests (caller_instance,idempotency_key,name,slug,owner_subject,organization_id,owner_membership_id) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (caller_instance,idempotency_key) DO NOTHING",
802    )
803    .bind(caller_instance)
804    .bind(&request.idempotency_key)
805    .bind(name)
806    .bind(&request.slug)
807    .bind(&request.owner_subject)
808    .bind(organization_id)
809    .bind(owner_membership_id)
810    .execute(&mut **transaction)
811    .await
812    .map(|result| result.rows_affected() == 1)
813    .map_err(|source| {
814        runtime(OrganizationError::Database {
815            operation: "reserve organization creation",
816            source,
817        })
818    })
819}
820
821async fn read_creation_replay(
822    transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
823    caller_instance: &str,
824    request: &CreateOrganizationRequest,
825    name: &str,
826) -> Result<Result<CreateOrganizationResponse, CreateOrganizationError>, RuntimeFailure> {
827    let (stored_name, stored_slug, stored_owner, organization_id, owner_membership_id): (
828        String,
829        String,
830        String,
831        String,
832        String,
833    ) = sqlx::query_as(
834        "SELECT name,slug,owner_subject,organization_id,owner_membership_id FROM organization_creation_requests WHERE caller_instance=$1 AND idempotency_key=$2",
835    )
836    .bind(caller_instance)
837    .bind(&request.idempotency_key)
838    .fetch_one(&mut **transaction)
839    .await
840    .map_err(|source| {
841        runtime(OrganizationError::Database {
842            operation: "read organization creation replay",
843            source,
844        })
845    })?;
846    if stored_name != name || stored_slug != request.slug || stored_owner != request.owner_subject {
847        return Ok(Err(CreateOrganizationError::IdempotencyConflict));
848    }
849    Ok(Ok(CreateOrganizationResponse {
850        created: false,
851        organization_id,
852        owner_membership_id,
853    }))
854}
855
856async fn insert_organization_and_owner(
857    transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
858    request: &CreateOrganizationRequest,
859    name: &str,
860    organization_id: &str,
861    owner_membership_id: &str,
862) -> Result<Result<(), CreateOrganizationError>, RuntimeFailure> {
863    let inserted =
864        sqlx::query("INSERT INTO organizations (organization_id,name,slug) VALUES ($1,$2,$3)")
865            .bind(organization_id)
866            .bind(name)
867            .bind(&request.slug)
868            .execute(&mut **transaction)
869            .await;
870    if let Err(error) = inserted {
871        if error
872            .as_database_error()
873            .and_then(|database| database.constraint())
874            == Some("organizations_active_slug_key")
875        {
876            return Ok(Err(CreateOrganizationError::SlugConflict));
877        }
878        return Err(runtime(OrganizationError::Database {
879            operation: "insert organization",
880            source: error,
881        }));
882    }
883    sqlx::query("INSERT INTO organization_memberships (membership_id,organization_id,subject,is_owner) VALUES ($1,$2,$3,true)")
884        .bind(owner_membership_id)
885        .bind(organization_id)
886        .bind(&request.owner_subject)
887        .execute(&mut **transaction)
888        .await
889        .map_err(|source| runtime(OrganizationError::Database { operation: "insert owner membership", source }))?;
890    Ok(Ok(()))
891}
892
893impl OrganizationMembershipProvider for OrganizationProvider {
894    fn check_membership(
895        &self,
896        _context: InvocationContext,
897        request: CheckMembershipRequest,
898    ) -> NativeRequestFuture<OrganizationMembership> {
899        let prepared = self.prepared();
900        Box::pin(async move {
901            if !valid_name(&request.organization_id, 256) || !valid_name(&request.subject, 256) {
902                return Ok(Err(CheckMembershipError::InvalidRequest));
903            }
904            let prepared = prepared?;
905            let row = sqlx::query(
906                "SELECT EXISTS(SELECT 1 FROM organizations WHERE organization_id=$1 AND archived_at IS NULL) AS organization_exists, COALESCE((SELECT removed_at IS NULL FROM organization_memberships WHERE organization_id=$1 AND subject=$2 ORDER BY created_at DESC LIMIT 1),false) AS active, COALESCE((SELECT is_owner AND removed_at IS NULL FROM organization_memberships WHERE organization_id=$1 AND subject=$2 ORDER BY created_at DESC LIMIT 1),false) AS owner",
907            )
908            .bind(&request.organization_id)
909            .bind(&request.subject)
910            .fetch_one(prepared.postgres.pool())
911            .await
912            .map_err(|source| runtime(OrganizationError::Database { operation: "check organization membership", source }))?;
913            let organization_exists: bool =
914                row.try_get("organization_exists").map_err(|source| {
915                    runtime(OrganizationError::Database {
916                        operation: "decode organization existence",
917                        source,
918                    })
919                })?;
920            if !organization_exists {
921                return Ok(Err(CheckMembershipError::OrganizationNotFound));
922            }
923            let active = row.try_get("active").map_err(|source| {
924                runtime(OrganizationError::Database {
925                    operation: "decode organization membership",
926                    source,
927                })
928            })?;
929            let owner = row.try_get("owner").map_err(|source| {
930                runtime(OrganizationError::Database {
931                    operation: "decode organization ownership",
932                    source,
933                })
934            })?;
935            Ok(Ok(CheckMembershipResponse { active, owner }))
936        })
937    }
938}
939
940#[derive(Debug)]
941struct OrganizationLifecycle {
942    config: OrganizationConfig,
943    state: Rc<RefCell<Option<PreparedOrganization>>>,
944}
945
946impl PluginLifecycle for OrganizationLifecycle {
947    fn prepare(&self, context: PrepareContext) -> PluginFuture {
948        let config = self.config.clone();
949        let state = self.state.clone();
950        let dependencies = context.dependencies().clone();
951        let cancellation = context.cancellation();
952        Box::pin(async move {
953            let secrets = SecretsClient::from_dependencies(&dependencies)?;
954            let invocation =
955                dependencies.invocation_context_after(DEPENDENCY_TIMEOUT, cancellation)?;
956            let database_url = secrets
957                .resolve_with_context(
958                    invocation,
959                    ResolveRequest {
960                        reference: config.database_url_secret.clone(),
961                    },
962                )
963                .await
964                .map_err(|error| match error {
965                    SecretsInvocationError::Domain(_) => RuntimeFailure::PluginFailure {
966                        detail: format!(
967                            "database URL secret `{}` was rejected",
968                            config.database_url_secret
969                        ),
970                    },
971                    SecretsInvocationError::Runtime(error) => error,
972                })?;
973            let database_url = Zeroizing::new(database_url.value);
974            let postgres = OwnedPostgres::prepare(
975                &database_url,
976                schema_plan(config.schema).map_err(|error| {
977                    RuntimeFailure::InvalidResolvedPlan {
978                        detail: error.to_string(),
979                    }
980                })?,
981            )
982            .await
983            .map_err(|error| RuntimeFailure::PluginFailure {
984                detail: error.to_string(),
985            })?;
986            state.replace(Some(PreparedOrganization { postgres }));
987            Ok(())
988        })
989    }
990
991    fn deactivate(&self, _context: DeactivateContext) -> PluginFuture {
992        let prepared = self.state.borrow_mut().take();
993        Box::pin(async move {
994            if let Some(prepared) = prepared {
995                prepared.postgres.pool().close().await;
996            }
997            Ok(())
998        })
999    }
1000}
1001
1002#[derive(Debug, Error)]
1003enum OrganizationError {
1004    #[error("PostgreSQL operation `{operation}` failed")]
1005    Database {
1006        operation: &'static str,
1007        #[source]
1008        source: sqlx::Error,
1009    },
1010    #[error("random source unavailable")]
1011    Random,
1012    #[error("Organization invariant failed: {detail}")]
1013    Invariant { detail: &'static str },
1014}
1015
1016fn runtime(error: impl fmt::Display) -> RuntimeFailure {
1017    RuntimeFailure::PluginFailure {
1018        detail: error.to_string(),
1019    }
1020}
1021
1022fn database_runtime(operation: &'static str, source: sqlx::Error) -> RuntimeFailure {
1023    runtime(OrganizationError::Database { operation, source })
1024}
1025
1026fn next_membership_revision(revision: i64) -> Result<i64, RuntimeFailure> {
1027    revision.checked_add(1).ok_or_else(|| {
1028        runtime(OrganizationError::Invariant {
1029            detail: "membership revision overflow",
1030        })
1031    })
1032}
1033
1034fn random_id(prefix: &str) -> Result<String, OrganizationError> {
1035    let mut bytes = [0_u8; 18];
1036    getrandom::fill(&mut bytes).map_err(|_| OrganizationError::Random)?;
1037    let mut id = String::with_capacity(prefix.len() + bytes.len() * 2);
1038    id.push_str(prefix);
1039    for byte in bytes {
1040        write!(&mut id, "{byte:02x}").expect("writing to String cannot fail");
1041    }
1042    Ok(id)
1043}
1044
1045fn valid_organization_name(value: &str) -> bool {
1046    let value = value.trim();
1047    !value.is_empty() && value.len() <= 200 && !value.chars().any(char::is_control)
1048}
1049
1050fn valid_slug(value: &str) -> bool {
1051    !value.is_empty()
1052        && value.len() <= 100
1053        && value
1054            .bytes()
1055            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
1056        && !value.starts_with('-')
1057        && !value.ends_with('-')
1058}
1059
1060fn valid_name(value: &str, max: usize) -> bool {
1061    !value.is_empty()
1062        && value.len() <= max
1063        && value
1064            .bytes()
1065            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':'))
1066}
1067
1068fn valid_membership_request(idempotency_key: &str, organization_id: &str, subject: &str) -> bool {
1069    valid_name(idempotency_key, 256) && valid_name(organization_id, 256) && valid_name(subject, 256)
1070}
1071
1072fn valid_secret_reference(reference: &str) -> bool {
1073    !reference.is_empty()
1074        && reference.len() <= 256
1075        && !reference.starts_with('/')
1076        && !reference.ends_with('/')
1077        && !reference.contains("//")
1078        && reference
1079            .split('/')
1080            .all(|segment| segment != "." && segment != "..")
1081        && reference
1082            .bytes()
1083            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/'))
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088    use super::*;
1089    use lenso_kernel::CancellationToken;
1090    use lenso_postgres_kit::{Migration, SchemaOperator, SchemaPlan};
1091    use sqlx::{AssertSqlSafe, Executor};
1092
1093    const LEGACY_MIGRATIONS: &[Migration] = &[Migration::new(
1094        1,
1095        "create-organizations",
1096        include_str!("../migrations/001_create_organizations.sql"),
1097    )];
1098
1099    fn legacy_schema_plan(schema: impl Into<std::sync::Arc<str>>) -> SchemaPlan {
1100        SchemaPlan::new(schema, LEGACY_MIGRATIONS).unwrap()
1101    }
1102
1103    #[test]
1104    fn configuration_rejects_ambient_admin_authority() {
1105        let error = OrganizationConfig::new("organization", "organization/database", Vec::new())
1106            .unwrap_err();
1107        assert_eq!(error, OrganizationConfigError::InvalidAdminCallers);
1108    }
1109
1110    #[test]
1111    fn configuration_rejects_invalid_directory_caller_keys() {
1112        let error = OrganizationConfig::new(
1113            "organization",
1114            "organization/database",
1115            vec!["business-admin".to_owned()],
1116        )
1117        .unwrap()
1118        .with_directory_callers(vec!["invalid caller".to_owned()])
1119        .unwrap_err();
1120        assert_eq!(error, OrganizationConfigError::InvalidDirectoryCallers);
1121    }
1122
1123    #[test]
1124    fn slugs_are_stable_and_narrow() {
1125        assert!(valid_slug("acme-platform"));
1126        assert!(!valid_slug("Acme Platform"));
1127        assert!(!valid_slug("-acme"));
1128    }
1129
1130    #[test]
1131    fn generated_ids_do_not_repeat() {
1132        assert_ne!(random_id("org_").unwrap(), random_id("org_").unwrap());
1133    }
1134
1135    #[tokio::test]
1136    async fn forbidden_admin_is_a_domain_error_before_storage_access() {
1137        let provider = OrganizationProvider {
1138            state: Rc::new(RefCell::new(None)),
1139            admin_callers: vec!["business-admin".to_owned()],
1140            directory_callers: Vec::new(),
1141            membership_admin_callers: Vec::new(),
1142        };
1143        let context = InvocationContext::new(1, None, CancellationToken::new())
1144            .with_caller_instance("untrusted");
1145        let result = provider
1146            .create_organization(
1147                context,
1148                CreateOrganizationRequest {
1149                    idempotency_key: "forbidden-create".to_owned(),
1150                    name: "Acme".to_owned(),
1151                    owner_subject: "usr_owner".to_owned(),
1152                    slug: "acme".to_owned(),
1153                },
1154            )
1155            .await
1156            .unwrap();
1157        assert_eq!(result, Err(CreateOrganizationError::Forbidden));
1158    }
1159
1160    #[tokio::test]
1161    async fn invalid_idempotency_key_is_rejected_before_storage_access() {
1162        let provider = OrganizationProvider {
1163            state: Rc::new(RefCell::new(None)),
1164            admin_callers: vec!["business-admin".to_owned()],
1165            directory_callers: Vec::new(),
1166            membership_admin_callers: Vec::new(),
1167        };
1168        let context = InvocationContext::new(1, None, CancellationToken::new())
1169            .with_caller_instance("business-admin");
1170        let result = provider
1171            .create_organization(
1172                context,
1173                CreateOrganizationRequest {
1174                    idempotency_key: String::new(),
1175                    name: "Acme".to_owned(),
1176                    owner_subject: "usr_owner".to_owned(),
1177                    slug: "acme".to_owned(),
1178                },
1179            )
1180            .await
1181            .unwrap();
1182        assert_eq!(result, Err(CreateOrganizationError::InvalidOrganization));
1183    }
1184
1185    #[tokio::test]
1186    async fn forbidden_membership_admin_is_a_domain_error_before_storage_access() {
1187        let provider = OrganizationProvider {
1188            state: Rc::new(RefCell::new(None)),
1189            admin_callers: vec!["business-admin".to_owned()],
1190            directory_callers: Vec::new(),
1191            membership_admin_callers: vec!["membership-admin".to_owned()],
1192        };
1193        let context = InvocationContext::new(1, None, CancellationToken::new())
1194            .with_caller_instance("untrusted");
1195
1196        let add = provider
1197            .add_member(
1198                context.clone(),
1199                AddMemberRequest {
1200                    idempotency_key: "add-member".to_owned(),
1201                    organization_id: "org_acme".to_owned(),
1202                    subject: "usr_member".to_owned(),
1203                },
1204            )
1205            .await
1206            .unwrap();
1207        assert_eq!(add, Err(AddMemberError::Forbidden));
1208
1209        let remove = provider
1210            .remove_member(
1211                context,
1212                RemoveMemberRequest {
1213                    idempotency_key: "remove-member".to_owned(),
1214                    organization_id: "org_acme".to_owned(),
1215                    subject: "usr_member".to_owned(),
1216                },
1217            )
1218            .await
1219            .unwrap();
1220        assert_eq!(remove, Err(RemoveMemberError::Forbidden));
1221    }
1222
1223    #[tokio::test]
1224    async fn invalid_membership_admin_request_is_rejected_before_storage_access() {
1225        let provider = OrganizationProvider {
1226            state: Rc::new(RefCell::new(None)),
1227            admin_callers: vec!["business-admin".to_owned()],
1228            directory_callers: Vec::new(),
1229            membership_admin_callers: vec!["membership-admin".to_owned()],
1230        };
1231        let context = InvocationContext::new(1, None, CancellationToken::new())
1232            .with_caller_instance("membership-admin");
1233
1234        let add = provider
1235            .add_member(
1236                context.clone(),
1237                AddMemberRequest {
1238                    idempotency_key: String::new(),
1239                    organization_id: "org_acme".to_owned(),
1240                    subject: "usr_member".to_owned(),
1241                },
1242            )
1243            .await
1244            .unwrap();
1245        assert_eq!(add, Err(AddMemberError::InvalidRequest));
1246
1247        let remove = provider
1248            .remove_member(
1249                context,
1250                RemoveMemberRequest {
1251                    idempotency_key: "remove-member".to_owned(),
1252                    organization_id: "org_acme".to_owned(),
1253                    subject: "invalid subject".to_owned(),
1254                },
1255            )
1256            .await
1257            .unwrap();
1258        assert_eq!(remove, Err(RemoveMemberError::InvalidRequest));
1259    }
1260
1261    #[tokio::test]
1262    async fn directory_authorization_and_validation_happen_before_storage_access() {
1263        let provider = OrganizationProvider {
1264            state: Rc::new(RefCell::new(None)),
1265            admin_callers: vec!["business-admin".to_owned()],
1266            directory_callers: vec!["directory-consumer".to_owned()],
1267            membership_admin_callers: Vec::new(),
1268        };
1269        let request = GetOrganizationRequest {
1270            organization_id: "org_acme".to_owned(),
1271        };
1272        let forbidden = provider
1273            .get_organization(
1274                InvocationContext::new(1, None, CancellationToken::new())
1275                    .with_caller_instance("untrusted"),
1276                request,
1277            )
1278            .await
1279            .unwrap();
1280        assert_eq!(forbidden, Err(GetOrganizationError::Forbidden));
1281
1282        let invalid = provider
1283            .get_organization(
1284                InvocationContext::new(2, None, CancellationToken::new())
1285                    .with_caller_instance("directory-consumer"),
1286                GetOrganizationRequest {
1287                    organization_id: String::new(),
1288                },
1289            )
1290            .await
1291            .unwrap();
1292        assert_eq!(invalid, Err(GetOrganizationError::InvalidRequest));
1293    }
1294
1295    #[tokio::test]
1296    async fn unprepared_membership_reports_runtime_failure() {
1297        let provider = OrganizationProvider {
1298            state: Rc::new(RefCell::new(None)),
1299            admin_callers: vec!["business-admin".to_owned()],
1300            directory_callers: Vec::new(),
1301            membership_admin_callers: Vec::new(),
1302        };
1303        let result = provider
1304            .check_membership(
1305                InvocationContext::new(1, None, CancellationToken::new()),
1306                CheckMembershipRequest {
1307                    organization_id: "org_missing".to_owned(),
1308                    subject: "usr_owner".to_owned(),
1309                },
1310            )
1311            .await;
1312        assert!(matches!(result, Err(RuntimeFailure::PluginFailure { .. })));
1313    }
1314
1315    #[tokio::test]
1316    #[ignore = "requires LENSO_POSTGRES_TEST_URL"]
1317    #[allow(
1318        clippy::too_many_lines,
1319        reason = "the acceptance scenario keeps concurrent creation and every replay boundary together"
1320    )]
1321    async fn concurrent_create_is_caller_scoped_idempotent_and_preserves_ownership() {
1322        let database_url =
1323            std::env::var("LENSO_POSTGRES_TEST_URL").expect("LENSO_POSTGRES_TEST_URL is required");
1324        let schema = random_id("organization_test_").unwrap();
1325        OrganizationOperator::setup(&database_url, &schema)
1326            .await
1327            .unwrap();
1328        let postgres = OwnedPostgres::prepare(&database_url, schema_plan(schema.clone()).unwrap())
1329            .await
1330            .unwrap();
1331        let provider = OrganizationProvider {
1332            state: Rc::new(RefCell::new(Some(PreparedOrganization { postgres }))),
1333            admin_callers: vec!["business-admin".to_owned(), "second-admin".to_owned()],
1334            directory_callers: vec!["directory-consumer".to_owned()],
1335            membership_admin_callers: vec!["membership-admin".to_owned()],
1336        };
1337        let admin_context = InvocationContext::new(1, None, CancellationToken::new())
1338            .with_caller_instance("business-admin");
1339        let create_request = CreateOrganizationRequest {
1340            idempotency_key: "create-acme".to_owned(),
1341            name: "Acme".to_owned(),
1342            owner_subject: "usr_owner".to_owned(),
1343            slug: "acme".to_owned(),
1344        };
1345        let first_creation =
1346            provider.create_organization(admin_context.clone(), create_request.clone());
1347        let concurrent_replay = provider.create_organization(
1348            InvocationContext::new(2, None, CancellationToken::new())
1349                .with_caller_instance("business-admin"),
1350            create_request.clone(),
1351        );
1352        let (first_creation, concurrent_replay) = tokio::join!(first_creation, concurrent_replay);
1353        let first_creation = first_creation.unwrap().unwrap();
1354        let concurrent_replay = concurrent_replay.unwrap().unwrap();
1355        assert_ne!(first_creation.created, concurrent_replay.created);
1356        assert_eq!(
1357            first_creation.organization_id,
1358            concurrent_replay.organization_id
1359        );
1360        assert_eq!(
1361            first_creation.owner_membership_id,
1362            concurrent_replay.owner_membership_id
1363        );
1364        let created = if first_creation.created {
1365            first_creation
1366        } else {
1367            concurrent_replay
1368        };
1369        let membership = provider
1370            .check_membership(
1371                InvocationContext::new(3, None, CancellationToken::new()),
1372                CheckMembershipRequest {
1373                    organization_id: created.organization_id.clone(),
1374                    subject: "usr_owner".to_owned(),
1375                },
1376            )
1377            .await
1378            .unwrap()
1379            .unwrap();
1380        assert!(membership.active);
1381        assert!(membership.owner);
1382
1383        let replay = provider
1384            .create_organization(admin_context.clone(), create_request.clone())
1385            .await
1386            .unwrap()
1387            .unwrap();
1388        assert!(!replay.created);
1389        assert_eq!(replay.organization_id, created.organization_id);
1390        assert_eq!(replay.owner_membership_id, created.owner_membership_id);
1391
1392        let conflict = provider
1393            .create_organization(
1394                admin_context.clone(),
1395                CreateOrganizationRequest {
1396                    owner_subject: "usr_other".to_owned(),
1397                    ..create_request
1398                },
1399            )
1400            .await
1401            .unwrap();
1402        assert_eq!(conflict, Err(CreateOrganizationError::IdempotencyConflict));
1403
1404        let second_caller = provider
1405            .create_organization(
1406                InvocationContext::new(4, None, CancellationToken::new())
1407                    .with_caller_instance("second-admin"),
1408                CreateOrganizationRequest {
1409                    idempotency_key: "create-acme".to_owned(),
1410                    name: "Second".to_owned(),
1411                    owner_subject: "usr_second".to_owned(),
1412                    slug: "second".to_owned(),
1413                },
1414            )
1415            .await
1416            .unwrap()
1417            .unwrap();
1418        assert!(second_caller.created);
1419        assert_ne!(second_caller.organization_id, created.organization_id);
1420
1421        let duplicate = provider
1422            .create_organization(
1423                admin_context,
1424                CreateOrganizationRequest {
1425                    idempotency_key: "create-another-acme".to_owned(),
1426                    name: "Another Acme".to_owned(),
1427                    owner_subject: "usr_other".to_owned(),
1428                    slug: "acme".to_owned(),
1429                },
1430            )
1431            .await
1432            .unwrap();
1433        assert_eq!(duplicate, Err(CreateOrganizationError::SlugConflict));
1434
1435        let cleanup_pool = sqlx::PgPool::connect(&database_url).await.unwrap();
1436        cleanup_pool
1437            .execute(AssertSqlSafe(format!("DROP SCHEMA \"{schema}\" CASCADE")))
1438            .await
1439            .unwrap();
1440        cleanup_pool.close().await;
1441    }
1442
1443    #[tokio::test]
1444    #[ignore = "requires LENSO_POSTGRES_TEST_URL"]
1445    #[allow(
1446        clippy::too_many_lines,
1447        reason = "the acceptance scenario keeps membership concurrency, replay, and owner protection together"
1448    )]
1449    async fn membership_admin_is_caller_scoped_idempotent_and_owner_safe() {
1450        let database_url =
1451            std::env::var("LENSO_POSTGRES_TEST_URL").expect("LENSO_POSTGRES_TEST_URL is required");
1452        let schema = random_id("org_member_test_").unwrap();
1453        OrganizationOperator::setup(&database_url, &schema)
1454            .await
1455            .unwrap();
1456        let postgres = OwnedPostgres::prepare(&database_url, schema_plan(schema.clone()).unwrap())
1457            .await
1458            .unwrap();
1459        let provider = OrganizationProvider {
1460            state: Rc::new(RefCell::new(Some(PreparedOrganization { postgres }))),
1461            admin_callers: vec!["business-admin".to_owned()],
1462            directory_callers: vec!["directory-consumer".to_owned()],
1463            membership_admin_callers: vec![
1464                "membership-admin".to_owned(),
1465                "second-membership-admin".to_owned(),
1466            ],
1467        };
1468        let organization = provider
1469            .create_organization(
1470                InvocationContext::new(1, None, CancellationToken::new())
1471                    .with_caller_instance("business-admin"),
1472                CreateOrganizationRequest {
1473                    idempotency_key: "create-membership-test".to_owned(),
1474                    name: "Membership Test".to_owned(),
1475                    owner_subject: "usr_owner".to_owned(),
1476                    slug: "membership-test".to_owned(),
1477                },
1478            )
1479            .await
1480            .unwrap()
1481            .unwrap();
1482        let directory_entry = provider
1483            .get_organization(
1484                InvocationContext::new(2, None, CancellationToken::new())
1485                    .with_caller_instance("directory-consumer"),
1486                GetOrganizationRequest {
1487                    organization_id: organization.organization_id.clone(),
1488                },
1489            )
1490            .await
1491            .unwrap()
1492            .unwrap();
1493        assert!(directory_entry.active);
1494        assert_eq!(directory_entry.name, "Membership Test");
1495        assert_eq!(directory_entry.slug, "membership-test");
1496        assert_eq!(directory_entry.revision, "1");
1497
1498        let missing_directory_entry = provider
1499            .get_organization(
1500                InvocationContext::new(3, None, CancellationToken::new())
1501                    .with_caller_instance("directory-consumer"),
1502                GetOrganizationRequest {
1503                    organization_id: "org_missing".to_owned(),
1504                },
1505            )
1506            .await
1507            .unwrap();
1508        assert_eq!(
1509            missing_directory_entry,
1510            Err(GetOrganizationError::OrganizationNotFound)
1511        );
1512        let add_request = AddMemberRequest {
1513            idempotency_key: "add-primary-member".to_owned(),
1514            organization_id: organization.organization_id.clone(),
1515            subject: "usr_member".to_owned(),
1516        };
1517        let first_add = provider.add_member(
1518            InvocationContext::new(4, None, CancellationToken::new())
1519                .with_caller_instance("membership-admin"),
1520            add_request.clone(),
1521        );
1522        let concurrent_replay = provider.add_member(
1523            InvocationContext::new(5, None, CancellationToken::new())
1524                .with_caller_instance("membership-admin"),
1525            add_request.clone(),
1526        );
1527        let (first_add, concurrent_replay) = tokio::join!(first_add, concurrent_replay);
1528        let first_add = first_add.unwrap().unwrap();
1529        let concurrent_replay = concurrent_replay.unwrap().unwrap();
1530        assert_ne!(first_add.created, concurrent_replay.created);
1531        assert_eq!(first_add.membership_id, concurrent_replay.membership_id);
1532        assert_eq!(first_add.revision, "1");
1533        assert_eq!(concurrent_replay.revision, "1");
1534
1535        let active = provider
1536            .check_membership(
1537                InvocationContext::new(4, None, CancellationToken::new()),
1538                CheckMembershipRequest {
1539                    organization_id: organization.organization_id.clone(),
1540                    subject: "usr_member".to_owned(),
1541                },
1542            )
1543            .await
1544            .unwrap()
1545            .unwrap();
1546        assert!(active.active);
1547        assert!(!active.owner);
1548
1549        let conflict = provider
1550            .add_member(
1551                InvocationContext::new(5, None, CancellationToken::new())
1552                    .with_caller_instance("membership-admin"),
1553                AddMemberRequest {
1554                    subject: "usr_other".to_owned(),
1555                    ..add_request.clone()
1556                },
1557            )
1558            .await
1559            .unwrap();
1560        assert_eq!(conflict, Err(AddMemberError::IdempotencyConflict));
1561
1562        let second_caller = provider
1563            .add_member(
1564                InvocationContext::new(6, None, CancellationToken::new())
1565                    .with_caller_instance("second-membership-admin"),
1566                AddMemberRequest {
1567                    subject: "usr_second".to_owned(),
1568                    ..add_request.clone()
1569                },
1570            )
1571            .await
1572            .unwrap()
1573            .unwrap();
1574        assert!(second_caller.created);
1575
1576        let owner_removal = provider
1577            .remove_member(
1578                InvocationContext::new(7, None, CancellationToken::new())
1579                    .with_caller_instance("membership-admin"),
1580                RemoveMemberRequest {
1581                    idempotency_key: "remove-owner".to_owned(),
1582                    organization_id: organization.organization_id.clone(),
1583                    subject: "usr_owner".to_owned(),
1584                },
1585            )
1586            .await
1587            .unwrap();
1588        assert_eq!(owner_removal, Err(RemoveMemberError::OwnerProtected));
1589
1590        let remove_request = RemoveMemberRequest {
1591            idempotency_key: "remove-primary-member".to_owned(),
1592            organization_id: organization.organization_id.clone(),
1593            subject: "usr_member".to_owned(),
1594        };
1595        let removal = provider
1596            .remove_member(
1597                InvocationContext::new(8, None, CancellationToken::new())
1598                    .with_caller_instance("membership-admin"),
1599                remove_request.clone(),
1600            )
1601            .await
1602            .unwrap()
1603            .unwrap();
1604        assert!(removal.removed);
1605        assert_eq!(removal.membership_id, first_add.membership_id);
1606        assert_eq!(removal.revision, "2");
1607
1608        let replay = provider
1609            .remove_member(
1610                InvocationContext::new(9, None, CancellationToken::new())
1611                    .with_caller_instance("membership-admin"),
1612                remove_request,
1613            )
1614            .await
1615            .unwrap()
1616            .unwrap();
1617        assert!(!replay.removed);
1618        assert_eq!(replay.membership_id, first_add.membership_id);
1619        assert_eq!(replay.revision, "2");
1620
1621        let inactive = provider
1622            .check_membership(
1623                InvocationContext::new(10, None, CancellationToken::new()),
1624                CheckMembershipRequest {
1625                    organization_id: organization.organization_id.clone(),
1626                    subject: "usr_member".to_owned(),
1627                },
1628            )
1629            .await
1630            .unwrap()
1631            .unwrap();
1632        assert!(!inactive.active);
1633        assert!(!inactive.owner);
1634
1635        let prepared = provider.prepared().unwrap();
1636        sqlx::query(
1637            "UPDATE organizations SET archived_at=transaction_timestamp(),revision=2 WHERE organization_id=$1",
1638        )
1639        .bind(&organization.organization_id)
1640        .execute(prepared.postgres.pool())
1641        .await
1642        .unwrap();
1643        let archived_directory_entry = provider
1644            .get_organization(
1645                InvocationContext::new(11, None, CancellationToken::new())
1646                    .with_caller_instance("directory-consumer"),
1647                GetOrganizationRequest {
1648                    organization_id: organization.organization_id.clone(),
1649                },
1650            )
1651            .await
1652            .unwrap()
1653            .unwrap();
1654        assert!(!archived_directory_entry.active);
1655        assert_eq!(archived_directory_entry.revision, "2");
1656
1657        let archived_replay = provider
1658            .add_member(
1659                InvocationContext::new(12, None, CancellationToken::new())
1660                    .with_caller_instance("membership-admin"),
1661                add_request.clone(),
1662            )
1663            .await
1664            .unwrap()
1665            .unwrap();
1666        assert!(!archived_replay.created);
1667        assert_eq!(archived_replay.membership_id, first_add.membership_id);
1668        assert_eq!(archived_replay.revision, "1");
1669
1670        let archived_new_command = provider
1671            .add_member(
1672                InvocationContext::new(13, None, CancellationToken::new())
1673                    .with_caller_instance("membership-admin"),
1674                AddMemberRequest {
1675                    idempotency_key: "archived-new-command".to_owned(),
1676                    organization_id: organization.organization_id.clone(),
1677                    subject: "usr_after_archive".to_owned(),
1678                },
1679            )
1680            .await
1681            .unwrap();
1682        assert_eq!(
1683            archived_new_command,
1684            Err(AddMemberError::OrganizationNotFound)
1685        );
1686
1687        let operation_conflict = provider
1688            .remove_member(
1689                InvocationContext::new(14, None, CancellationToken::new())
1690                    .with_caller_instance("membership-admin"),
1691                RemoveMemberRequest {
1692                    idempotency_key: add_request.idempotency_key,
1693                    organization_id: organization.organization_id.clone(),
1694                    subject: add_request.subject,
1695                },
1696            )
1697            .await
1698            .unwrap();
1699        assert_eq!(
1700            operation_conflict,
1701            Err(RemoveMemberError::IdempotencyConflict)
1702        );
1703
1704        let missing_organization = provider
1705            .add_member(
1706                InvocationContext::new(15, None, CancellationToken::new())
1707                    .with_caller_instance("membership-admin"),
1708                AddMemberRequest {
1709                    idempotency_key: "missing-organization".to_owned(),
1710                    organization_id: "org_missing".to_owned(),
1711                    subject: "usr_member".to_owned(),
1712                },
1713            )
1714            .await
1715            .unwrap();
1716        assert_eq!(
1717            missing_organization,
1718            Err(AddMemberError::OrganizationNotFound)
1719        );
1720
1721        prepared.postgres.pool().close().await;
1722        let cleanup_pool = sqlx::PgPool::connect(&database_url).await.unwrap();
1723        cleanup_pool
1724            .execute(AssertSqlSafe(format!("DROP SCHEMA \"{schema}\" CASCADE")))
1725            .await
1726            .unwrap();
1727        cleanup_pool.close().await;
1728    }
1729
1730    #[tokio::test]
1731    #[ignore = "requires LENSO_POSTGRES_TEST_URL"]
1732    async fn upgrade_projects_legacy_owner_and_rejects_an_ownerless_active_organization() {
1733        let database_url =
1734            std::env::var("LENSO_POSTGRES_TEST_URL").expect("LENSO_POSTGRES_TEST_URL is required");
1735        let schema = random_id("organization_upgrade_test_").unwrap();
1736        SchemaOperator::connect(&database_url, legacy_schema_plan(schema.clone()))
1737            .await
1738            .unwrap()
1739            .setup()
1740            .await
1741            .unwrap();
1742        let legacy = OwnedPostgres::prepare(&database_url, legacy_schema_plan(schema.clone()))
1743            .await
1744            .unwrap();
1745
1746        sqlx::query("INSERT INTO organizations (organization_id,name,slug) VALUES ('org_good','Good','good'),('org_bad','Bad','bad')")
1747            .execute(legacy.pool())
1748            .await
1749            .unwrap();
1750        sqlx::query("INSERT INTO organization_roles (role_id,organization_id,name,permissions,system_key) VALUES ('role_good','org_good','Owner',ARRAY['organization.read'],'owner'),('role_bad','org_bad','Member',ARRAY['organization.read'],NULL)")
1751            .execute(legacy.pool())
1752            .await
1753            .unwrap();
1754        sqlx::query("INSERT INTO organization_memberships (membership_id,organization_id,subject,role_id) VALUES ('membership_good','org_good','usr_good','role_good'),('membership_bad','org_bad','usr_bad','role_bad')")
1755            .execute(legacy.pool())
1756            .await
1757            .unwrap();
1758
1759        assert!(
1760            OrganizationOperator::upgrade(&database_url, &schema)
1761                .await
1762                .is_err()
1763        );
1764        let legacy_roles_remain: bool =
1765            sqlx::query_scalar("SELECT to_regclass('organization_roles') IS NOT NULL")
1766                .fetch_one(legacy.pool())
1767                .await
1768                .unwrap();
1769        assert!(legacy_roles_remain);
1770
1771        sqlx::query("UPDATE organization_roles SET system_key='owner' WHERE role_id='role_bad'")
1772            .execute(legacy.pool())
1773            .await
1774            .unwrap();
1775        legacy.pool().close().await;
1776        OrganizationOperator::upgrade(&database_url, &schema)
1777            .await
1778            .unwrap();
1779        let upgraded = OwnedPostgres::prepare(&database_url, schema_plan(schema.clone()).unwrap())
1780            .await
1781            .unwrap();
1782        let owner_count: i64 = sqlx::query_scalar(
1783            "SELECT count(*) FROM organization_memberships WHERE removed_at IS NULL AND is_owner",
1784        )
1785        .fetch_one(upgraded.pool())
1786        .await
1787        .unwrap();
1788        assert_eq!(owner_count, 2);
1789        let legacy_roles_remain: bool =
1790            sqlx::query_scalar("SELECT to_regclass('organization_roles') IS NOT NULL")
1791                .fetch_one(upgraded.pool())
1792                .await
1793                .unwrap();
1794        assert!(!legacy_roles_remain);
1795        let creation_receipts_exist: bool =
1796            sqlx::query_scalar("SELECT to_regclass('organization_creation_requests') IS NOT NULL")
1797                .fetch_one(upgraded.pool())
1798                .await
1799                .unwrap();
1800        assert!(creation_receipts_exist);
1801
1802        upgraded.pool().close().await;
1803        let cleanup_pool = sqlx::PgPool::connect(&database_url).await.unwrap();
1804        cleanup_pool
1805            .execute(AssertSqlSafe(format!("DROP SCHEMA \"{schema}\" CASCADE")))
1806            .await
1807            .unwrap();
1808        cleanup_pool.close().await;
1809    }
1810}