1use apiplant_auth::Authenticator;
41use chrono::{DateTime, Duration, Utc};
42use ntex::web::types::{Json, Path, State};
43use ntex::web::{HttpRequest, HttpResponse};
44use serde_json::{json, Map, Value};
45use uuid::Uuid;
46
47use crate::auth_routes::{auth_spec, quote, table, VERIFIED_AT_FIELD};
48use crate::emails::{self, Links};
49use crate::response::{db_error, error};
50use crate::state::AppState;
51
52const KIND_VERIFICATION: &str = "email_verification";
54const KIND_RESET: &str = "password_reset";
56
57pub async fn create_invitation(
76 req: HttpRequest,
77 state: State<AppState>,
78 body: Json<Value>,
79) -> HttpResponse {
80 let Some(principal) = state.resolve_principal(&req).await else {
81 return error(401, "authentication required");
82 };
83 let Some(org) = state.active_org(&req, &Some(principal.clone())) else {
84 return error(400, "no active organization — pick one with X-Organization");
85 };
86 if !may_invite(&state, &principal, org) {
87 return error(403, "you may not add people to this organization");
88 }
89
90 let Some(invitation_r) = state.app.resources.get("invitation") else {
91 return error(500, "no invitation resource");
92 };
93 let spec = auth_spec(&state);
94
95 let address = body
96 .get("email")
97 .or_else(|| body.get(&spec.identity_field))
98 .and_then(|v| v.as_str())
99 .map(str::trim)
100 .unwrap_or_default()
101 .to_string();
102 if address.is_empty() {
103 return error(400, "`email` is required");
104 }
105 let role = body
106 .get("role")
107 .and_then(|v| v.as_str())
108 .map(str::trim)
109 .filter(|role| !role.is_empty())
110 .unwrap_or("member")
111 .to_string();
112
113 match already_a_member(&state, &address, org).await {
116 Ok(true) => return error(409, "they are already in this organization"),
117 Ok(false) => {}
118 Err(resp) => return resp,
119 }
120
121 if let Some(invitation_tbl) = table(&state, "invitation") {
125 let sql = format!(
126 "DELETE FROM {invitation_tbl} \
127 WHERE organization_id = $1::uuid AND lower(email) = lower($2) \
128 AND accepted_at IS NULL"
129 );
130 if let Err(e) = state
131 .db
132 .raw_json(
133 &sql,
134 &[
135 Value::String(org.to_string()),
136 Value::String(address.clone()),
137 ],
138 )
139 .await
140 {
141 return db_error(e);
142 }
143 }
144
145 let ttl = state.app.config.auth.invite_ttl_secs;
146 let (plaintext, hash) = Authenticator::generate_link_token("inv");
147
148 let mut data = Map::new();
149 data.insert("email".into(), Value::String(address.clone()));
150 data.insert("role".into(), Value::String(role.clone()));
151 data.insert("token_hash".into(), Value::String(hash));
152 data.insert("organization_id".into(), Value::String(org.to_string()));
153 data.insert(
154 "invited_by".into(),
155 Value::String(principal.user_id.to_string()),
156 );
157 data.insert("expires_at".into(), Value::String(rfc3339_in(ttl as i64)));
158
159 let row = match state.db.create(invitation_r, &data).await {
160 Ok(row) => row,
161 Err(e) => return db_error(e),
162 };
163
164 let organization = organization_name(&state, org).await;
165 let inviter = inviter_name(&state, principal.user_id).await;
166 let message = emails::invitation(
167 &Links::from_app(&state.app),
168 &organization,
169 inviter.as_deref(),
170 &plaintext,
171 &emails::humanise(ttl),
172 );
173
174 if let Err(resp) = send(&state, message.to(&address)).await {
178 if let (Some(invitation_tbl), Some(id)) = (
179 table(&state, "invitation"),
180 row.get("id").and_then(|v| v.as_str()),
181 ) {
182 let sql = format!("DELETE FROM {invitation_tbl} WHERE id = $1::uuid");
183 let _ = state
184 .db
185 .raw_json(&sql, &[Value::String(id.to_string())])
186 .await;
187 }
188 return resp;
189 }
190
191 HttpResponse::Created().json(&json!({ "invitation": row }))
192}
193
194pub async fn preview_invitation(state: State<AppState>, token: Path<String>) -> HttpResponse {
205 let invitation = match live_invitation(&state, &token).await {
206 Ok(row) => row,
207 Err(resp) => return resp,
208 };
209
210 let org = invitation
211 .get("organization_id")
212 .and_then(|v| v.as_str())
213 .and_then(|s| Uuid::parse_str(s).ok());
214 let organization = match org {
215 Some(org) => organization_name(&state, org).await,
216 None => state.app.display_name(),
217 };
218 let address = invitation
219 .get("email")
220 .and_then(|v| v.as_str())
221 .unwrap_or_default();
222
223 let has_account = match find_user_by_identity(&state, address).await {
224 Ok(found) => found.is_some(),
225 Err(resp) => return resp,
226 };
227
228 HttpResponse::Ok().json(&json!({
229 "email": address,
230 "organization": organization,
231 "role": invitation.get("role").cloned().unwrap_or(Value::Null),
232 "expires_at": invitation.get("expires_at").cloned().unwrap_or(Value::Null),
233 "has_account": has_account,
235 "identity_field": auth_spec(&state).identity_field,
236 }))
237}
238
239pub async fn accept_invitation(
256 req: HttpRequest,
257 state: State<AppState>,
258 token: Path<String>,
259 body: Json<Map<String, Value>>,
260) -> HttpResponse {
261 let invitation = match live_invitation(&state, &token).await {
262 Ok(row) => row,
263 Err(resp) => return resp,
264 };
265 let (Some(invitation_id), Some(org), address) = (
266 invitation
267 .get("id")
268 .and_then(|v| v.as_str())
269 .and_then(|s| Uuid::parse_str(s).ok()),
270 invitation
271 .get("organization_id")
272 .and_then(|v| v.as_str())
273 .and_then(|s| Uuid::parse_str(s).ok()),
274 invitation
275 .get("email")
276 .and_then(|v| v.as_str())
277 .unwrap_or_default()
278 .to_string(),
279 ) else {
280 return error(500, "invitation is missing its organization");
281 };
282
283 let spec = auth_spec(&state);
284 let user_id = match find_user_by_identity(&state, &address).await {
285 Ok(Some(id)) => id,
286 Err(resp) => return resp,
287 Ok(None) => {
288 let mut data = body.into_inner();
292 let password = match data
293 .remove("password")
294 .and_then(|v| v.as_str().map(String::from))
295 .filter(|p| !p.is_empty())
296 {
297 Some(password) => password,
298 None => return error(400, "`password` is required to create your account"),
299 };
300 let hash = match state.auth.hash_password(&password) {
301 Ok(hash) => hash,
302 Err(_) => return error(500, "failed to hash password"),
303 };
304 data.insert(spec.identity_field.clone(), Value::String(address.clone()));
305 data.insert(spec.password_field.clone(), Value::String(hash));
306 if state
309 .app
310 .resources
311 .get("user")
312 .is_some_and(|user| user.fields.contains_key(VERIFIED_AT_FIELD))
313 {
314 data.insert(VERIFIED_AT_FIELD.into(), Value::String(rfc3339_in(0)));
315 }
316
317 match crate::auth_routes::create_account(&state, &req, data).await {
318 Ok((id, _)) => id,
319 Err(resp) => return resp,
320 }
321 }
322 };
323
324 if let Err(resp) = ensure_membership(
327 &state,
328 user_id,
329 org,
330 invitation.get("role").and_then(|v| v.as_str()),
331 )
332 .await
333 {
334 return resp;
335 }
336
337 if let Some(invitation_tbl) = table(&state, "invitation") {
338 let sql = format!("UPDATE {invitation_tbl} SET accepted_at = now() WHERE id = $1::uuid");
339 if let Err(e) = state
340 .db
341 .raw_json(&sql, &[Value::String(invitation_id.to_string())])
342 .await
343 {
344 return db_error(e);
345 }
346 }
347
348 match state.auth.issue_token(user_id) {
349 Ok(session) => HttpResponse::Ok().json(&json!({
350 "token": session,
351 "organization_id": org.to_string(),
352 })),
353 Err(_) => error(500, "failed to issue token"),
354 }
355}
356
357pub async fn send_verification(
367 state: &AppState,
368 user_id: Uuid,
369 address: &str,
370) -> Result<(), HttpResponse> {
371 if !deliverable(address) {
372 return Ok(());
373 }
374 let ttl = state.app.config.auth.verification_ttl_secs;
375 let plaintext = mint_token(state, user_id, KIND_VERIFICATION, ttl).await?;
376 let message = emails::verification(
377 &Links::from_app(&state.app),
378 &plaintext,
379 &emails::humanise(ttl),
380 );
381 send(state, message.to(address)).await
382}
383
384pub async fn verify_email(state: State<AppState>, body: Json<Value>) -> HttpResponse {
389 let Some(token) = body.get("token").and_then(|v| v.as_str()) else {
390 return error(400, "`token` is required");
391 };
392 let user_id = match spend_token(&state, token, KIND_VERIFICATION).await {
393 Ok(id) => id,
394 Err(resp) => return resp,
395 };
396
397 let Some(user_tbl) = table(&state, "user") else {
398 return error(500, "missing user resource");
399 };
400 let sql = format!(
403 "UPDATE {user_tbl} SET {VERIFIED_AT_FIELD} = coalesce({VERIFIED_AT_FIELD}, now()) \
404 WHERE id = $1::uuid"
405 );
406 if let Err(e) = state
407 .db
408 .raw_json(&sql, &[Value::String(user_id.to_string())])
409 .await
410 {
411 return db_error(e);
412 }
413
414 match state.auth.issue_token(user_id) {
415 Ok(session) => HttpResponse::Ok().json(&json!({ "token": session, "verified": true })),
416 Err(_) => error(500, "failed to issue token"),
417 }
418}
419
420pub async fn resend_verification(state: State<AppState>, body: Json<Value>) -> HttpResponse {
426 let spec = auth_spec(&state);
427 let address = body
428 .get("email")
429 .or_else(|| body.get(&spec.identity_field))
430 .and_then(|v| v.as_str())
431 .map(str::trim)
432 .unwrap_or_default()
433 .to_string();
434
435 if !address.is_empty() {
436 if let Ok(Some(user_id)) = find_unverified_user(&state, &address).await {
437 if let Err(_resp) = send_verification(&state, user_id, &address).await {
440 tracing::warn!("could not send a verification email");
441 }
442 }
443 }
444
445 accepted("If that address needs confirming, a new link is on its way.")
446}
447
448pub async fn forgot_password(state: State<AppState>, body: Json<Value>) -> HttpResponse {
454 let spec = auth_spec(&state);
455 let address = body
456 .get("email")
457 .or_else(|| body.get(&spec.identity_field))
458 .and_then(|v| v.as_str())
459 .map(str::trim)
460 .unwrap_or_default()
461 .to_string();
462
463 if !address.is_empty() && deliverable(&address) {
464 if let Ok(Some(user_id)) = find_user_by_identity(&state, &address).await {
465 let ttl = state.app.config.auth.password_reset_ttl_secs;
466 match mint_token(&state, user_id, KIND_RESET, ttl).await {
467 Ok(plaintext) => {
468 let message = emails::password_reset(
469 &Links::from_app(&state.app),
470 &plaintext,
471 &emails::humanise(ttl),
472 );
473 if send(&state, message.to(&address)).await.is_err() {
474 tracing::warn!("could not send a password reset email");
475 }
476 }
477 Err(_) => tracing::warn!("could not mint a password reset token"),
478 }
479 }
480 }
481
482 accepted("If that address has an account, a reset link is on its way.")
483}
484
485pub async fn reset_password(state: State<AppState>, body: Json<Value>) -> HttpResponse {
494 let Some(token) = body.get("token").and_then(|v| v.as_str()) else {
495 return error(400, "`token` is required");
496 };
497 let Some(password) = body
498 .get("password")
499 .and_then(|v| v.as_str())
500 .filter(|p| !p.is_empty())
501 else {
502 return error(400, "`password` is required");
503 };
504
505 let user_id = match spend_token(&state, token, KIND_RESET).await {
506 Ok(id) => id,
507 Err(resp) => return resp,
508 };
509 let hash = match state.auth.hash_password(password) {
510 Ok(hash) => hash,
511 Err(_) => return error(500, "failed to hash password"),
512 };
513
514 let spec = auth_spec(&state);
515 let Some(user_tbl) = table(&state, "user") else {
516 return error(500, "missing user resource");
517 };
518 let sql = format!(
519 "UPDATE {user_tbl} \
520 SET {pw} = $1, {VERIFIED_AT_FIELD} = coalesce({VERIFIED_AT_FIELD}, now()) \
521 WHERE id = $2::uuid",
522 pw = quote(&spec.password_field),
523 );
524 if let Err(e) = state
525 .db
526 .raw_json(
527 &sql,
528 &[Value::String(hash), Value::String(user_id.to_string())],
529 )
530 .await
531 {
532 return db_error(e);
533 }
534
535 if let Some(token_tbl) = table(&state, "auth_token") {
536 let sql = format!(
537 "UPDATE {token_tbl} SET used_at = now() \
538 WHERE user_id = $1::uuid AND kind = $2 AND used_at IS NULL"
539 );
540 let _ = state
541 .db
542 .raw_json(
543 &sql,
544 &[
545 Value::String(user_id.to_string()),
546 Value::String(KIND_RESET.into()),
547 ],
548 )
549 .await;
550 }
551
552 match state.auth.issue_token(user_id) {
553 Ok(session) => HttpResponse::Ok().json(&json!({ "token": session })),
554 Err(_) => error(500, "failed to issue token"),
555 }
556}
557
558fn may_invite(state: &AppState, principal: &apiplant_auth::Principal, org: Uuid) -> bool {
568 invite_policy(
569 state
570 .app
571 .resources
572 .get("membership")
573 .map(|membership| &membership.permissions.create),
574 principal,
575 org,
576 )
577}
578
579fn invite_policy(
582 create: Option<&apiplant_core::Access>,
583 principal: &apiplant_auth::Principal,
584 org: Uuid,
585) -> bool {
586 use apiplant_core::Access;
587 match create {
588 Some(Access::Role(role)) => principal.has_role_in(org, role),
589 Some(Access::Member | Access::Owner | Access::Authenticated) => principal.is_member(org),
590 _ => principal.is_admin_of(org),
591 }
592}
593
594async fn live_invitation(state: &AppState, token: &str) -> Result<Value, HttpResponse> {
600 let Some(invitation_tbl) = table(state, "invitation") else {
601 return Err(error(500, "no invitation resource"));
602 };
603 let hash = Authenticator::hash_link_token(token.trim());
604 let sql = format!(
605 "SELECT id::text AS id, email, role, organization_id::text AS organization_id, \
606 expires_at::text AS expires_at \
607 FROM {invitation_tbl} \
608 WHERE token_hash = $1 AND accepted_at IS NULL AND expires_at > now() \
609 LIMIT 1"
610 );
611 let rows = state
612 .db
613 .raw_json(&sql, &[Value::String(hash)])
614 .await
615 .map_err(db_error)?;
616 match rows.as_array().and_then(|rows| rows.first()) {
617 Some(row) => Ok(row.clone()),
618 None => Err(error(404, "this invitation is no longer valid")),
619 }
620}
621
622async fn mint_token(
624 state: &AppState,
625 user_id: Uuid,
626 kind: &str,
627 ttl_secs: u64,
628) -> Result<String, HttpResponse> {
629 let Some(token_r) = state.app.resources.get("auth_token") else {
630 return Err(error(500, "no auth_token resource"));
631 };
632 let prefix = if kind == KIND_RESET {
633 "reset"
634 } else {
635 "verify"
636 };
637 let (plaintext, hash) = Authenticator::generate_link_token(prefix);
638
639 let mut data = Map::new();
640 data.insert("user_id".into(), Value::String(user_id.to_string()));
641 data.insert("kind".into(), Value::String(kind.to_string()));
642 data.insert("token_hash".into(), Value::String(hash));
643 data.insert(
644 "expires_at".into(),
645 Value::String(rfc3339_in(ttl_secs as i64)),
646 );
647
648 state
649 .db
650 .create(token_r, &data)
651 .await
652 .map_err(db_error)
653 .map(|_| plaintext)
654}
655
656async fn spend_token(state: &AppState, token: &str, kind: &str) -> Result<Uuid, HttpResponse> {
664 let Some(token_tbl) = table(state, "auth_token") else {
665 return Err(error(500, "no auth_token resource"));
666 };
667 let hash = Authenticator::hash_link_token(token.trim());
668 let params = [Value::String(hash), Value::String(kind.to_string())];
669
670 let claim = format!(
671 "UPDATE {token_tbl} SET used_at = now() \
672 WHERE token_hash = $1 AND kind = $2 AND used_at IS NULL AND expires_at > now()"
673 );
674 let claimed = state
675 .db
676 .raw_json(&claim, ¶ms)
677 .await
678 .map_err(db_error)?
679 .get("rows_affected")
680 .and_then(|v| v.as_u64())
681 .unwrap_or(0);
682 if claimed == 0 {
683 return Err(error(404, "this link is no longer valid"));
684 }
685
686 let lookup = format!(
687 "SELECT user_id::text AS user_id FROM {token_tbl} \
688 WHERE token_hash = $1 AND kind = $2 LIMIT 1"
689 );
690 let rows = state
691 .db
692 .raw_json(&lookup, ¶ms)
693 .await
694 .map_err(db_error)?;
695
696 rows.as_array()
697 .and_then(|rows| rows.first())
698 .and_then(|row| row.get("user_id"))
699 .and_then(|v| v.as_str())
700 .and_then(|s| Uuid::parse_str(s).ok())
701 .ok_or_else(|| error(404, "this link is no longer valid"))
702}
703
704async fn find_user_by_identity(
710 state: &AppState,
711 address: &str,
712) -> Result<Option<Uuid>, HttpResponse> {
713 let spec = auth_spec(state);
714 let Some(user_tbl) = table(state, "user") else {
715 return Err(error(500, "missing user resource"));
716 };
717 let sql = format!(
718 "SELECT id::text AS id FROM {user_tbl} WHERE lower({ident}) = lower($1) LIMIT 1",
719 ident = quote(&spec.identity_field),
720 );
721 let rows = state
722 .db
723 .raw_json(&sql, &[Value::String(address.to_string())])
724 .await
725 .map_err(db_error)?;
726 Ok(rows
727 .as_array()
728 .and_then(|rows| rows.first())
729 .and_then(|row| row.get("id"))
730 .and_then(|v| v.as_str())
731 .and_then(|s| Uuid::parse_str(s).ok()))
732}
733
734async fn find_unverified_user(
738 state: &AppState,
739 address: &str,
740) -> Result<Option<Uuid>, HttpResponse> {
741 let spec = auth_spec(state);
742 let Some(user_tbl) = table(state, "user") else {
743 return Err(error(500, "missing user resource"));
744 };
745 let sql = format!(
746 "SELECT id::text AS id FROM {user_tbl} \
747 WHERE lower({ident}) = lower($1) AND {VERIFIED_AT_FIELD} IS NULL LIMIT 1",
748 ident = quote(&spec.identity_field),
749 );
750 let rows = state
751 .db
752 .raw_json(&sql, &[Value::String(address.to_string())])
753 .await
754 .map_err(db_error)?;
755 Ok(rows
756 .as_array()
757 .and_then(|rows| rows.first())
758 .and_then(|row| row.get("id"))
759 .and_then(|v| v.as_str())
760 .and_then(|s| Uuid::parse_str(s).ok()))
761}
762
763async fn already_a_member(
765 state: &AppState,
766 address: &str,
767 org: Uuid,
768) -> Result<bool, HttpResponse> {
769 let Some(user_id) = find_user_by_identity(state, address).await? else {
770 return Ok(false);
771 };
772 let Some(membership_tbl) = table(state, "membership") else {
773 return Ok(false);
774 };
775 let sql = format!(
776 "SELECT 1 AS hit FROM {membership_tbl} \
777 WHERE user_id = $1::uuid AND organization_id = $2::uuid LIMIT 1"
778 );
779 let rows = state
780 .db
781 .raw_json(
782 &sql,
783 &[
784 Value::String(user_id.to_string()),
785 Value::String(org.to_string()),
786 ],
787 )
788 .await
789 .map_err(db_error)?;
790 Ok(rows.as_array().is_some_and(|rows| !rows.is_empty()))
791}
792
793async fn ensure_membership(
799 state: &AppState,
800 user_id: Uuid,
801 org: Uuid,
802 role: Option<&str>,
803) -> Result<(), HttpResponse> {
804 let Some(membership_r) = state.app.resources.get("membership") else {
805 return Err(error(500, "no membership resource"));
806 };
807 let Some(membership_tbl) = table(state, "membership") else {
808 return Err(error(500, "no membership resource"));
809 };
810
811 let sql = format!(
812 "SELECT 1 AS hit FROM {membership_tbl} \
813 WHERE user_id = $1::uuid AND organization_id = $2::uuid LIMIT 1"
814 );
815 let rows = state
816 .db
817 .raw_json(
818 &sql,
819 &[
820 Value::String(user_id.to_string()),
821 Value::String(org.to_string()),
822 ],
823 )
824 .await
825 .map_err(db_error)?;
826 if rows.as_array().is_some_and(|rows| !rows.is_empty()) {
827 return Ok(());
828 }
829
830 let mut data = Map::new();
831 data.insert("user_id".into(), Value::String(user_id.to_string()));
832 data.insert("organization_id".into(), Value::String(org.to_string()));
833 if let Some(role) = role.filter(|role| !role.is_empty()) {
834 data.insert("role".into(), Value::String(role.to_string()));
835 }
836 state
837 .db
838 .create(membership_r, &data)
839 .await
840 .map_err(db_error)
841 .map(|_| ())
842}
843
844async fn organization_name(state: &AppState, org: Uuid) -> String {
847 let fallback = || state.app.display_name();
848 let Some(org_tbl) = table(state, "organization") else {
849 return fallback();
850 };
851 let sql = format!("SELECT name FROM {org_tbl} WHERE id = $1::uuid LIMIT 1");
852 let rows = match state
853 .db
854 .raw_json(&sql, &[Value::String(org.to_string())])
855 .await
856 {
857 Ok(rows) => rows,
858 Err(_) => return fallback(),
859 };
860 rows.as_array()
861 .and_then(|rows| rows.first())
862 .and_then(|row| row.get("name"))
863 .and_then(|v| v.as_str())
864 .filter(|name| !name.is_empty())
865 .map(str::to_owned)
866 .unwrap_or_else(fallback)
867}
868
869async fn inviter_name(state: &AppState, user_id: Uuid) -> Option<String> {
872 let spec = auth_spec(state);
873 let user_r = state.app.resources.get("user")?;
874 let user_tbl = table(state, "user")?;
875
876 let mut candidates: Vec<String> = Vec::new();
877 for name in ["display_name", "name"] {
878 if user_r.fields.contains_key(name) {
879 candidates.push(quote(name));
880 }
881 }
882 candidates.push(quote(&spec.identity_field));
883
884 let sql = format!(
885 "SELECT coalesce({}) AS who FROM {user_tbl} WHERE id = $1::uuid LIMIT 1",
886 candidates.join(", "),
887 );
888 let rows = state
889 .db
890 .raw_json(&sql, &[Value::String(user_id.to_string())])
891 .await
892 .ok()?;
893 rows.as_array()?
894 .first()?
895 .get("who")?
896 .as_str()
897 .filter(|who| !who.is_empty())
898 .map(str::to_owned)
899}
900
901async fn send(state: &AppState, message: apiplant_email::Message) -> Result<(), HttpResponse> {
907 let Some(mailer) = &state.mailer else {
908 return Err(error(502, "this server cannot send email"));
911 };
912 match mailer.send(&message).await {
913 Ok(_) => Ok(()),
914 Err(e) => {
915 tracing::error!(error = %e, "could not send an email");
916 Err(error(502, "could not send the email — try again shortly"))
917 }
918 }
919}
920
921fn deliverable(address: &str) -> bool {
934 let deliverable = !address
935 .rsplit_once('@')
936 .map(|(_, domain)| {
937 let domain = domain.trim().trim_end_matches('.').to_lowercase();
938 domain == "invalid" || domain.ends_with(".invalid")
939 })
940 .unwrap_or(false);
941 if !deliverable {
942 tracing::debug!("not mailing an address at a reserved `.invalid` domain");
943 }
944 deliverable
945}
946
947fn accepted(message: &str) -> HttpResponse {
949 HttpResponse::Accepted().json(&json!({ "message": message }))
950}
951
952fn rfc3339_in(secs: i64) -> String {
955 let at: DateTime<Utc> = Utc::now() + Duration::seconds(secs);
956 at.to_rfc3339()
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use apiplant_core::Access;
963
964 fn principal(org: Uuid, role: &str) -> apiplant_auth::Principal {
965 apiplant_auth::Principal {
966 user_id: Uuid::new_v4(),
967 organizations: vec![apiplant_auth::OrgMembership::new(
968 org,
969 Some(role.to_string()),
970 [],
971 )],
972 }
973 }
974
975 #[test]
976 fn who_may_invite_follows_who_may_add_a_member() {
977 let org = Uuid::new_v4();
978
979 let admins = Access::Role("admin".into());
981 assert!(invite_policy(Some(&admins), &principal(org, "admin"), org));
982 assert!(!invite_policy(
983 Some(&admins),
984 &principal(org, "member"),
985 org
986 ));
987
988 let members = Access::Member;
991 assert!(invite_policy(
992 Some(&members),
993 &principal(org, "member"),
994 org
995 ));
996
997 assert!(!invite_policy(
999 Some(&members),
1000 &principal(org, "member"),
1001 Uuid::new_v4()
1002 ));
1003 }
1004
1005 #[test]
1006 fn a_policy_that_is_not_a_role_check_falls_back_to_admin() {
1007 let org = Uuid::new_v4();
1008 for policy in [Some(&Access::Public), None] {
1012 assert!(!invite_policy(policy, &principal(org, "member"), org));
1013 assert!(invite_policy(policy, &principal(org, "admin"), org));
1014 }
1015 }
1016}