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 let ttl = state.app.config.auth.verification_ttl_secs;
372 let plaintext = mint_token(state, user_id, KIND_VERIFICATION, ttl).await?;
373 let message = emails::verification(
374 &Links::from_app(&state.app),
375 &plaintext,
376 &emails::humanise(ttl),
377 );
378 send(state, message.to(address)).await
379}
380
381pub async fn verify_email(state: State<AppState>, body: Json<Value>) -> HttpResponse {
386 let Some(token) = body.get("token").and_then(|v| v.as_str()) else {
387 return error(400, "`token` is required");
388 };
389 let user_id = match spend_token(&state, token, KIND_VERIFICATION).await {
390 Ok(id) => id,
391 Err(resp) => return resp,
392 };
393
394 let Some(user_tbl) = table(&state, "user") else {
395 return error(500, "missing user resource");
396 };
397 let sql = format!(
400 "UPDATE {user_tbl} SET {VERIFIED_AT_FIELD} = coalesce({VERIFIED_AT_FIELD}, now()) \
401 WHERE id = $1::uuid"
402 );
403 if let Err(e) = state
404 .db
405 .raw_json(&sql, &[Value::String(user_id.to_string())])
406 .await
407 {
408 return db_error(e);
409 }
410
411 match state.auth.issue_token(user_id) {
412 Ok(session) => HttpResponse::Ok().json(&json!({ "token": session, "verified": true })),
413 Err(_) => error(500, "failed to issue token"),
414 }
415}
416
417pub async fn resend_verification(state: State<AppState>, body: Json<Value>) -> HttpResponse {
423 let spec = auth_spec(&state);
424 let address = body
425 .get("email")
426 .or_else(|| body.get(&spec.identity_field))
427 .and_then(|v| v.as_str())
428 .map(str::trim)
429 .unwrap_or_default()
430 .to_string();
431
432 if !address.is_empty() {
433 if let Ok(Some(user_id)) = find_unverified_user(&state, &address).await {
434 if let Err(_resp) = send_verification(&state, user_id, &address).await {
437 tracing::warn!("could not send a verification email");
438 }
439 }
440 }
441
442 accepted("If that address needs confirming, a new link is on its way.")
443}
444
445pub async fn forgot_password(state: State<AppState>, body: Json<Value>) -> HttpResponse {
451 let spec = auth_spec(&state);
452 let address = body
453 .get("email")
454 .or_else(|| body.get(&spec.identity_field))
455 .and_then(|v| v.as_str())
456 .map(str::trim)
457 .unwrap_or_default()
458 .to_string();
459
460 if !address.is_empty() {
461 if let Ok(Some(user_id)) = find_user_by_identity(&state, &address).await {
462 let ttl = state.app.config.auth.password_reset_ttl_secs;
463 match mint_token(&state, user_id, KIND_RESET, ttl).await {
464 Ok(plaintext) => {
465 let message = emails::password_reset(
466 &Links::from_app(&state.app),
467 &plaintext,
468 &emails::humanise(ttl),
469 );
470 if send(&state, message.to(&address)).await.is_err() {
471 tracing::warn!("could not send a password reset email");
472 }
473 }
474 Err(_) => tracing::warn!("could not mint a password reset token"),
475 }
476 }
477 }
478
479 accepted("If that address has an account, a reset link is on its way.")
480}
481
482pub async fn reset_password(state: State<AppState>, body: Json<Value>) -> HttpResponse {
491 let Some(token) = body.get("token").and_then(|v| v.as_str()) else {
492 return error(400, "`token` is required");
493 };
494 let Some(password) = body
495 .get("password")
496 .and_then(|v| v.as_str())
497 .filter(|p| !p.is_empty())
498 else {
499 return error(400, "`password` is required");
500 };
501
502 let user_id = match spend_token(&state, token, KIND_RESET).await {
503 Ok(id) => id,
504 Err(resp) => return resp,
505 };
506 let hash = match state.auth.hash_password(password) {
507 Ok(hash) => hash,
508 Err(_) => return error(500, "failed to hash password"),
509 };
510
511 let spec = auth_spec(&state);
512 let Some(user_tbl) = table(&state, "user") else {
513 return error(500, "missing user resource");
514 };
515 let sql = format!(
516 "UPDATE {user_tbl} \
517 SET {pw} = $1, {VERIFIED_AT_FIELD} = coalesce({VERIFIED_AT_FIELD}, now()) \
518 WHERE id = $2::uuid",
519 pw = quote(&spec.password_field),
520 );
521 if let Err(e) = state
522 .db
523 .raw_json(
524 &sql,
525 &[Value::String(hash), Value::String(user_id.to_string())],
526 )
527 .await
528 {
529 return db_error(e);
530 }
531
532 if let Some(token_tbl) = table(&state, "auth_token") {
533 let sql = format!(
534 "UPDATE {token_tbl} SET used_at = now() \
535 WHERE user_id = $1::uuid AND kind = $2 AND used_at IS NULL"
536 );
537 let _ = state
538 .db
539 .raw_json(
540 &sql,
541 &[
542 Value::String(user_id.to_string()),
543 Value::String(KIND_RESET.into()),
544 ],
545 )
546 .await;
547 }
548
549 match state.auth.issue_token(user_id) {
550 Ok(session) => HttpResponse::Ok().json(&json!({ "token": session })),
551 Err(_) => error(500, "failed to issue token"),
552 }
553}
554
555fn may_invite(state: &AppState, principal: &apiplant_auth::Principal, org: Uuid) -> bool {
565 invite_policy(
566 state
567 .app
568 .resources
569 .get("membership")
570 .map(|membership| &membership.permissions.create),
571 principal,
572 org,
573 )
574}
575
576fn invite_policy(
579 create: Option<&apiplant_core::Access>,
580 principal: &apiplant_auth::Principal,
581 org: Uuid,
582) -> bool {
583 use apiplant_core::Access;
584 match create {
585 Some(Access::Role(role)) => principal.has_role_in(org, role),
586 Some(Access::Member | Access::Owner | Access::Authenticated) => principal.is_member(org),
587 _ => principal.is_admin_of(org),
588 }
589}
590
591async fn live_invitation(state: &AppState, token: &str) -> Result<Value, HttpResponse> {
597 let Some(invitation_tbl) = table(state, "invitation") else {
598 return Err(error(500, "no invitation resource"));
599 };
600 let hash = Authenticator::hash_link_token(token.trim());
601 let sql = format!(
602 "SELECT id::text AS id, email, role, organization_id::text AS organization_id, \
603 expires_at::text AS expires_at \
604 FROM {invitation_tbl} \
605 WHERE token_hash = $1 AND accepted_at IS NULL AND expires_at > now() \
606 LIMIT 1"
607 );
608 let rows = state
609 .db
610 .raw_json(&sql, &[Value::String(hash)])
611 .await
612 .map_err(db_error)?;
613 match rows.as_array().and_then(|rows| rows.first()) {
614 Some(row) => Ok(row.clone()),
615 None => Err(error(404, "this invitation is no longer valid")),
616 }
617}
618
619async fn mint_token(
621 state: &AppState,
622 user_id: Uuid,
623 kind: &str,
624 ttl_secs: u64,
625) -> Result<String, HttpResponse> {
626 let Some(token_r) = state.app.resources.get("auth_token") else {
627 return Err(error(500, "no auth_token resource"));
628 };
629 let prefix = if kind == KIND_RESET {
630 "reset"
631 } else {
632 "verify"
633 };
634 let (plaintext, hash) = Authenticator::generate_link_token(prefix);
635
636 let mut data = Map::new();
637 data.insert("user_id".into(), Value::String(user_id.to_string()));
638 data.insert("kind".into(), Value::String(kind.to_string()));
639 data.insert("token_hash".into(), Value::String(hash));
640 data.insert(
641 "expires_at".into(),
642 Value::String(rfc3339_in(ttl_secs as i64)),
643 );
644
645 state
646 .db
647 .create(token_r, &data)
648 .await
649 .map_err(db_error)
650 .map(|_| plaintext)
651}
652
653async fn spend_token(state: &AppState, token: &str, kind: &str) -> Result<Uuid, HttpResponse> {
661 let Some(token_tbl) = table(state, "auth_token") else {
662 return Err(error(500, "no auth_token resource"));
663 };
664 let hash = Authenticator::hash_link_token(token.trim());
665 let params = [Value::String(hash), Value::String(kind.to_string())];
666
667 let claim = format!(
668 "UPDATE {token_tbl} SET used_at = now() \
669 WHERE token_hash = $1 AND kind = $2 AND used_at IS NULL AND expires_at > now()"
670 );
671 let claimed = state
672 .db
673 .raw_json(&claim, ¶ms)
674 .await
675 .map_err(db_error)?
676 .get("rows_affected")
677 .and_then(|v| v.as_u64())
678 .unwrap_or(0);
679 if claimed == 0 {
680 return Err(error(404, "this link is no longer valid"));
681 }
682
683 let lookup = format!(
684 "SELECT user_id::text AS user_id FROM {token_tbl} \
685 WHERE token_hash = $1 AND kind = $2 LIMIT 1"
686 );
687 let rows = state
688 .db
689 .raw_json(&lookup, ¶ms)
690 .await
691 .map_err(db_error)?;
692
693 rows.as_array()
694 .and_then(|rows| rows.first())
695 .and_then(|row| row.get("user_id"))
696 .and_then(|v| v.as_str())
697 .and_then(|s| Uuid::parse_str(s).ok())
698 .ok_or_else(|| error(404, "this link is no longer valid"))
699}
700
701async fn find_user_by_identity(
707 state: &AppState,
708 address: &str,
709) -> Result<Option<Uuid>, HttpResponse> {
710 let spec = auth_spec(state);
711 let Some(user_tbl) = table(state, "user") else {
712 return Err(error(500, "missing user resource"));
713 };
714 let sql = format!(
715 "SELECT id::text AS id FROM {user_tbl} WHERE lower({ident}) = lower($1) LIMIT 1",
716 ident = quote(&spec.identity_field),
717 );
718 let rows = state
719 .db
720 .raw_json(&sql, &[Value::String(address.to_string())])
721 .await
722 .map_err(db_error)?;
723 Ok(rows
724 .as_array()
725 .and_then(|rows| rows.first())
726 .and_then(|row| row.get("id"))
727 .and_then(|v| v.as_str())
728 .and_then(|s| Uuid::parse_str(s).ok()))
729}
730
731async fn find_unverified_user(
735 state: &AppState,
736 address: &str,
737) -> Result<Option<Uuid>, HttpResponse> {
738 let spec = auth_spec(state);
739 let Some(user_tbl) = table(state, "user") else {
740 return Err(error(500, "missing user resource"));
741 };
742 let sql = format!(
743 "SELECT id::text AS id FROM {user_tbl} \
744 WHERE lower({ident}) = lower($1) AND {VERIFIED_AT_FIELD} IS NULL LIMIT 1",
745 ident = quote(&spec.identity_field),
746 );
747 let rows = state
748 .db
749 .raw_json(&sql, &[Value::String(address.to_string())])
750 .await
751 .map_err(db_error)?;
752 Ok(rows
753 .as_array()
754 .and_then(|rows| rows.first())
755 .and_then(|row| row.get("id"))
756 .and_then(|v| v.as_str())
757 .and_then(|s| Uuid::parse_str(s).ok()))
758}
759
760async fn already_a_member(
762 state: &AppState,
763 address: &str,
764 org: Uuid,
765) -> Result<bool, HttpResponse> {
766 let Some(user_id) = find_user_by_identity(state, address).await? else {
767 return Ok(false);
768 };
769 let Some(membership_tbl) = table(state, "membership") else {
770 return Ok(false);
771 };
772 let sql = format!(
773 "SELECT 1 AS hit FROM {membership_tbl} \
774 WHERE user_id = $1::uuid AND organization_id = $2::uuid LIMIT 1"
775 );
776 let rows = state
777 .db
778 .raw_json(
779 &sql,
780 &[
781 Value::String(user_id.to_string()),
782 Value::String(org.to_string()),
783 ],
784 )
785 .await
786 .map_err(db_error)?;
787 Ok(rows.as_array().is_some_and(|rows| !rows.is_empty()))
788}
789
790async fn ensure_membership(
796 state: &AppState,
797 user_id: Uuid,
798 org: Uuid,
799 role: Option<&str>,
800) -> Result<(), HttpResponse> {
801 let Some(membership_r) = state.app.resources.get("membership") else {
802 return Err(error(500, "no membership resource"));
803 };
804 let Some(membership_tbl) = table(state, "membership") else {
805 return Err(error(500, "no membership resource"));
806 };
807
808 let sql = format!(
809 "SELECT 1 AS hit FROM {membership_tbl} \
810 WHERE user_id = $1::uuid AND organization_id = $2::uuid LIMIT 1"
811 );
812 let rows = state
813 .db
814 .raw_json(
815 &sql,
816 &[
817 Value::String(user_id.to_string()),
818 Value::String(org.to_string()),
819 ],
820 )
821 .await
822 .map_err(db_error)?;
823 if rows.as_array().is_some_and(|rows| !rows.is_empty()) {
824 return Ok(());
825 }
826
827 let mut data = Map::new();
828 data.insert("user_id".into(), Value::String(user_id.to_string()));
829 data.insert("organization_id".into(), Value::String(org.to_string()));
830 if let Some(role) = role.filter(|role| !role.is_empty()) {
831 data.insert("role".into(), Value::String(role.to_string()));
832 }
833 state
834 .db
835 .create(membership_r, &data)
836 .await
837 .map_err(db_error)
838 .map(|_| ())
839}
840
841async fn organization_name(state: &AppState, org: Uuid) -> String {
844 let fallback = || state.app.display_name();
845 let Some(org_tbl) = table(state, "organization") else {
846 return fallback();
847 };
848 let sql = format!("SELECT name FROM {org_tbl} WHERE id = $1::uuid LIMIT 1");
849 let rows = match state
850 .db
851 .raw_json(&sql, &[Value::String(org.to_string())])
852 .await
853 {
854 Ok(rows) => rows,
855 Err(_) => return fallback(),
856 };
857 rows.as_array()
858 .and_then(|rows| rows.first())
859 .and_then(|row| row.get("name"))
860 .and_then(|v| v.as_str())
861 .filter(|name| !name.is_empty())
862 .map(str::to_owned)
863 .unwrap_or_else(fallback)
864}
865
866async fn inviter_name(state: &AppState, user_id: Uuid) -> Option<String> {
869 let spec = auth_spec(state);
870 let user_r = state.app.resources.get("user")?;
871 let user_tbl = table(state, "user")?;
872
873 let mut candidates: Vec<String> = Vec::new();
874 for name in ["display_name", "name"] {
875 if user_r.fields.contains_key(name) {
876 candidates.push(quote(name));
877 }
878 }
879 candidates.push(quote(&spec.identity_field));
880
881 let sql = format!(
882 "SELECT coalesce({}) AS who FROM {user_tbl} WHERE id = $1::uuid LIMIT 1",
883 candidates.join(", "),
884 );
885 let rows = state
886 .db
887 .raw_json(&sql, &[Value::String(user_id.to_string())])
888 .await
889 .ok()?;
890 rows.as_array()?
891 .first()?
892 .get("who")?
893 .as_str()
894 .filter(|who| !who.is_empty())
895 .map(str::to_owned)
896}
897
898async fn send(state: &AppState, message: apiplant_email::Message) -> Result<(), HttpResponse> {
904 let Some(mailer) = &state.mailer else {
905 return Err(error(502, "this server cannot send email"));
908 };
909 match mailer.send(&message).await {
910 Ok(_) => Ok(()),
911 Err(e) => {
912 tracing::error!(error = %e, "could not send an email");
913 Err(error(502, "could not send the email — try again shortly"))
914 }
915 }
916}
917
918fn accepted(message: &str) -> HttpResponse {
920 HttpResponse::Accepted().json(&json!({ "message": message }))
921}
922
923fn rfc3339_in(secs: i64) -> String {
926 let at: DateTime<Utc> = Utc::now() + Duration::seconds(secs);
927 at.to_rfc3339()
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933 use apiplant_core::Access;
934
935 fn principal(org: Uuid, role: &str) -> apiplant_auth::Principal {
936 apiplant_auth::Principal {
937 user_id: Uuid::new_v4(),
938 organizations: vec![apiplant_auth::OrgMembership::new(
939 org,
940 Some(role.to_string()),
941 [],
942 )],
943 }
944 }
945
946 #[test]
947 fn who_may_invite_follows_who_may_add_a_member() {
948 let org = Uuid::new_v4();
949
950 let admins = Access::Role("admin".into());
952 assert!(invite_policy(Some(&admins), &principal(org, "admin"), org));
953 assert!(!invite_policy(
954 Some(&admins),
955 &principal(org, "member"),
956 org
957 ));
958
959 let members = Access::Member;
962 assert!(invite_policy(
963 Some(&members),
964 &principal(org, "member"),
965 org
966 ));
967
968 assert!(!invite_policy(
970 Some(&members),
971 &principal(org, "member"),
972 Uuid::new_v4()
973 ));
974 }
975
976 #[test]
977 fn a_policy_that_is_not_a_role_check_falls_back_to_admin() {
978 let org = Uuid::new_v4();
979 for policy in [Some(&Access::Public), None] {
983 assert!(!invite_policy(policy, &principal(org, "member"), org));
984 assert!(invite_policy(policy, &principal(org, "admin"), org));
985 }
986 }
987}