#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
pub mod audit;
mod clock;
pub mod grants;
mod handlers;
mod mail;
mod service;
pub mod service_accounts;
mod store;
use std::sync::OnceLock;
use async_trait::async_trait;
use http::HeaderMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use time::Duration;
use cratefield_core::{
AnyError, BoxFuture, Config, ConfigError, DataKind, Database, Disposition, Migrations, Module,
ModuleContext, PersonalDataSet, Port, Problem, SLUGS, SqlMigration, SubjectVia, Surface,
};
use handlers::Settings;
pub use mail::{InvitationMail, TEMPLATE_INVITATION, default_templates, themed_templates};
pub const MODULE_NAME: &str = "orgs";
const MIGRATION_INIT: SqlMigration = SqlMigration::new(
"0001",
"init",
include_str!("../migrations/sqlite/0001_init.sql"),
);
pub const MIGRATION_SERVICE_ACCOUNTS: SqlMigration = SqlMigration::new(
"0002",
"service-accounts",
include_str!("../migrations/sqlite/0002_service_accounts.sql"),
);
pub const MIGRATION_GRANTS: SqlMigration = SqlMigration::new(
"0003",
"grants",
include_str!("../migrations/sqlite/0003_grants.sql"),
);
pub const MIGRATION_AUDIT: SqlMigration = SqlMigration::new(
"0004",
"audit",
include_str!("../migrations/sqlite/0004_audit.sql"),
);
pub const ACCESS_PERSONAL_DATA: [PersonalDataSet; 4] = [
PersonalDataSet {
table: service_accounts::ACCOUNTS_TABLE,
subject: "owner_sub",
kind: DataKind::Identifier,
disposition: Disposition::Erase,
description: "A service account you own: its name, what it is for, who made it, and \
whether it is disabled. Erasing your account removes the service \
accounts you own.",
redacted: &["created_by"],
subject_via: None,
},
PersonalDataSet {
table: service_accounts::KEYS_TABLE,
subject: "account_id",
kind: DataKind::Identifier,
disposition: Disposition::Erase,
description: "The API keys of a service account you own: the public prefix, the \
label, the scopes, and when each was made, last used and revoked. The \
key itself is held only as a hash.",
redacted: &["secret_hash"],
subject_via: Some(SubjectVia {
table: service_accounts::ACCOUNTS_TABLE,
subject: "owner_sub",
key: "id",
}),
},
PersonalDataSet {
table: grants::TABLE,
subject: "principal",
kind: DataKind::Identifier,
disposition: Disposition::Erase,
description: "The access you were given or refused: which part of an organization's \
data, at which permission, until when, and who granted it.",
redacted: &["created_by"],
subject_via: None,
},
PersonalDataSet {
table: audit::TABLE,
subject: "actor_id",
kind: DataKind::Usage,
disposition: Disposition::Retain(
"An audit trail that an erasure could rewrite would no longer show who did what. \
Entries name ids and credential prefixes, never secrets or content.",
),
description: "What you did in an organization that its administrators can review: \
the action, what it touched, when, and with which credential prefix.",
redacted: &[],
subject_via: None,
},
];
const DEFAULT_INVITATION_TTL_SECS: i64 = 7 * 86_400;
#[must_use]
pub(crate) fn sha256_hex(value: &str) -> String {
hex::encode(Sha256::digest(value.as_bytes()))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Org {
pub id: String,
pub name: String,
pub created_by: String,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Membership {
pub org: Org,
pub role: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Member {
pub sub: String,
pub role: String,
pub invited_by: Option<String>,
pub joined_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Staff {
pub subject: Option<String>,
pub role: Option<String>,
}
impl Staff {
#[must_use]
pub fn is_machine(&self) -> bool {
self.subject.is_none()
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum OrgsError {
#[error("no organization or member matching `{0}` is visible to the caller")]
NotFound(String),
#[error("{0}")]
Forbidden(String),
#[error("`{0}` is not a configured role")]
UnknownRole(String),
#[error("an organization needs a name")]
InvalidName,
#[error("`{0}` is not a valid email address")]
InvalidEmail(String),
#[error("`{field}` must be at most {max} characters")]
TooLong { field: &'static str, max: usize },
#[error("this is the organization's last owner")]
LastOwner,
#[error("`{0}` is already a member")]
AlreadyMember(String),
#[error("the invitation is unknown, expired or already accepted")]
InvitationGone,
#[error("the invitation was issued to a different address")]
InvitationForSomeoneElse,
#[error("the caller's email address is not verified")]
EmailUnverified,
#[error("mail is not configured")]
MailNotConfigured,
#[error("the invitation message was refused: {0}")]
Mail(String),
#[error("orgs is misconfigured: {0}")]
Config(String),
#[error(transparent)]
Db(#[from] cratefield_core::DbError),
}
impl OrgsError {
#[must_use]
pub fn slug(&self) -> &'static str {
handlers::problem_for(self).slug
}
#[must_use]
pub fn problem(&self) -> Problem {
handlers::problem_for(self)
}
}
pub async fn member_role(
db: &dyn Database,
org_id: &str,
sub: &str,
) -> Result<Option<String>, OrgsError> {
Ok(store::find_member(db, org_id, sub)
.await?
.map(|member| member.role))
}
pub async fn require_staff(
ctx: &ModuleContext,
headers: &HeaderMap,
staff_org: &str,
roles: &[&str],
) -> Result<Staff, Problem> {
if cratefield_core::require_admin(&*ctx.config, headers).is_ok() {
return Ok(Staff {
subject: None,
role: None,
});
}
if staff_org.is_empty() || roles.is_empty() {
return cratefield_core::require_admin(&*ctx.config, headers).map(|()| Staff {
subject: None,
role: None,
});
}
let Some(auth) = ctx.ports.auth.clone() else {
return Err(Problem::new(&SLUGS.admin_unauthorized));
};
let subject = match auth.identify(headers).await {
Ok(caller) => caller.subject().map(|subject| subject.id.clone()),
Err(_) => None,
};
let Some(subject) = subject else {
return Err(Problem::new(&SLUGS.admin_unauthorized));
};
let db = ctx.ports.db.as_deref().ok_or_else(Problem::internal)?;
let role = member_role(db, staff_org, &subject)
.await
.map_err(|error| {
tracing::error!(error = %error, "reading a staff membership failed");
Problem::internal()
})?;
match role {
Some(role) if roles.iter().any(|allowed| *allowed == role) => Ok(Staff {
subject: Some(subject),
role: Some(role),
}),
_ => Err(Problem::new(&SLUGS.admin_forbidden)),
}
}
pub struct Orgs {
settings: Settings,
}
impl Default for Orgs {
fn default() -> Self {
Self::builder().build()
}
}
impl Orgs {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn builder() -> OrgsBuilder {
OrgsBuilder::new()
}
#[must_use]
pub fn roles(&self) -> &[String] {
&self.settings.roles
}
}
pub struct OrgsBuilder {
settings: Settings,
}
impl OrgsBuilder {
fn new() -> Self {
Self {
settings: Settings {
roles: vec![store::OWNER.to_owned()],
managers: Vec::new(),
staff_org: String::new(),
staff_roles: Vec::new(),
invitation_ttl_secs: DEFAULT_INVITATION_TTL_SECS,
},
}
}
#[must_use]
pub fn roles<I, S>(mut self, roles: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut out: Vec<String> = Vec::new();
for role in roles {
let role = role.into();
if !role.trim().is_empty() && !out.contains(&role) {
out.push(role);
}
}
if !out.iter().any(|role| role == store::OWNER) {
out.insert(0, store::OWNER.to_owned());
}
self.settings.roles = out;
self
}
#[must_use]
pub fn managers<I, S>(mut self, managers: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.settings.managers = managers.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn staff_org(mut self, org_id: impl Into<String>) -> Self {
self.settings.staff_org = org_id.into();
self
}
#[must_use]
pub fn staff_roles<I, S>(mut self, roles: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.settings.staff_roles = roles.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn invitation_ttl(mut self, ttl: Duration) -> Self {
self.settings.invitation_ttl_secs = ttl.whole_seconds().max(0);
self
}
#[must_use]
pub fn build(self) -> Orgs {
Orgs {
settings: self.settings,
}
}
}
#[async_trait]
impl Module for Orgs {
fn name(&self) -> &'static str {
MODULE_NAME
}
fn version(&self) -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn requires(&self) -> &'static [Port] {
&[Port::Db, Port::Auth]
}
fn optional(&self) -> &'static [Port] {
&[Port::Clock, Port::IdGen, Port::Mailer]
}
fn tables(&self) -> &'static [&'static str] {
&[
"orgs",
"org_members",
"org_invitations",
service_accounts::ACCOUNTS_TABLE,
service_accounts::KEYS_TABLE,
grants::TABLE,
audit::TABLE,
]
}
fn personal_data(&self) -> &'static [PersonalDataSet] {
static SETS: OnceLock<Vec<PersonalDataSet>> = OnceLock::new();
SETS.get_or_init(|| {
vec![
PersonalDataSet {
table: "orgs",
subject: "created_by",
kind: DataKind::Identifier,
disposition: Disposition::Retain(
"An organization is not one person's: removing the row would remove \
every other member's organization with it. The creator is kept as \
provenance, and the name belongs to the organization.",
),
description: "An organization you created: its name, when it was made, \
and the fact that you made it. It is kept because its other \
members' data is inside it.",
redacted: &[],
subject_via: None,
},
PersonalDataSet {
table: "org_members",
subject: "user_sub",
kind: DataKind::Identifier,
disposition: Disposition::Erase,
description: "Your membership in an organization: the role you hold, when \
you joined, and who added you if it was not you. Erasing \
your account removes the membership.",
redacted: &["invited_by"],
subject_via: None,
},
PersonalDataSet {
table: "org_invitations",
subject: "invited_by",
kind: DataKind::Contact,
disposition: Disposition::Erase,
description: "An invitation you sent: the organization, the role offered, \
an address held only as a hash, and when the link lapses. \
Erasing your account removes the invitations you sent.",
redacted: &[],
subject_via: None,
},
]
.into_iter()
.chain(ACCESS_PERSONAL_DATA)
.collect()
})
.as_slice()
}
fn migrations(&self) -> Migrations {
const MIGRATIONS: [SqlMigration; 4] = [
MIGRATION_INIT,
MIGRATION_SERVICE_ACCOUNTS,
MIGRATION_GRANTS,
MIGRATION_AUDIT,
];
const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
Migrations::sqlite(&MIGRATIONS)
}
fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
handlers::validate(&self.settings, cfg).into_result()
}
fn self_check(&self) -> Vec<String> {
handlers::self_check(&self.settings)
}
fn router(&self, ctx: ModuleContext) -> axum::Router {
handlers::router(std::sync::Arc::new(ctx), self.settings.clone())
}
fn surface(&self) -> Surface {
handlers::surface()
}
fn scheduled<'a>(
&'a self,
ctx: &'a ModuleContext,
cron: &'a str,
) -> BoxFuture<'a, Result<(), AnyError>> {
Box::pin(async move {
if let Err(error) = service::maintain(ctx).await {
tracing::warn!(cron, error = %error, "the scheduled orgs pass did not complete");
}
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_role_set_always_holds_owner_and_never_a_blank() {
let builder = Orgs::builder().roles(["manager", "manager", " ", "staff"]);
assert_eq!(builder.settings.roles, vec!["owner", "manager", "staff"]);
let builder = Orgs::builder().roles(Vec::<String>::new());
assert_eq!(builder.settings.roles, vec!["owner"]);
}
#[test]
fn a_manager_role_that_is_not_a_role_is_reported() {
let module = Orgs::builder()
.roles(["staff"])
.managers(["manager"])
.build();
let problems = module.self_check();
assert_eq!(problems.len(), 1, "one problem: {problems:?}");
assert!(problems[0].contains("manager"));
}
#[test]
fn staff_roles_need_a_staff_organization() {
let module = Orgs::builder().staff_roles(["admin"]).build();
assert_eq!(module.self_check().len(), 1);
let module = Orgs::builder()
.staff_org("org-staff")
.staff_roles(["admin"])
.build();
assert!(module.self_check().is_empty());
}
#[test]
fn the_invitation_lifetime_is_what_the_builder_set() {
let module = Orgs::builder().invitation_ttl(Duration::days(3)).build();
assert_eq!(module.settings.invitation_ttl_secs, 3 * 86_400);
let module = Orgs::builder()
.invitation_ttl(Duration::seconds(-5))
.build();
assert_eq!(module.settings.invitation_ttl_secs, 0);
}
}