use cratefield_core::{Database, ModuleContext, normalize_email};
use crate::clock;
use crate::handlers::Settings;
use crate::store;
use crate::{Member, Membership, Org, OrgsError};
pub(crate) const MAX_NAME_CHARS: usize = 100;
pub(crate) const MAX_SUB_CHARS: usize = 200;
pub(crate) const MAX_ROLE_CHARS: usize = 64;
pub(crate) const MAX_EMAIL_CHARS: usize = 254;
pub(crate) const MAX_TOKEN_CHARS: usize = 512;
fn within(field: &'static str, value: &str, max: usize) -> Result<(), OrgsError> {
if value.chars().count() > max {
return Err(OrgsError::TooLong { field, max });
}
Ok(())
}
pub(crate) fn is_owner(role: &str) -> bool {
role == store::OWNER
}
fn check_role(settings: &Settings, role: &str) -> Result<(), OrgsError> {
if settings.roles.iter().any(|known| known == role) {
Ok(())
} else {
Err(OrgsError::UnknownRole(role.to_owned()))
}
}
pub(crate) fn may_manage(settings: &Settings, role: &str) -> bool {
is_owner(role) || settings.managers.iter().any(|manager| manager == role)
}
fn org_of(row: store::OrgRow) -> Org {
Org {
id: row.id,
name: row.name,
created_by: row.created_by,
created_at: row.created_at,
}
}
fn member_of(row: store::MemberRow) -> Member {
Member {
sub: row.sub,
role: row.role,
invited_by: row.invited_by,
joined_at: row.created_at,
}
}
fn db(ctx: &ModuleContext) -> Result<&dyn Database, OrgsError> {
ctx.ports
.db
.as_deref()
.ok_or_else(|| OrgsError::Config("no database is mounted".to_owned()))
}
pub(crate) async fn require_member(
ctx: &ModuleContext,
org_id: &str,
sub: &str,
) -> Result<Member, OrgsError> {
store::find_member(db(ctx)?, org_id, sub)
.await?
.map(member_of)
.ok_or_else(|| OrgsError::NotFound(org_id.to_owned()))
}
async fn require_manager(
ctx: &ModuleContext,
settings: &Settings,
org_id: &str,
actor: &str,
) -> Result<Member, OrgsError> {
let member = require_member(ctx, org_id, actor).await?;
if may_manage(settings, &member.role) {
Ok(member)
} else {
Err(OrgsError::Forbidden(format!(
"the `{}` role may not manage members of this organization",
member.role
)))
}
}
fn check_not_an_owner(actor: &Member, target: &Member) -> Result<(), OrgsError> {
if !is_owner(&actor.role) && is_owner(&target.role) {
return Err(OrgsError::Forbidden(
"only the owner role may change an owner's membership".to_owned(),
));
}
Ok(())
}
fn check_may_grant(authorised: bool, role: &str) -> Result<(), OrgsError> {
if !authorised && is_owner(role) {
return Err(OrgsError::Forbidden(
"only an owner may grant the owner role".to_owned(),
));
}
Ok(())
}
pub(crate) async fn create(ctx: &ModuleContext, owner: &str, name: &str) -> Result<Org, OrgsError> {
let name = name.trim();
if name.is_empty() {
return Err(OrgsError::InvalidName);
}
within("name", name, MAX_NAME_CHARS)?;
let db = db(ctx)?;
let at = clock::now(ctx);
let id = clock::new_id(ctx);
db.batch_atomic(&[
store::insert_org_statement(&id, name, owner, &at),
store::insert_member_statement(&id, owner, store::OWNER, None, &at),
])
.await?;
Ok(Org {
id,
name: name.to_owned(),
created_by: owner.to_owned(),
created_at: at,
})
}
pub(crate) async fn list(
ctx: &ModuleContext,
sub: &str,
after: Option<(&str, &str)>,
fetch: u64,
) -> Result<Vec<Membership>, OrgsError> {
Ok(store::list_orgs_for(db(ctx)?, sub, after, fetch)
.await?
.into_iter()
.map(|(org, role)| Membership {
org: org_of(org),
role,
})
.collect())
}
pub(crate) async fn get(
ctx: &ModuleContext,
org_id: &str,
sub: &str,
) -> Result<(Org, Member), OrgsError> {
let member = require_member(ctx, org_id, sub).await?;
let org = store::find_org(db(ctx)?, org_id)
.await?
.ok_or_else(|| OrgsError::NotFound(org_id.to_owned()))?;
Ok((org_of(org), member))
}
pub(crate) async fn members(
ctx: &ModuleContext,
org_id: &str,
sub: &str,
after: Option<(&str, &str)>,
fetch: u64,
) -> Result<Vec<Member>, OrgsError> {
require_member(ctx, org_id, sub).await?;
Ok(store::list_members(db(ctx)?, org_id, after, fetch)
.await?
.into_iter()
.map(member_of)
.collect())
}
pub(crate) async fn my_role(
ctx: &ModuleContext,
org_id: &str,
sub: &str,
) -> Result<String, OrgsError> {
Ok(require_member(ctx, org_id, sub).await?.role)
}
pub(crate) async fn all_orgs(
ctx: &ModuleContext,
after: Option<(&str, &str)>,
fetch: u64,
) -> Result<Vec<Org>, OrgsError> {
Ok(store::list_all_orgs(db(ctx)?, after, fetch)
.await?
.into_iter()
.map(org_of)
.collect())
}
pub(crate) async fn add_member(
ctx: &ModuleContext,
settings: &Settings,
org_id: &str,
actor: &str,
sub: &str,
role: &str,
) -> Result<Member, OrgsError> {
let actor_member = require_manager(ctx, settings, org_id, actor).await?;
check_may_grant(is_owner(&actor_member.role), role)?;
check_role(settings, role)?;
within("sub", sub, MAX_SUB_CHARS)?;
within("role", role, MAX_ROLE_CHARS)?;
let db = db(ctx)?;
if let Some(existing) = store::find_member(db, org_id, sub).await? {
check_not_an_owner(&actor_member, &member_of(existing))?;
return Err(OrgsError::AlreadyMember(sub.to_owned()));
}
let at = clock::now(ctx);
let changed = store::insert_member_once(db, org_id, sub, role, Some(actor), &at).await?;
if changed == 0 {
return Err(OrgsError::AlreadyMember(sub.to_owned()));
}
Ok(Member {
sub: sub.to_owned(),
role: role.to_owned(),
invited_by: Some(actor.to_owned()),
joined_at: at,
})
}
pub(crate) async fn set_role(
ctx: &ModuleContext,
settings: &Settings,
org_id: &str,
actor: &str,
sub: &str,
role: &str,
) -> Result<Member, OrgsError> {
let actor_member = require_manager(ctx, settings, org_id, actor).await?;
let db = db(ctx)?;
let target = store::find_member(db, org_id, sub)
.await?
.ok_or_else(|| OrgsError::NotFound(org_id.to_owned()))?;
let target = member_of(target);
check_not_an_owner(&actor_member, &target)?;
check_may_grant(is_owner(&actor_member.role), role)?;
check_role(settings, role)?;
within("sub", sub, MAX_SUB_CHARS)?;
within("role", role, MAX_ROLE_CHARS)?;
db.batch_atomic(&store::update_role_statements(
org_id,
sub,
role,
actor,
&settings.managers,
))
.await?;
match store::find_member(db, org_id, sub).await? {
Some(now) if now.role == role => Ok(Member {
role: role.to_owned(),
..member_of(now)
}),
Some(now) => {
if is_owner(&now.role) && !is_owner(role) && store::count_owners(db, org_id).await? <= 1
{
Err(OrgsError::LastOwner)
} else {
Err(OrgsError::Forbidden(
"this change is not permitted for your role in this organization".to_owned(),
))
}
}
None => Err(OrgsError::NotFound(org_id.to_owned())),
}
}
pub(crate) async fn remove_member(
ctx: &ModuleContext,
settings: &Settings,
org_id: &str,
actor: &str,
sub: &str,
) -> Result<(), OrgsError> {
let db = db(ctx)?;
if actor == sub {
require_member(ctx, org_id, actor).await?;
} else {
let actor_member = require_manager(ctx, settings, org_id, actor).await?;
let target = store::find_member(db, org_id, sub)
.await?
.ok_or_else(|| OrgsError::NotFound(org_id.to_owned()))?;
check_not_an_owner(&actor_member, &member_of(target))?;
}
db.batch_atomic(&store::remove_member_statements(
org_id,
sub,
actor,
&settings.managers,
))
.await?;
match store::find_member(db, org_id, sub).await? {
None => Ok(()),
Some(now) => {
if is_owner(&now.role) && store::count_owners(db, org_id).await? <= 1 {
Err(OrgsError::LastOwner)
} else {
Err(OrgsError::Forbidden(
"this removal is not permitted for your role in this organization".to_owned(),
))
}
}
}
}
pub(crate) async fn invite(
ctx: &ModuleContext,
settings: &Settings,
org_id: &str,
actor: &str,
email: &str,
role: &str,
) -> Result<(), OrgsError> {
let actor_member = require_manager(ctx, settings, org_id, actor).await?;
check_may_grant(is_owner(&actor_member.role), role)?;
check_role(settings, role)?;
within("role", role, MAX_ROLE_CHARS)?;
within("email", email, MAX_EMAIL_CHARS)?;
let org = store::find_org(db(ctx)?, org_id)
.await?
.ok_or_else(|| OrgsError::NotFound(org_id.to_owned()))?;
let normalized = normalize_email(email);
if !cratefield_core::is_valid(&normalized) {
return Err(OrgsError::InvalidEmail(normalized));
}
let db = db(ctx)?;
let at = clock::now(ctx);
let token = clock::new_token(ctx);
let id = clock::new_id(ctx);
let expires_at = clock::plus_secs(&at, settings.invitation_ttl_secs);
store::insert_invitation(
db,
&store::NewInvitation {
id: &id,
org_id,
token_hash: &crate::sha256_hex(&token),
email_hash: &crate::sha256_hex(&normalized),
role,
invited_by: actor,
expires_at: &expires_at,
now: &at,
},
)
.await?;
let mail = crate::mail::OutgoingInvitation {
to: &normalized,
org_name: &org.name,
role,
accept_url: &crate::mail::accept_url(ctx, &token),
token: &token,
idempotency_key: &format!("orgs-invite-{id}"),
expires_in_days: (settings.invitation_ttl_secs / 86_400).max(1),
};
match crate::mail::send(ctx, &mail).await {
Ok(()) => Ok(()),
Err(error) => {
let _ = store::delete_invitation(db, &id).await;
Err(error)
}
}
}
pub(crate) async fn accept(
ctx: &ModuleContext,
settings: &Settings,
token: &str,
sub: &str,
email: Option<&str>,
) -> Result<Org, OrgsError> {
let Some(email) = email else {
return Err(OrgsError::EmailUnverified);
};
within("token", token, MAX_TOKEN_CHARS)?;
within("email", email, MAX_EMAIL_CHARS)?;
let email = normalize_email(email);
let db = db(ctx)?;
let token_hash = crate::sha256_hex(token);
let invitation = store::find_invitation(db, &token_hash)
.await?
.ok_or(OrgsError::InvitationGone)?;
let at = clock::now(ctx);
if invitation.accepted_at.is_some() || invitation.expires_at <= at {
return Err(OrgsError::InvitationGone);
}
if !cratefield_core::constant_time_eq(
crate::sha256_hex(&email).as_bytes(),
invitation.email_hash.as_bytes(),
) {
return Err(OrgsError::InvitationForSomeoneElse);
}
check_role(settings, &invitation.role)?;
let spend_id = clock::new_id(ctx);
db.batch_atomic(&store::accept_statements(
&invitation.org_id,
sub,
&invitation.role,
&invitation.invited_by,
&at,
&token_hash,
&spend_id,
))
.await?;
match store::find_invitation(db, &token_hash).await? {
Some(row) if row.spend_id.as_deref() == Some(spend_id.as_str()) => {}
_ => return Err(OrgsError::InvitationGone),
}
let org = store::find_org(db, &invitation.org_id)
.await?
.ok_or_else(|| OrgsError::NotFound(invitation.org_id.clone()))?;
Ok(org_of(org))
}
pub(crate) async fn maintain(ctx: &ModuleContext) -> Result<(), OrgsError> {
let at = clock::now(ctx);
store::purge_invitations(db(ctx)?, &at).await?;
Ok(())
}