Skip to main content

cratefield_module_orgs/
lib.rs

1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3
4pub mod audit;
5mod clock;
6pub mod grants;
7mod handlers;
8mod mail;
9mod service;
10pub mod service_accounts;
11mod store;
12
13use std::sync::OnceLock;
14
15use async_trait::async_trait;
16use http::HeaderMap;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use time::Duration;
21
22use cratefield_core::{
23    AnyError, BoxFuture, Config, ConfigError, DataKind, Database, Disposition, Migrations, Module,
24    ModuleContext, PersonalDataSet, Port, Problem, SLUGS, SqlMigration, SubjectVia, Surface,
25};
26
27use handlers::Settings;
28
29pub use mail::{InvitationMail, TEMPLATE_INVITATION, default_templates, themed_templates};
30
31/// The module's name: it is mounted at `/v1/orgs`, and its config keys are
32/// prefixed `ORGS_`.
33pub const MODULE_NAME: &str = "orgs";
34
35/// The module's one migration: the three tables in the portable SQL subset
36/// (ADR 0004). Portable means it is also the set the Postgres runner applies,
37/// so a `postgres` override is only needed if the SQL ever truly diverges.
38const MIGRATION_INIT: SqlMigration = SqlMigration::new(
39    "0001",
40    "init",
41    include_str!("../migrations/sqlite/0001_init.sql"),
42);
43
44/// Service accounts and their keys (issue #878): [`service_accounts`].
45/// Public so a venture with its own identity, which does not mount this
46/// module's routes, can apply the table under its own module.
47pub const MIGRATION_SERVICE_ACCOUNTS: SqlMigration = SqlMigration::new(
48    "0002",
49    "service-accounts",
50    include_str!("../migrations/sqlite/0002_service_accounts.sql"),
51);
52
53/// Access grants (issue #878): [`grants`]. Public for the reason
54/// [`MIGRATION_SERVICE_ACCOUNTS`] is.
55pub const MIGRATION_GRANTS: SqlMigration = SqlMigration::new(
56    "0003",
57    "grants",
58    include_str!("../migrations/sqlite/0003_grants.sql"),
59);
60
61/// The audit log (issue #878): [`audit`]. Public for the reason
62/// [`MIGRATION_SERVICE_ACCOUNTS`] is.
63pub const MIGRATION_AUDIT: SqlMigration = SqlMigration::new(
64    "0004",
65    "audit",
66    include_str!("../migrations/sqlite/0004_audit.sql"),
67);
68
69/// The personal-data declarations for the three issue #878 tables, for a
70/// venture that applies [`MIGRATION_SERVICE_ACCOUNTS`], [`MIGRATION_GRANTS`]
71/// and [`MIGRATION_AUDIT`] under a module of its own and must declare what
72/// they hold. [`Orgs`] declares the same four sets.
73pub const ACCESS_PERSONAL_DATA: [PersonalDataSet; 4] = [
74    PersonalDataSet {
75        table: service_accounts::ACCOUNTS_TABLE,
76        subject: "owner_sub",
77        kind: DataKind::Identifier,
78        disposition: Disposition::Erase,
79        description: "A service account you own: its name, what it is for, who made it, and \
80                      whether it is disabled. Erasing your account removes the service \
81                      accounts you own.",
82        redacted: &["created_by"],
83        subject_via: None,
84    },
85    PersonalDataSet {
86        table: service_accounts::KEYS_TABLE,
87        subject: "account_id",
88        kind: DataKind::Identifier,
89        disposition: Disposition::Erase,
90        description: "The API keys of a service account you own: the public prefix, the \
91                      label, the scopes, and when each was made, last used and revoked. The \
92                      key itself is held only as a hash.",
93        redacted: &["secret_hash"],
94        subject_via: Some(SubjectVia {
95            table: service_accounts::ACCOUNTS_TABLE,
96            subject: "owner_sub",
97            key: "id",
98        }),
99    },
100    PersonalDataSet {
101        table: grants::TABLE,
102        subject: "principal",
103        kind: DataKind::Identifier,
104        disposition: Disposition::Erase,
105        description: "The access you were given or refused: which part of an organization's \
106                      data, at which permission, until when, and who granted it.",
107        redacted: &["created_by"],
108        subject_via: None,
109    },
110    PersonalDataSet {
111        table: audit::TABLE,
112        subject: "actor_id",
113        kind: DataKind::Usage,
114        disposition: Disposition::Retain(
115            "An audit trail that an erasure could rewrite would no longer show who did what. \
116             Entries name ids and credential prefixes, never secrets or content.",
117        ),
118        description: "What you did in an organization that its administrators can review: \
119                      the action, what it touched, when, and with which credential prefix.",
120        redacted: &[],
121        subject_via: None,
122    },
123];
124
125/// The invitation lifetime when a venture sets none.
126const DEFAULT_INVITATION_TTL_SECS: i64 = 7 * 86_400;
127
128/// The lowercase-hex SHA-256 of a value. Both the invitation token and the
129/// invitee's normalized address reach the database only through this, so a
130/// dump, a backup or a query log holds nothing that can be redeemed or read
131/// back as an address.
132#[must_use]
133pub(crate) fn sha256_hex(value: &str) -> String {
134    hex::encode(Sha256::digest(value.as_bytes()))
135}
136
137// ---------------------------------------------------------------------------
138// The public types
139
140/// An organization, as a venture reads it.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
142pub struct Org {
143    /// The organization's id — stable, and what every other call takes.
144    pub id: String,
145    /// The name a person gave it.
146    pub name: String,
147    /// The subject that created it.
148    pub created_by: String,
149    /// When it was created, RFC 3339 UTC.
150    pub created_at: String,
151}
152
153/// An organization and the role the caller holds in it.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
155pub struct Membership {
156    pub org: Org,
157    pub role: String,
158}
159
160/// One membership, as a member of the organization reads it.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
162pub struct Member {
163    /// The account's subject id.
164    pub sub: String,
165    /// The role they hold here.
166    pub role: String,
167    /// Whoever added them, when it was not themselves.
168    pub invited_by: Option<String>,
169    /// When they joined, RFC 3339 UTC.
170    pub joined_at: String,
171}
172
173/// An authorized staff caller, as [`require_staff`] returns it.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct Staff {
176    /// The staff member's subject, when a person proved it. `None` for a
177    /// machine holding `ADMIN_TOKEN`.
178    pub subject: Option<String>,
179    /// The role they hold in the staff organization, when a person.
180    pub role: Option<String>,
181}
182
183impl Staff {
184    /// Whether this caller was a machine token rather than a person.
185    #[must_use]
186    pub fn is_machine(&self) -> bool {
187        self.subject.is_none()
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Errors
193
194/// What an orgs entry point can fail with.
195#[derive(Debug, thiserror::Error)]
196#[non_exhaustive]
197pub enum OrgsError {
198    /// No such organization, no such member, or the caller is not a member.
199    /// One variant on purpose: a caller who is not in an organization must not
200    /// be able to tell it from one that does not exist.
201    #[error("no organization or member matching `{0}` is visible to the caller")]
202    NotFound(String),
203    /// The caller is a member, but their role does not permit this.
204    #[error("{0}")]
205    Forbidden(String),
206    /// The role is not one the venture configured.
207    #[error("`{0}` is not a configured role")]
208    UnknownRole(String),
209    /// The organization's name was empty.
210    #[error("an organization needs a name")]
211    InvalidName,
212    /// The address to invite is not an address.
213    #[error("`{0}` is not a valid email address")]
214    InvalidEmail(String),
215    /// A field was longer than the module accepts.
216    #[error("`{field}` must be at most {max} characters")]
217    TooLong { field: &'static str, max: usize },
218    /// The change would leave the organization with no owner.
219    #[error("this is the organization's last owner")]
220    LastOwner,
221    /// The account is already a member.
222    #[error("`{0}` is already a member")]
223    AlreadyMember(String),
224    /// The invitation is unknown, lapsed or already spent.
225    #[error("the invitation is unknown, expired or already accepted")]
226    InvitationGone,
227    /// The invitation was issued to a different address.
228    #[error("the invitation was issued to a different address")]
229    InvitationForSomeoneElse,
230    /// The credential proved no address, so no invitation can be matched.
231    #[error("the caller's email address is not verified")]
232    EmailUnverified,
233    /// The deployment has no mailer, or the mailer has no verified domain.
234    #[error("mail is not configured")]
235    MailNotConfigured,
236    /// The provider refused the invitation message.
237    #[error("the invitation message was refused: {0}")]
238    Mail(String),
239    /// The module is misconfigured.
240    #[error("orgs is misconfigured: {0}")]
241    Config(String),
242    /// A database failure.
243    #[error(transparent)]
244    Db(#[from] cratefield_core::DbError),
245}
246
247impl OrgsError {
248    /// The RFC 9457 `type` slug this failure maps to.
249    #[must_use]
250    pub fn slug(&self) -> &'static str {
251        handlers::problem_for(self).slug
252    }
253
254    /// The problem body a caller should answer with.
255    #[must_use]
256    pub fn problem(&self) -> Problem {
257        handlers::problem_for(self)
258    }
259}
260
261// ---------------------------------------------------------------------------
262// The typed in-process API
263
264/// The role `sub` holds in `org_id`, or `None` when they are not a member —
265/// the one call an external app needs to answer "may this person see this
266/// organization's page", without going through HTTP.
267///
268/// # Errors
269///
270/// [`OrgsError::Db`] when the query fails; a caller with no role and a caller
271/// whose organization does not exist both answer `Ok(None)`, which is the same
272/// deliberate silence the routes keep.
273pub async fn member_role(
274    db: &dyn Database,
275    org_id: &str,
276    sub: &str,
277) -> Result<Option<String>, OrgsError> {
278    Ok(store::find_member(db, org_id, sub)
279        .await?
280        .map(|member| member.role))
281}
282
283/// Authorizes a staff caller for an admin action: a machine holding
284/// `ADMIN_TOKEN`, or a person whose verified subject is a member of `staff_org`
285/// holding one of `roles`.
286///
287/// `ADMIN_TOKEN` stays for machines — a cron, a CLI, a back-office script that
288/// has no account. People move to the staff organization, where membership is
289/// revocable the same way any other membership is: no shared secret to rotate,
290/// and the person's departure is a `DELETE` away.
291///
292/// # Errors
293///
294/// `401 admin-unauthorized` when `ADMIN_TOKEN` is the only configured path and
295/// nothing acceptable was presented, `403 admin-forbidden` when a verified
296/// caller is not staff, and `500` when the database cannot be read.
297pub async fn require_staff(
298    ctx: &ModuleContext,
299    headers: &HeaderMap,
300    staff_org: &str,
301    roles: &[&str],
302) -> Result<Staff, Problem> {
303    // The machine path first and unchanged: a deployment that has an
304    // `ADMIN_TOKEN` keeps the machine it was written for.
305    if cratefield_core::require_admin(&*ctx.config, headers).is_ok() {
306        return Ok(Staff {
307            subject: None,
308            role: None,
309        });
310    }
311    if staff_org.is_empty() || roles.is_empty() {
312        // No staff organization to move to: report the admin-token outcome,
313        // so the answer is the 401 or 403 the token path would have given.
314        return cratefield_core::require_admin(&*ctx.config, headers).map(|()| Staff {
315            subject: None,
316            role: None,
317        });
318    }
319
320    let Some(auth) = ctx.ports.auth.clone() else {
321        return Err(Problem::new(&SLUGS.admin_unauthorized));
322    };
323    let subject = match auth.identify(headers).await {
324        Ok(caller) => caller.subject().map(|subject| subject.id.clone()),
325        Err(_) => None,
326    };
327    let Some(subject) = subject else {
328        // Anonymous, or a credential that did not verify. Both are a caller
329        // who has not been established as anybody.
330        return Err(Problem::new(&SLUGS.admin_unauthorized));
331    };
332
333    let db = ctx.ports.db.as_deref().ok_or_else(Problem::internal)?;
334    let role = member_role(db, staff_org, &subject)
335        .await
336        .map_err(|error| {
337            tracing::error!(error = %error, "reading a staff membership failed");
338            Problem::internal()
339        })?;
340    match role {
341        Some(role) if roles.iter().any(|allowed| *allowed == role) => Ok(Staff {
342            subject: Some(subject),
343            role: Some(role),
344        }),
345        // A signed-in person who is not staff: a 403, never a 401, so the
346        // answer does not tell them to sign in again.
347        _ => Err(Problem::new(&SLUGS.admin_forbidden)),
348    }
349}
350
351// ---------------------------------------------------------------------------
352// The module and its builder
353
354/// The organizations module (issue #652): organizations, memberships, roles
355/// and email invitations. Compose it with [`Orgs::builder`].
356pub struct Orgs {
357    settings: Settings,
358}
359
360impl Default for Orgs {
361    fn default() -> Self {
362        Self::builder().build()
363    }
364}
365
366impl Orgs {
367    /// The module with its defaults: the `owner` role alone, no manager roles,
368    /// no staff organization. The same as `builder().build()`, and what a
369    /// generated manifest mounts when it has nothing to configure.
370    #[must_use]
371    pub fn new() -> Self {
372        Self::default()
373    }
374
375    /// A builder with the default role set: `owner` alone. Named `builder`
376    /// rather than `new`, like every other module in this workspace, because
377    /// it returns an [`OrgsBuilder`] rather than the module itself.
378    #[must_use]
379    pub fn builder() -> OrgsBuilder {
380        OrgsBuilder::new()
381    }
382
383    /// The role set, for a caller that wants to publish it.
384    #[must_use]
385    pub fn roles(&self) -> &[String] {
386        &self.settings.roles
387    }
388}
389
390/// Builds an [`Orgs`].
391pub struct OrgsBuilder {
392    settings: Settings,
393}
394
395impl OrgsBuilder {
396    fn new() -> Self {
397        Self {
398            settings: Settings {
399                roles: vec![store::OWNER.to_owned()],
400                managers: Vec::new(),
401                staff_org: String::new(),
402                staff_roles: Vec::new(),
403                invitation_ttl_secs: DEFAULT_INVITATION_TTL_SECS,
404            },
405        }
406    }
407
408    /// The roles an organization may use, e.g.
409    /// `Orgs::builder().roles(["owner", "manager", "staff"])`.
410    ///
411    /// `owner` is always in the set — it is added here if it was omitted — and
412    /// is the role the creator gets. A role a venture did not name is refused
413    /// with `422 orgs-unknown-role` rather than stored, so a typo cannot leave
414    /// an organization holding a role nothing can interpret.
415    #[must_use]
416    pub fn roles<I, S>(mut self, roles: I) -> Self
417    where
418        I: IntoIterator<Item = S>,
419        S: Into<String>,
420    {
421        let mut out: Vec<String> = Vec::new();
422        for role in roles {
423            let role = role.into();
424            if !role.trim().is_empty() && !out.contains(&role) {
425                out.push(role);
426            }
427        }
428        if !out.iter().any(|role| role == store::OWNER) {
429            out.insert(0, store::OWNER.to_owned());
430        }
431        self.settings.roles = out;
432        self
433    }
434
435    /// The non-owner roles that may manage members and invitations: add,
436    /// remove, change roles, and invite. Owners may always manage; only an
437    /// owner may grant, change or remove the `owner` role, and only an owner
438    /// may touch an owner's membership. The default is that only owners manage.
439    #[must_use]
440    pub fn managers<I, S>(mut self, managers: I) -> Self
441    where
442        I: IntoIterator<Item = S>,
443        S: Into<String>,
444    {
445        self.settings.managers = managers.into_iter().map(Into::into).collect();
446        self
447    }
448
449    /// The venture's staff organization — the one whose members may see the
450    /// admin listing. `ORGS_STAFF_ORG` overrides it per deployment.
451    #[must_use]
452    pub fn staff_org(mut self, org_id: impl Into<String>) -> Self {
453        self.settings.staff_org = org_id.into();
454        self
455    }
456
457    /// The roles in the staff organization that may see the admin listing.
458    /// Empty (the default) means the staff organization grants nothing.
459    #[must_use]
460    pub fn staff_roles<I, S>(mut self, roles: I) -> Self
461    where
462        I: IntoIterator<Item = S>,
463        S: Into<String>,
464    {
465        self.settings.staff_roles = roles.into_iter().map(Into::into).collect();
466        self
467    }
468
469    /// How long an invitation stays acceptable. Seven days by default; a
470    /// negative value is clamped to zero, which makes every invitation lapse
471    /// at once — a shape only a test wants.
472    #[must_use]
473    pub fn invitation_ttl(mut self, ttl: Duration) -> Self {
474        self.settings.invitation_ttl_secs = ttl.whole_seconds().max(0);
475        self
476    }
477
478    /// Finishes the module.
479    #[must_use]
480    pub fn build(self) -> Orgs {
481        Orgs {
482            settings: self.settings,
483        }
484    }
485}
486
487// ---------------------------------------------------------------------------
488// The module
489
490#[async_trait]
491impl Module for Orgs {
492    fn name(&self) -> &'static str {
493        MODULE_NAME
494    }
495
496    fn version(&self) -> &'static str {
497        env!("CARGO_PKG_VERSION")
498    }
499
500    /// Memberships are rows, so the module needs a database; every route but
501    /// the admin listing acts for the account a credential proves, so it needs
502    /// a verifier. A deployment without one refuses to boot rather than
503    /// serving an organization's roster to whoever asks.
504    fn requires(&self) -> &'static [Port] {
505        &[Port::Db, Port::Auth]
506    }
507
508    /// `Clock` decides "now" for expiry and single-use, `IdGen` mints ids and
509    /// the invitation token, and `Mailer` delivers the invitation. Each has a
510    /// working default or a documented absence, so none is required.
511    fn optional(&self) -> &'static [Port] {
512        &[Port::Clock, Port::IdGen, Port::Mailer]
513    }
514
515    fn tables(&self) -> &'static [&'static str] {
516        &[
517            "orgs",
518            "org_members",
519            "org_invitations",
520            service_accounts::ACCOUNTS_TABLE,
521            service_accounts::KEYS_TABLE,
522            grants::TABLE,
523            audit::TABLE,
524        ]
525    }
526
527    /// Three tables, one declaration each — `HarnessBuilder::build` refuses a
528    /// table declared twice, so a column that names a *second* person cannot
529    /// get a declaration of its own and the choice has to be made once per
530    /// table:
531    ///
532    /// - `org_members` is the member's. `Erase` is the only disposition that
533    ///   leaves no membership pointing at an account that no longer exists.
534    ///   Its `invited_by` names whoever added them — a *second* person on the
535    ///   member's row — so it is named in `redacted`: an export of the row
536    ///   shows the column and not the id. Nothing erases it, and the reason is
537    ///   in the declaration rather than left to be discovered: the framework
538    ///   blanks columns only on the rows a subject's own value matches.
539    /// - `orgs` is retained: the row is not one person's — deleting it would
540    ///   delete every other member's organization — and `created_by` is kept
541    ///   as provenance. Erasure of the creator's account does not remove the
542    ///   organization, which is what `Retain` says.
543    /// - `org_invitations` is the inviter's. The row is keyed by `invited_by`,
544    ///   so erasing the inviter takes the invitations they sent; the invitee
545    ///   is held only as the SHA-256 of their address, which is not a value an
546    ///   erasure request can carry, so there is nothing to erase *for* them
547    ///   and nothing of theirs left to read.
548    fn personal_data(&self) -> &'static [PersonalDataSet] {
549        static SETS: OnceLock<Vec<PersonalDataSet>> = OnceLock::new();
550        SETS.get_or_init(|| {
551            vec![
552                PersonalDataSet {
553                    table: "orgs",
554                    subject: "created_by",
555                    kind: DataKind::Identifier,
556                    disposition: Disposition::Retain(
557                        "An organization is not one person's: removing the row would remove \
558                         every other member's organization with it. The creator is kept as \
559                         provenance, and the name belongs to the organization.",
560                    ),
561                    description: "An organization you created: its name, when it was made, \
562                                  and the fact that you made it. It is kept because its other \
563                                  members' data is inside it.",
564                    redacted: &[],
565                    subject_via: None,
566                },
567                PersonalDataSet {
568                    table: "org_members",
569                    subject: "user_sub",
570                    kind: DataKind::Identifier,
571                    disposition: Disposition::Erase,
572                    description: "Your membership in an organization: the role you hold, when \
573                                  you joined, and who added you if it was not you. Erasing \
574                                  your account removes the membership.",
575                    // The `invited_by` on somebody *else's* row names you: an
576                    // export of their membership shows the column and not your
577                    // id. Nothing erases it — see the note above the sets.
578                    redacted: &["invited_by"],
579                    subject_via: None,
580                },
581                PersonalDataSet {
582                    table: "org_invitations",
583                    subject: "invited_by",
584                    kind: DataKind::Contact,
585                    disposition: Disposition::Erase,
586                    description: "An invitation you sent: the organization, the role offered, \
587                                  an address held only as a hash, and when the link lapses. \
588                                  Erasing your account removes the invitations you sent.",
589                    redacted: &[],
590                    subject_via: None,
591                },
592            ]
593            .into_iter()
594            .chain(ACCESS_PERSONAL_DATA)
595            .collect()
596        })
597        .as_slice()
598    }
599
600    fn migrations(&self) -> Migrations {
601        const MIGRATIONS: [SqlMigration; 4] = [
602            MIGRATION_INIT,
603            MIGRATION_SERVICE_ACCOUNTS,
604            MIGRATION_GRANTS,
605            MIGRATION_AUDIT,
606        ];
607        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
608        Migrations::sqlite(&MIGRATIONS)
609    }
610
611    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
612        handlers::validate(&self.settings, cfg).into_result()
613    }
614
615    fn self_check(&self) -> Vec<String> {
616        handlers::self_check(&self.settings)
617    }
618
619    fn router(&self, ctx: ModuleContext) -> axum::Router {
620        handlers::router(std::sync::Arc::new(ctx), self.settings.clone())
621    }
622
623    fn surface(&self) -> Surface {
624        handlers::surface()
625    }
626
627    /// The scheduled pass: delete invitations that have lapsed, so an
628    /// organization is not left holding addresses it can no longer invite
629    /// with. An accepted invitation is kept until it lapses — it is what the
630    /// accept path reads back to tell who spent the token, and it holds only
631    /// hashes.
632    fn scheduled<'a>(
633        &'a self,
634        ctx: &'a ModuleContext,
635        cron: &'a str,
636    ) -> BoxFuture<'a, Result<(), AnyError>> {
637        Box::pin(async move {
638            if let Err(error) = service::maintain(ctx).await {
639                tracing::warn!(cron, error = %error, "the scheduled orgs pass did not complete");
640            }
641            Ok(())
642        })
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    /// `owner` is in the set whether or not the venture named it, and one
651    /// blank or repeated role does not become a role an organization can hold.
652    #[test]
653    fn the_role_set_always_holds_owner_and_never_a_blank() {
654        let builder = Orgs::builder().roles(["manager", "manager", " ", "staff"]);
655        assert_eq!(builder.settings.roles, vec!["owner", "manager", "staff"]);
656
657        let builder = Orgs::builder().roles(Vec::<String>::new());
658        assert_eq!(builder.settings.roles, vec!["owner"]);
659    }
660
661    /// A manager role the venture named but did not declare a role for is a
662    /// composition mistake, not a role nothing can hold.
663    #[test]
664    fn a_manager_role_that_is_not_a_role_is_reported() {
665        let module = Orgs::builder()
666            .roles(["staff"])
667            .managers(["manager"])
668            .build();
669        let problems = module.self_check();
670        assert_eq!(problems.len(), 1, "one problem: {problems:?}");
671        assert!(problems[0].contains("manager"));
672    }
673
674    /// Staff roles without a staff organization grant nothing, so the builder
675    /// says so rather than letting the admin listing quietly never open.
676    #[test]
677    fn staff_roles_need_a_staff_organization() {
678        let module = Orgs::builder().staff_roles(["admin"]).build();
679        assert_eq!(module.self_check().len(), 1);
680        let module = Orgs::builder()
681            .staff_org("org-staff")
682            .staff_roles(["admin"])
683            .build();
684        assert!(module.self_check().is_empty());
685    }
686
687    /// A three-day invitation is three days, and a negative one lapses at once
688    /// rather than underflowing into a lifetime.
689    #[test]
690    fn the_invitation_lifetime_is_what_the_builder_set() {
691        let module = Orgs::builder().invitation_ttl(Duration::days(3)).build();
692        assert_eq!(module.settings.invitation_ttl_secs, 3 * 86_400);
693        let module = Orgs::builder()
694            .invitation_ttl(Duration::seconds(-5))
695            .build();
696        assert_eq!(module.settings.invitation_ttl_secs, 0);
697    }
698}