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
31pub const MODULE_NAME: &str = "orgs";
34
35const MIGRATION_INIT: SqlMigration = SqlMigration::new(
39 "0001",
40 "init",
41 include_str!("../migrations/sqlite/0001_init.sql"),
42);
43
44pub const MIGRATION_SERVICE_ACCOUNTS: SqlMigration = SqlMigration::new(
48 "0002",
49 "service-accounts",
50 include_str!("../migrations/sqlite/0002_service_accounts.sql"),
51);
52
53pub const MIGRATION_GRANTS: SqlMigration = SqlMigration::new(
56 "0003",
57 "grants",
58 include_str!("../migrations/sqlite/0003_grants.sql"),
59);
60
61pub const MIGRATION_AUDIT: SqlMigration = SqlMigration::new(
64 "0004",
65 "audit",
66 include_str!("../migrations/sqlite/0004_audit.sql"),
67);
68
69pub 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
125const DEFAULT_INVITATION_TTL_SECS: i64 = 7 * 86_400;
127
128#[must_use]
133pub(crate) fn sha256_hex(value: &str) -> String {
134 hex::encode(Sha256::digest(value.as_bytes()))
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
142pub struct Org {
143 pub id: String,
145 pub name: String,
147 pub created_by: String,
149 pub created_at: String,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
155pub struct Membership {
156 pub org: Org,
157 pub role: String,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
162pub struct Member {
163 pub sub: String,
165 pub role: String,
167 pub invited_by: Option<String>,
169 pub joined_at: String,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct Staff {
176 pub subject: Option<String>,
179 pub role: Option<String>,
181}
182
183impl Staff {
184 #[must_use]
186 pub fn is_machine(&self) -> bool {
187 self.subject.is_none()
188 }
189}
190
191#[derive(Debug, thiserror::Error)]
196#[non_exhaustive]
197pub enum OrgsError {
198 #[error("no organization or member matching `{0}` is visible to the caller")]
202 NotFound(String),
203 #[error("{0}")]
205 Forbidden(String),
206 #[error("`{0}` is not a configured role")]
208 UnknownRole(String),
209 #[error("an organization needs a name")]
211 InvalidName,
212 #[error("`{0}` is not a valid email address")]
214 InvalidEmail(String),
215 #[error("`{field}` must be at most {max} characters")]
217 TooLong { field: &'static str, max: usize },
218 #[error("this is the organization's last owner")]
220 LastOwner,
221 #[error("`{0}` is already a member")]
223 AlreadyMember(String),
224 #[error("the invitation is unknown, expired or already accepted")]
226 InvitationGone,
227 #[error("the invitation was issued to a different address")]
229 InvitationForSomeoneElse,
230 #[error("the caller's email address is not verified")]
232 EmailUnverified,
233 #[error("mail is not configured")]
235 MailNotConfigured,
236 #[error("the invitation message was refused: {0}")]
238 Mail(String),
239 #[error("orgs is misconfigured: {0}")]
241 Config(String),
242 #[error(transparent)]
244 Db(#[from] cratefield_core::DbError),
245}
246
247impl OrgsError {
248 #[must_use]
250 pub fn slug(&self) -> &'static str {
251 handlers::problem_for(self).slug
252 }
253
254 #[must_use]
256 pub fn problem(&self) -> Problem {
257 handlers::problem_for(self)
258 }
259}
260
261pub 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
283pub async fn require_staff(
298 ctx: &ModuleContext,
299 headers: &HeaderMap,
300 staff_org: &str,
301 roles: &[&str],
302) -> Result<Staff, Problem> {
303 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 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 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 _ => Err(Problem::new(&SLUGS.admin_forbidden)),
348 }
349}
350
351pub 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 #[must_use]
371 pub fn new() -> Self {
372 Self::default()
373 }
374
375 #[must_use]
379 pub fn builder() -> OrgsBuilder {
380 OrgsBuilder::new()
381 }
382
383 #[must_use]
385 pub fn roles(&self) -> &[String] {
386 &self.settings.roles
387 }
388}
389
390pub 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 #[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 #[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 #[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 #[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 #[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 #[must_use]
480 pub fn build(self) -> Orgs {
481 Orgs {
482 settings: self.settings,
483 }
484 }
485}
486
487#[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 fn requires(&self) -> &'static [Port] {
505 &[Port::Db, Port::Auth]
506 }
507
508 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 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 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 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 #[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 #[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 #[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 #[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}