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 &[Value::String(org.to_string()), Value::String(address.clone())],
135 )
136 .await
137 {
138 return db_error(e);
139 }
140 }
141
142 let ttl = state.app.config.auth.invite_ttl_secs;
143 let (plaintext, hash) = Authenticator::generate_link_token("inv");
144
145 let mut data = Map::new();
146 data.insert("email".into(), Value::String(address.clone()));
147 data.insert("role".into(), Value::String(role.clone()));
148 data.insert("token_hash".into(), Value::String(hash));
149 data.insert("organization_id".into(), Value::String(org.to_string()));
150 data.insert(
151 "invited_by".into(),
152 Value::String(principal.user_id.to_string()),
153 );
154 data.insert("expires_at".into(), Value::String(rfc3339_in(ttl as i64)));
155
156 let row = match state.db.create(invitation_r, &data).await {
157 Ok(row) => row,
158 Err(e) => return db_error(e),
159 };
160
161 let organization = organization_name(&state, org).await;
162 let inviter = inviter_name(&state, principal.user_id).await;
163 let message = emails::invitation(
164 &Links::from_app(&state.app),
165 &organization,
166 inviter.as_deref(),
167 &plaintext,
168 &emails::humanise(ttl),
169 );
170
171 if let Err(resp) = send(&state, message.to(&address)).await {
175 if let (Some(invitation_tbl), Some(id)) = (
176 table(&state, "invitation"),
177 row.get("id").and_then(|v| v.as_str()),
178 ) {
179 let sql = format!("DELETE FROM {invitation_tbl} WHERE id = $1::uuid");
180 let _ = state
181 .db
182 .raw_json(&sql, &[Value::String(id.to_string())])
183 .await;
184 }
185 return resp;
186 }
187
188 HttpResponse::Created().json(&json!({ "invitation": row }))
189}
190
191pub async fn preview_invitation(state: State<AppState>, token: Path<String>) -> HttpResponse {
202 let invitation = match live_invitation(&state, &token).await {
203 Ok(row) => row,
204 Err(resp) => return resp,
205 };
206
207 let org = invitation
208 .get("organization_id")
209 .and_then(|v| v.as_str())
210 .and_then(|s| Uuid::parse_str(s).ok());
211 let organization = match org {
212 Some(org) => organization_name(&state, org).await,
213 None => state.app.display_name(),
214 };
215 let address = invitation
216 .get("email")
217 .and_then(|v| v.as_str())
218 .unwrap_or_default();
219
220 let has_account = match find_user_by_identity(&state, address).await {
221 Ok(found) => found.is_some(),
222 Err(resp) => return resp,
223 };
224
225 HttpResponse::Ok().json(&json!({
226 "email": address,
227 "organization": organization,
228 "role": invitation.get("role").cloned().unwrap_or(Value::Null),
229 "expires_at": invitation.get("expires_at").cloned().unwrap_or(Value::Null),
230 "has_account": has_account,
232 "identity_field": auth_spec(&state).identity_field,
233 }))
234}
235
236pub async fn accept_invitation(
253 req: HttpRequest,
254 state: State<AppState>,
255 token: Path<String>,
256 body: Json<Map<String, Value>>,
257) -> HttpResponse {
258 let invitation = match live_invitation(&state, &token).await {
259 Ok(row) => row,
260 Err(resp) => return resp,
261 };
262 let (Some(invitation_id), Some(org), address) = (
263 invitation
264 .get("id")
265 .and_then(|v| v.as_str())
266 .and_then(|s| Uuid::parse_str(s).ok()),
267 invitation
268 .get("organization_id")
269 .and_then(|v| v.as_str())
270 .and_then(|s| Uuid::parse_str(s).ok()),
271 invitation
272 .get("email")
273 .and_then(|v| v.as_str())
274 .unwrap_or_default()
275 .to_string(),
276 ) else {
277 return error(500, "invitation is missing its organization");
278 };
279
280 let spec = auth_spec(&state);
281 let user_id = match find_user_by_identity(&state, &address).await {
282 Ok(Some(id)) => id,
283 Err(resp) => return resp,
284 Ok(None) => {
285 let mut data = body.into_inner();
289 let password = match data
290 .remove("password")
291 .and_then(|v| v.as_str().map(String::from))
292 .filter(|p| !p.is_empty())
293 {
294 Some(password) => password,
295 None => return error(400, "`password` is required to create your account"),
296 };
297 let hash = match state.auth.hash_password(&password) {
298 Ok(hash) => hash,
299 Err(_) => return error(500, "failed to hash password"),
300 };
301 data.insert(spec.identity_field.clone(), Value::String(address.clone()));
302 data.insert(spec.password_field.clone(), Value::String(hash));
303 if state
306 .app
307 .resources
308 .get("user")
309 .is_some_and(|user| user.fields.contains_key(VERIFIED_AT_FIELD))
310 {
311 data.insert(VERIFIED_AT_FIELD.into(), Value::String(rfc3339_in(0)));
312 }
313
314 match crate::auth_routes::create_account(&state, &req, data).await {
315 Ok((id, _)) => id,
316 Err(resp) => return resp,
317 }
318 }
319 };
320
321 if let Err(resp) = ensure_membership(
324 &state,
325 user_id,
326 org,
327 invitation.get("role").and_then(|v| v.as_str()),
328 )
329 .await
330 {
331 return resp;
332 }
333
334 if let Some(invitation_tbl) = table(&state, "invitation") {
335 let sql = format!(
336 "UPDATE {invitation_tbl} SET accepted_at = now() WHERE id = $1::uuid"
337 );
338 if let Err(e) = state
339 .db
340 .raw_json(&sql, &[Value::String(invitation_id.to_string())])
341 .await
342 {
343 return db_error(e);
344 }
345 }
346
347 match state.auth.issue_token(user_id) {
348 Ok(session) => HttpResponse::Ok().json(&json!({
349 "token": session,
350 "organization_id": org.to_string(),
351 })),
352 Err(_) => error(500, "failed to issue token"),
353 }
354}
355
356pub async fn send_verification(
366 state: &AppState,
367 user_id: Uuid,
368 address: &str,
369) -> Result<(), HttpResponse> {
370 let ttl = state.app.config.auth.verification_ttl_secs;
371 let plaintext = mint_token(state, user_id, KIND_VERIFICATION, ttl).await?;
372 let message = emails::verification(
373 &Links::from_app(&state.app),
374 &plaintext,
375 &emails::humanise(ttl),
376 );
377 send(state, message.to(address)).await
378}
379
380pub async fn verify_email(state: State<AppState>, body: Json<Value>) -> HttpResponse {
385 let Some(token) = body.get("token").and_then(|v| v.as_str()) else {
386 return error(400, "`token` is required");
387 };
388 let user_id = match spend_token(&state, token, KIND_VERIFICATION).await {
389 Ok(id) => id,
390 Err(resp) => return resp,
391 };
392
393 let Some(user_tbl) = table(&state, "user") else {
394 return error(500, "missing user resource");
395 };
396 let sql = format!(
399 "UPDATE {user_tbl} SET {VERIFIED_AT_FIELD} = coalesce({VERIFIED_AT_FIELD}, now()) \
400 WHERE id = $1::uuid"
401 );
402 if let Err(e) = state
403 .db
404 .raw_json(&sql, &[Value::String(user_id.to_string())])
405 .await
406 {
407 return db_error(e);
408 }
409
410 match state.auth.issue_token(user_id) {
411 Ok(session) => HttpResponse::Ok().json(&json!({ "token": session, "verified": true })),
412 Err(_) => error(500, "failed to issue token"),
413 }
414}
415
416pub async fn resend_verification(state: State<AppState>, body: Json<Value>) -> HttpResponse {
422 let spec = auth_spec(&state);
423 let address = body
424 .get("email")
425 .or_else(|| body.get(&spec.identity_field))
426 .and_then(|v| v.as_str())
427 .map(str::trim)
428 .unwrap_or_default()
429 .to_string();
430
431 if !address.is_empty() {
432 if let Ok(Some(user_id)) = find_unverified_user(&state, &address).await {
433 if let Err(_resp) = send_verification(&state, user_id, &address).await {
436 tracing::warn!("could not send a verification email");
437 }
438 }
439 }
440
441 accepted("If that address needs confirming, a new link is on its way.")
442}
443
444pub async fn forgot_password(state: State<AppState>, body: Json<Value>) -> HttpResponse {
450 let spec = auth_spec(&state);
451 let address = body
452 .get("email")
453 .or_else(|| body.get(&spec.identity_field))
454 .and_then(|v| v.as_str())
455 .map(str::trim)
456 .unwrap_or_default()
457 .to_string();
458
459 if !address.is_empty() {
460 if let Ok(Some(user_id)) = find_user_by_identity(&state, &address).await {
461 let ttl = state.app.config.auth.password_reset_ttl_secs;
462 match mint_token(&state, user_id, KIND_RESET, ttl).await {
463 Ok(plaintext) => {
464 let message = emails::password_reset(
465 &Links::from_app(&state.app),
466 &plaintext,
467 &emails::humanise(ttl),
468 );
469 if send(&state, message.to(&address)).await.is_err() {
470 tracing::warn!("could not send a password reset email");
471 }
472 }
473 Err(_) => tracing::warn!("could not mint a password reset token"),
474 }
475 }
476 }
477
478 accepted("If that address has an account, a reset link is on its way.")
479}
480
481pub async fn reset_password(state: State<AppState>, body: Json<Value>) -> HttpResponse {
490 let Some(token) = body.get("token").and_then(|v| v.as_str()) else {
491 return error(400, "`token` is required");
492 };
493 let Some(password) = body
494 .get("password")
495 .and_then(|v| v.as_str())
496 .filter(|p| !p.is_empty())
497 else {
498 return error(400, "`password` is required");
499 };
500
501 let user_id = match spend_token(&state, token, KIND_RESET).await {
502 Ok(id) => id,
503 Err(resp) => return resp,
504 };
505 let hash = match state.auth.hash_password(password) {
506 Ok(hash) => hash,
507 Err(_) => return error(500, "failed to hash password"),
508 };
509
510 let spec = auth_spec(&state);
511 let Some(user_tbl) = table(&state, "user") else {
512 return error(500, "missing user resource");
513 };
514 let sql = format!(
515 "UPDATE {user_tbl} \
516 SET {pw} = $1, {VERIFIED_AT_FIELD} = coalesce({VERIFIED_AT_FIELD}, now()) \
517 WHERE id = $2::uuid",
518 pw = quote(&spec.password_field),
519 );
520 if let Err(e) = state
521 .db
522 .raw_json(
523 &sql,
524 &[Value::String(hash), Value::String(user_id.to_string())],
525 )
526 .await
527 {
528 return db_error(e);
529 }
530
531 if let Some(token_tbl) = table(&state, "auth_token") {
532 let sql = format!(
533 "UPDATE {token_tbl} SET used_at = now() \
534 WHERE user_id = $1::uuid AND kind = $2 AND used_at IS NULL"
535 );
536 let _ = state
537 .db
538 .raw_json(
539 &sql,
540 &[
541 Value::String(user_id.to_string()),
542 Value::String(KIND_RESET.into()),
543 ],
544 )
545 .await;
546 }
547
548 match state.auth.issue_token(user_id) {
549 Ok(session) => HttpResponse::Ok().json(&json!({ "token": session })),
550 Err(_) => error(500, "failed to issue token"),
551 }
552}
553
554fn may_invite(state: &AppState, principal: &apiplant_auth::Principal, org: Uuid) -> bool {
564 invite_policy(
565 state
566 .app
567 .resources
568 .get("membership")
569 .map(|membership| &membership.permissions.create),
570 principal,
571 org,
572 )
573}
574
575fn invite_policy(
578 create: Option<&apiplant_core::Access>,
579 principal: &apiplant_auth::Principal,
580 org: Uuid,
581) -> bool {
582 use apiplant_core::Access;
583 match create {
584 Some(Access::Role(role)) => principal.has_role_in(org, role),
585 Some(Access::Member | Access::Owner | Access::Authenticated) => principal.is_member(org),
586 _ => principal.is_admin_of(org),
587 }
588}
589
590async fn live_invitation(state: &AppState, token: &str) -> Result<Value, HttpResponse> {
596 let Some(invitation_tbl) = table(state, "invitation") else {
597 return Err(error(500, "no invitation resource"));
598 };
599 let hash = Authenticator::hash_link_token(token.trim());
600 let sql = format!(
601 "SELECT id::text AS id, email, role, organization_id::text AS organization_id, \
602 expires_at::text AS expires_at \
603 FROM {invitation_tbl} \
604 WHERE token_hash = $1 AND accepted_at IS NULL AND expires_at > now() \
605 LIMIT 1"
606 );
607 let rows = state
608 .db
609 .raw_json(&sql, &[Value::String(hash)])
610 .await
611 .map_err(db_error)?;
612 match rows.as_array().and_then(|rows| rows.first()) {
613 Some(row) => Ok(row.clone()),
614 None => Err(error(404, "this invitation is no longer valid")),
615 }
616}
617
618async fn mint_token(
620 state: &AppState,
621 user_id: Uuid,
622 kind: &str,
623 ttl_secs: u64,
624) -> Result<String, HttpResponse> {
625 let Some(token_r) = state.app.resources.get("auth_token") else {
626 return Err(error(500, "no auth_token resource"));
627 };
628 let prefix = if kind == KIND_RESET { "reset" } else { "verify" };
629 let (plaintext, hash) = Authenticator::generate_link_token(prefix);
630
631 let mut data = Map::new();
632 data.insert("user_id".into(), Value::String(user_id.to_string()));
633 data.insert("kind".into(), Value::String(kind.to_string()));
634 data.insert("token_hash".into(), Value::String(hash));
635 data.insert(
636 "expires_at".into(),
637 Value::String(rfc3339_in(ttl_secs as i64)),
638 );
639
640 state
641 .db
642 .create(token_r, &data)
643 .await
644 .map_err(db_error)
645 .map(|_| plaintext)
646}
647
648async fn spend_token(state: &AppState, token: &str, kind: &str) -> Result<Uuid, HttpResponse> {
656 let Some(token_tbl) = table(state, "auth_token") else {
657 return Err(error(500, "no auth_token resource"));
658 };
659 let hash = Authenticator::hash_link_token(token.trim());
660 let params = [Value::String(hash), Value::String(kind.to_string())];
661
662 let claim = format!(
663 "UPDATE {token_tbl} SET used_at = now() \
664 WHERE token_hash = $1 AND kind = $2 AND used_at IS NULL AND expires_at > now()"
665 );
666 let claimed = state
667 .db
668 .raw_json(&claim, ¶ms)
669 .await
670 .map_err(db_error)?
671 .get("rows_affected")
672 .and_then(|v| v.as_u64())
673 .unwrap_or(0);
674 if claimed == 0 {
675 return Err(error(404, "this link is no longer valid"));
676 }
677
678 let lookup = format!(
679 "SELECT user_id::text AS user_id FROM {token_tbl} \
680 WHERE token_hash = $1 AND kind = $2 LIMIT 1"
681 );
682 let rows = state
683 .db
684 .raw_json(&lookup, ¶ms)
685 .await
686 .map_err(db_error)?;
687
688 rows.as_array()
689 .and_then(|rows| rows.first())
690 .and_then(|row| row.get("user_id"))
691 .and_then(|v| v.as_str())
692 .and_then(|s| Uuid::parse_str(s).ok())
693 .ok_or_else(|| error(404, "this link is no longer valid"))
694}
695
696async fn find_user_by_identity(
702 state: &AppState,
703 address: &str,
704) -> Result<Option<Uuid>, HttpResponse> {
705 let spec = auth_spec(state);
706 let Some(user_tbl) = table(state, "user") else {
707 return Err(error(500, "missing user resource"));
708 };
709 let sql = format!(
710 "SELECT id::text AS id FROM {user_tbl} WHERE lower({ident}) = lower($1) LIMIT 1",
711 ident = quote(&spec.identity_field),
712 );
713 let rows = state
714 .db
715 .raw_json(&sql, &[Value::String(address.to_string())])
716 .await
717 .map_err(db_error)?;
718 Ok(rows
719 .as_array()
720 .and_then(|rows| rows.first())
721 .and_then(|row| row.get("id"))
722 .and_then(|v| v.as_str())
723 .and_then(|s| Uuid::parse_str(s).ok()))
724}
725
726async fn find_unverified_user(
730 state: &AppState,
731 address: &str,
732) -> Result<Option<Uuid>, HttpResponse> {
733 let spec = auth_spec(state);
734 let Some(user_tbl) = table(state, "user") else {
735 return Err(error(500, "missing user resource"));
736 };
737 let sql = format!(
738 "SELECT id::text AS id FROM {user_tbl} \
739 WHERE lower({ident}) = lower($1) AND {VERIFIED_AT_FIELD} IS NULL LIMIT 1",
740 ident = quote(&spec.identity_field),
741 );
742 let rows = state
743 .db
744 .raw_json(&sql, &[Value::String(address.to_string())])
745 .await
746 .map_err(db_error)?;
747 Ok(rows
748 .as_array()
749 .and_then(|rows| rows.first())
750 .and_then(|row| row.get("id"))
751 .and_then(|v| v.as_str())
752 .and_then(|s| Uuid::parse_str(s).ok()))
753}
754
755async fn already_a_member(
757 state: &AppState,
758 address: &str,
759 org: Uuid,
760) -> Result<bool, HttpResponse> {
761 let Some(user_id) = find_user_by_identity(state, address).await? else {
762 return Ok(false);
763 };
764 let Some(membership_tbl) = table(state, "membership") else {
765 return Ok(false);
766 };
767 let sql = format!(
768 "SELECT 1 AS hit FROM {membership_tbl} \
769 WHERE user_id = $1::uuid AND organization_id = $2::uuid LIMIT 1"
770 );
771 let rows = state
772 .db
773 .raw_json(
774 &sql,
775 &[
776 Value::String(user_id.to_string()),
777 Value::String(org.to_string()),
778 ],
779 )
780 .await
781 .map_err(db_error)?;
782 Ok(rows.as_array().is_some_and(|rows| !rows.is_empty()))
783}
784
785async fn ensure_membership(
791 state: &AppState,
792 user_id: Uuid,
793 org: Uuid,
794 role: Option<&str>,
795) -> Result<(), HttpResponse> {
796 let Some(membership_r) = state.app.resources.get("membership") else {
797 return Err(error(500, "no membership resource"));
798 };
799 let Some(membership_tbl) = table(state, "membership") else {
800 return Err(error(500, "no membership resource"));
801 };
802
803 let sql = format!(
804 "SELECT 1 AS hit FROM {membership_tbl} \
805 WHERE user_id = $1::uuid AND organization_id = $2::uuid LIMIT 1"
806 );
807 let rows = state
808 .db
809 .raw_json(
810 &sql,
811 &[
812 Value::String(user_id.to_string()),
813 Value::String(org.to_string()),
814 ],
815 )
816 .await
817 .map_err(db_error)?;
818 if rows.as_array().is_some_and(|rows| !rows.is_empty()) {
819 return Ok(());
820 }
821
822 let mut data = Map::new();
823 data.insert("user_id".into(), Value::String(user_id.to_string()));
824 data.insert("organization_id".into(), Value::String(org.to_string()));
825 if let Some(role) = role.filter(|role| !role.is_empty()) {
826 data.insert("role".into(), Value::String(role.to_string()));
827 }
828 state
829 .db
830 .create(membership_r, &data)
831 .await
832 .map_err(db_error)
833 .map(|_| ())
834}
835
836async fn organization_name(state: &AppState, org: Uuid) -> String {
839 let fallback = || state.app.display_name();
840 let Some(org_tbl) = table(state, "organization") else {
841 return fallback();
842 };
843 let sql = format!("SELECT name FROM {org_tbl} WHERE id = $1::uuid LIMIT 1");
844 let rows = match state
845 .db
846 .raw_json(&sql, &[Value::String(org.to_string())])
847 .await
848 {
849 Ok(rows) => rows,
850 Err(_) => return fallback(),
851 };
852 rows.as_array()
853 .and_then(|rows| rows.first())
854 .and_then(|row| row.get("name"))
855 .and_then(|v| v.as_str())
856 .filter(|name| !name.is_empty())
857 .map(str::to_owned)
858 .unwrap_or_else(fallback)
859}
860
861async fn inviter_name(state: &AppState, user_id: Uuid) -> Option<String> {
864 let spec = auth_spec(state);
865 let user_r = state.app.resources.get("user")?;
866 let user_tbl = table(state, "user")?;
867
868 let mut candidates: Vec<String> = Vec::new();
869 for name in ["display_name", "name"] {
870 if user_r.fields.contains_key(name) {
871 candidates.push(quote(name));
872 }
873 }
874 candidates.push(quote(&spec.identity_field));
875
876 let sql = format!(
877 "SELECT coalesce({}) AS who FROM {user_tbl} WHERE id = $1::uuid LIMIT 1",
878 candidates.join(", "),
879 );
880 let rows = state
881 .db
882 .raw_json(&sql, &[Value::String(user_id.to_string())])
883 .await
884 .ok()?;
885 rows.as_array()?
886 .first()?
887 .get("who")?
888 .as_str()
889 .filter(|who| !who.is_empty())
890 .map(str::to_owned)
891}
892
893async fn send(state: &AppState, message: apiplant_email::Message) -> Result<(), HttpResponse> {
899 let Some(mailer) = &state.mailer else {
900 return Err(error(502, "this server cannot send email"));
903 };
904 match mailer.send(&message).await {
905 Ok(_) => Ok(()),
906 Err(e) => {
907 tracing::error!(error = %e, "could not send an email");
908 Err(error(502, "could not send the email — try again shortly"))
909 }
910 }
911}
912
913fn accepted(message: &str) -> HttpResponse {
915 HttpResponse::Accepted().json(&json!({ "message": message }))
916}
917
918fn rfc3339_in(secs: i64) -> String {
921 let at: DateTime<Utc> = Utc::now() + Duration::seconds(secs);
922 at.to_rfc3339()
923}
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928 use apiplant_core::Access;
929
930 fn principal(org: Uuid, role: &str) -> apiplant_auth::Principal {
931 apiplant_auth::Principal {
932 user_id: Uuid::new_v4(),
933 organizations: vec![apiplant_auth::OrgMembership::new(
934 org,
935 Some(role.to_string()),
936 [],
937 )],
938 }
939 }
940
941 #[test]
942 fn who_may_invite_follows_who_may_add_a_member() {
943 let org = Uuid::new_v4();
944
945 let admins = Access::Role("admin".into());
947 assert!(invite_policy(Some(&admins), &principal(org, "admin"), org));
948 assert!(!invite_policy(Some(&admins), &principal(org, "member"), org));
949
950 let members = Access::Member;
953 assert!(invite_policy(Some(&members), &principal(org, "member"), org));
954
955 assert!(!invite_policy(
957 Some(&members),
958 &principal(org, "member"),
959 Uuid::new_v4()
960 ));
961 }
962
963 #[test]
964 fn a_policy_that_is_not_a_role_check_falls_back_to_admin() {
965 let org = Uuid::new_v4();
966 for policy in [Some(&Access::Public), None] {
970 assert!(!invite_policy(policy, &principal(org, "member"), org));
971 assert!(invite_policy(policy, &principal(org, "admin"), org));
972 }
973 }
974}