1mod 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}