use crate::error::{human_bytes, Error, Result};
use crate::model::{now_ms, Level, PrincipalKind, UnixMillis};
use super::{AuthDoc, AuthTable};
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Clan {
pub name: String,
pub created_by: String,
pub created_at: UnixMillis,
#[serde(default)]
pub members: Vec<ClanMember>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub invites: Vec<ClanInvite>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub offers: Vec<ClanRepoOffer>,
}
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClanRepoOffer {
pub repo: String,
pub to: String,
pub by: String,
pub at: UnixMillis,
}
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClanMember {
pub principal: String,
#[serde(default)]
pub admin: bool,
#[serde(default)]
pub contribution_bytes: u64,
pub joined_at: UnixMillis,
}
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClanInvite {
pub principal: String,
pub by: String,
pub at: UnixMillis,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ClanRole {
Admin,
Member,
Invited,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ClanRefusal {
NotFound,
Denied,
Precondition,
Invalid,
Exists,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct AccountRoom {
pub cap: Option<u64>,
pub stored: u64,
}
impl Clan {
pub fn room_bytes(&self) -> u64 {
self.members
.iter()
.map(|m| m.contribution_bytes)
.fold(0, u64::saturating_add)
}
pub fn member(&self, principal: &str) -> Option<&ClanMember> {
self.members.iter().find(|m| m.principal == principal)
}
pub fn role_of(&self, principal: &str) -> Option<ClanRole> {
match self.member(principal) {
Some(m) if m.admin => Some(ClanRole::Admin),
Some(_) => Some(ClanRole::Member),
None if self.invites.iter().any(|i| i.principal == principal) => {
Some(ClanRole::Invited)
}
None => None,
}
}
pub fn admins(&self) -> usize {
self.members.iter().filter(|m| m.admin).count()
}
pub fn floor_for(&self, member: &str, used: u64) -> u64 {
self.floor_in_force(member, used, &[])
}
pub fn floor_in_force(&self, member: &str, used: u64, lapsed: &[String]) -> u64 {
if lapsed.iter().any(|l| l == member) {
return 0;
}
let others: u64 = self
.members
.iter()
.filter(|m| m.principal != member && !lapsed.contains(&m.principal))
.map(|m| m.contribution_bytes)
.fold(0, u64::saturating_add);
used.saturating_sub(others)
}
pub fn room_in_force(&self, lapsed: &[String]) -> u64 {
self.members
.iter()
.filter(|m| !lapsed.contains(&m.principal))
.map(|m| m.contribution_bytes)
.fold(0, u64::saturating_add)
}
pub fn offer_of(&self, repo: &str) -> Option<&ClanRepoOffer> {
self.offers.iter().find(|o| o.repo == repo)
}
}
fn refused(clan: &str, refusal: ClanRefusal, reason: String) -> Error {
Error::Clan {
clan: clan.to_owned(),
refusal,
reason,
}
}
impl AuthTable {
pub fn clan(&self, name: &str) -> Result<Option<Clan>> {
let doc = self.doc.load()?;
if doc.clans.is_empty() {
return Ok(None);
}
doc.clans.get(name)
}
pub fn is_clan(&self, name: &str) -> Result<bool> {
Ok(self.clan(name)?.is_some())
}
pub fn clans_of(&self, principal: &str) -> Result<Vec<Clan>> {
let doc = self.doc.load()?;
let mut out = Vec::new();
for name in doc.clan_index.get(principal)?.unwrap_or_default() {
if let Some(clan) = doc.clans.get(&name)? {
out.push(clan);
}
}
Ok(out)
}
pub fn clan_role(&self, principal: &str, namespace: &str) -> Result<Option<ClanRole>> {
Ok(self
.clan(namespace)?
.and_then(|clan| clan.role_of(principal)))
}
pub fn contributed_by(&self, account: &str) -> Result<u64> {
let doc = self.doc.load()?;
Self::contributed_in(&doc, account, None)
}
pub fn room_left(&self, account: &str, room: AccountRoom) -> Result<Option<u64>> {
let Some(cap) = room.cap else { return Ok(None) };
let doc = self.doc.load()?;
let committed = room
.stored
.saturating_add(Self::promised_by(&doc, account)?)
.saturating_add(Self::contributed_in(&doc, account, None)?);
Ok(Some(cap.saturating_sub(committed)))
}
pub(super) fn clan_level(
doc: &AuthDoc,
principal: &str,
namespace: &str,
) -> Result<Option<Level>> {
if doc.clans.is_empty() {
return Ok(None);
}
let Some(clan) = doc.clans.get(namespace)? else {
return Ok(None);
};
Ok(clan
.member(principal)
.map(|m| if m.admin { Level::Admin } else { Level::Write }))
}
pub(super) fn contributed_in(
doc: &AuthDoc,
account: &str,
except: Option<&str>,
) -> Result<u64> {
if doc.clan_index.is_empty() {
return Ok(0);
}
let mut sum: u64 = 0;
for name in doc.clan_index.get(account)?.unwrap_or_default() {
if Some(name.as_str()) == except {
continue;
}
if let Some(clan) = doc.clans.get(&name)? {
if let Some(m) = clan.member(account) {
sum = sum.saturating_add(m.contribution_bytes);
}
}
}
Ok(sum)
}
pub fn clan_for(&self, clan: &str, by: &str) -> Result<Clan> {
let doc = self.doc.load()?;
Self::clan_for_admin(&doc, clan, by, "dissolve it")
}
pub fn create_clan(
&self,
name: &str,
creator: &str,
contribution: u64,
room: AccountRoom,
) -> Result<Clan> {
crate::sandbox::check_component(name, name)?;
if name == crate::model::ANONYMOUS_PRINCIPAL_ID {
return Err(Error::ReservedPrincipalId(name.to_owned()));
}
let at = now_ms();
self.doc.update(|doc| {
Self::ordinary_account(doc, name, creator, "make a clan")?;
if doc.clans.contains_key(name)? {
return Err(refused(
name,
ClanRefusal::Exists,
format!("a clan called {name}"),
));
}
if doc.principals.contains_key(name)? {
return Err(refused(
name,
ClanRefusal::Exists,
format!("an account called {name}"),
));
}
Self::fits_in_room(doc, name, creator, contribution, room)?;
let clan = Clan {
name: name.to_owned(),
created_by: creator.to_owned(),
created_at: at,
members: vec![ClanMember {
principal: creator.to_owned(),
admin: true,
contribution_bytes: contribution,
joined_at: at,
}],
invites: Vec::new(),
offers: Vec::new(),
};
doc.clans.insert(name.to_owned(), clan.clone())?;
Self::index_clan(doc, creator, name)?;
Ok(clan)
})
}
pub fn invite_to_clan(&self, clan: &str, by: &str, principal: &str) -> Result<Clan> {
let at = now_ms();
self.doc.update(|doc| {
let mut row = Self::clan_for_admin(doc, clan, by, "invite an account")?;
Self::ordinary_account(doc, clan, principal, "join a clan")?;
match row.role_of(principal) {
Some(ClanRole::Admin | ClanRole::Member) => {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!("{principal} is already in clan {clan}"),
))
}
Some(ClanRole::Invited) => {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!(
"{principal} is already invited to clan {clan}, and has not answered"
),
))
}
None => {}
}
row.invites.push(ClanInvite {
principal: principal.to_owned(),
by: by.to_owned(),
at,
});
doc.clans.insert(clan.to_owned(), row.clone())?;
Self::index_clan(doc, principal, clan)?;
Ok(row)
})
}
pub fn answer_clan_invite(
&self,
clan: &str,
principal: &str,
accept: bool,
contribution: u64,
room: AccountRoom,
) -> Result<Clan> {
let at = now_ms();
self.doc.update(|doc| {
let mut row = Self::visible_clan(doc, clan, principal)?;
if row.role_of(principal) != Some(ClanRole::Invited) {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!("{principal} has no invitation to clan {clan} to answer"),
));
}
row.invites.retain(|i| i.principal != principal);
if accept {
Self::ordinary_account(doc, clan, principal, "join a clan")?;
Self::fits_in_room(doc, clan, principal, contribution, room)?;
row.members.push(ClanMember {
principal: principal.to_owned(),
admin: false,
contribution_bytes: contribution,
joined_at: at,
});
} else {
Self::unindex_clan(doc, principal, clan)?;
}
doc.clans.insert(clan.to_owned(), row.clone())?;
Ok(row)
})
}
pub fn withdraw_clan_invite(&self, clan: &str, by: &str, principal: &str) -> Result<Clan> {
self.doc.update(|doc| {
let mut row = Self::clan_for_admin(doc, clan, by, "withdraw an invitation")?;
if row.role_of(principal) != Some(ClanRole::Invited) {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!("{principal} has no invitation to clan {clan}"),
));
}
row.invites.retain(|i| i.principal != principal);
Self::unindex_clan(doc, principal, clan)?;
doc.clans.insert(clan.to_owned(), row.clone())?;
Ok(row)
})
}
pub fn set_clan_contribution(
&self,
clan: &str,
member: &str,
bytes: u64,
room: AccountRoom,
used: Option<u64>,
lapsed: &[String],
) -> Result<Clan> {
self.doc.update(|doc| {
let mut row = Self::visible_clan(doc, clan, member)?;
Self::refuse_gated(doc, clan, member)?;
let Some(now) = row.member(member).map(|m| m.contribution_bytes) else {
return Err(refused(
clan,
ClanRefusal::Denied,
format!("{member} is not a member of clan {clan}: only a member gives it room"),
));
};
if bytes > now {
Self::fits_in_room(doc, clan, member, bytes, room)?;
} else if bytes < now {
Self::above_floor(
&row,
clan,
member,
bytes,
used,
lapsed,
"lower its contribution",
)?;
}
for m in &mut row.members {
if m.principal == member {
m.contribution_bytes = bytes;
}
}
doc.clans.insert(clan.to_owned(), row.clone())?;
Ok(row)
})
}
pub fn leave_clan(
&self,
clan: &str,
member: &str,
used: Option<u64>,
lapsed: &[String],
) -> Result<Clan> {
self.doc.update(|doc| {
let mut row = Self::visible_clan(doc, clan, member)?;
Self::refuse_gated(doc, clan, member)?;
let Some(me) = row.member(member).cloned() else {
return Err(refused(clan, ClanRefusal::Precondition, format!("{member} is not a member of clan {clan}")));
};
if me.admin && row.admins() == 1 {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!(
"{member} is clan {clan}'s last admin, and the last admin cannot leave: make another \
member admin first, or dissolve the clan"
),
));
}
Self::above_floor(&row, clan, member, 0, used, lapsed, "leave")?;
row.members.retain(|m| m.principal != member);
Self::unindex_clan(doc, member, clan)?;
doc.clans.insert(clan.to_owned(), row.clone())?;
Ok(row)
})
}
pub fn remove_from_clan(
&self,
clan: &str,
by: &str,
member: &str,
used: Option<u64>,
lapsed: &[String],
) -> Result<Clan> {
self.doc.update(|doc| {
let mut row = Self::clan_for_admin(doc, clan, by, "remove a member")?;
if member == by {
return Err(refused(
clan,
ClanRefusal::Invalid,
format!("{by} cannot remove itself from clan {clan}: that is leaving"),
));
}
if row.member(member).is_none() {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!("{member} is not a member of clan {clan}"),
));
}
Self::above_floor(&row, clan, member, 0, used, lapsed, "be removed")?;
row.members.retain(|m| m.principal != member);
Self::unindex_clan(doc, member, clan)?;
doc.clans.insert(clan.to_owned(), row.clone())?;
Ok(row)
})
}
pub fn set_clan_admin(&self, clan: &str, by: &str, member: &str, admin: bool) -> Result<Clan> {
self.doc.update(|doc| {
let mut row = Self::clan_for_admin(doc, clan, by, "change who administers it")?;
let Some(was) = row.member(member).map(|m| m.admin) else {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!("{member} is not a member of clan {clan}"),
));
};
if was && !admin && row.admins() == 1 {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!(
"{member} is clan {clan}'s last admin: make another member admin first"
),
));
}
for m in &mut row.members {
if m.principal == member {
m.admin = admin;
}
}
doc.clans.insert(clan.to_owned(), row.clone())?;
Ok(row)
})
}
pub fn dissolve_clan(&self, clan: &str, by: &str) -> Result<Clan> {
self.doc.update(|doc| {
let row = Self::clan_for_admin(doc, clan, by, "dissolve it")?;
for m in &row.members {
Self::unindex_clan(doc, &m.principal, clan)?;
}
for i in &row.invites {
Self::unindex_clan(doc, &i.principal, clan)?;
}
doc.clans.remove(clan)?;
Ok(row)
})
}
pub fn offer_clan_repo(&self, clan: &str, by: &str, repo: &str, to: &str) -> Result<Clan> {
let at = now_ms();
self.doc.update(|doc| {
let mut row = Self::clan_for_admin(doc, clan, by, "move a repository out of it")?;
if to == clan || doc.clans.contains_key(to)? {
return Err(refused(
clan,
ClanRefusal::Invalid,
format!("{to} is a clan: a repository moves out of a clan into an account"),
));
}
let Some(account) = doc.principals.get(to)? else {
return Err(refused(
clan,
ClanRefusal::NotFound,
format!("there is no account {to}"),
));
};
if account.kind != PrincipalKind::User || doc.employers.contains_key(to)? {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!("{to} holds no account of its own to move {clan}/{repo} into"),
));
}
if let Some(open) = row.offer_of(repo) {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!(
"{clan}/{repo} is already offered to {}; withdraw that offer first",
open.to
),
));
}
row.offers.push(ClanRepoOffer {
repo: repo.to_owned(),
to: to.to_owned(),
by: by.to_owned(),
at,
});
doc.clans.insert(clan.to_owned(), row.clone())?;
Ok(row)
})
}
pub fn clan_repo_offer(&self, clan: &str, repo: &str) -> Result<Option<ClanRepoOffer>> {
Ok(self.clan(clan)?.and_then(|row| row.offer_of(repo).cloned()))
}
pub fn clan_offers_to(&self, to: &str) -> Result<Vec<(String, ClanRepoOffer)>> {
let doc = self.doc.load()?;
if doc.clans.is_empty() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for (name, row) in doc.clans.entries()? {
for offer in row.offers {
if offer.to == to {
out.push((name.clone(), offer));
}
}
}
Ok(out)
}
pub fn withdraw_clan_repo_offer(&self, clan: &str, by: &str, repo: &str) -> Result<Clan> {
self.doc.update(|doc| {
let mut row = Self::clan_for_admin(doc, clan, by, "withdraw an offer")?;
if row.offer_of(repo).is_none() {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!("{clan}/{repo} is offered to nobody"),
));
}
row.offers.retain(|o| o.repo != repo);
doc.clans.insert(clan.to_owned(), row.clone())?;
Ok(row)
})
}
pub fn close_clan_repo_offer(&self, clan: &str, repo: &str, to: &str) -> Result<ClanRepoOffer> {
self.doc.update(|doc| {
let mut row = doc.clans.get(clan)?.ok_or_else(|| {
refused(
clan,
ClanRefusal::NotFound,
format!("there is no clan {clan}"),
)
})?;
let Some(offer) = row.offer_of(repo).filter(|o| o.to == to).cloned() else {
return Err(refused(
clan,
ClanRefusal::NotFound,
format!("{clan}/{repo} is not offered to {to}"),
));
};
row.offers.retain(|o| o.repo != repo);
doc.clans.insert(clan.to_owned(), row)?;
Ok(offer)
})
}
pub(super) fn forget_clan_member(doc: &mut AuthDoc, id: &str) -> Result<()> {
if doc.clan_index.is_empty() {
return Ok(());
}
for name in doc.clan_index.remove(id)?.unwrap_or_default() {
let Some(mut row) = doc.clans.get(&name)? else {
continue;
};
row.members.retain(|m| m.principal != id);
row.invites.retain(|i| i.principal != id);
if row.admins() == 0 {
if let Some(first) = row.members.iter_mut().min_by_key(|m| m.joined_at) {
first.admin = true;
}
}
doc.clans.insert(name, row)?;
}
Ok(())
}
fn clan_for_admin(doc: &AuthDoc, clan: &str, by: &str, what: &str) -> Result<Clan> {
let row = Self::visible_clan(doc, clan, by)?;
Self::refuse_gated(doc, clan, by)?;
if row.role_of(by) != Some(ClanRole::Admin) {
return Err(refused(
clan,
ClanRefusal::Denied,
format!("only clan {clan}'s admin may {what}; {by} is not its admin"),
));
}
Ok(row)
}
fn visible_clan(doc: &AuthDoc, clan: &str, who: &str) -> Result<Clan> {
match doc.clans.get(clan)? {
Some(row) if row.role_of(who).is_some() => Ok(row),
_ => Err(refused(
clan,
ClanRefusal::NotFound,
format!("there is no clan {clan} you are in or invited to"),
)),
}
}
fn ordinary_account(doc: &AuthDoc, clan: &str, id: &str, what: &str) -> Result<()> {
let Some(principal) = doc.principals.get(id)? else {
return Err(refused(
clan,
ClanRefusal::NotFound,
format!("there is no account {id}"),
));
};
if principal.kind != PrincipalKind::User {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!(
"{id} is a {:?}, and only a person's own account may {what}",
principal.kind
),
));
}
if let Some(employer) = doc.employers.get(id)? {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!(
"{id} is an employee of {employer}: an enterprise account's people may not {what} — \
their account admin's rules win"
),
));
}
Self::refuse_gated(doc, clan, id)
}
fn refuse_gated(doc: &AuthDoc, clan: &str, id: &str) -> Result<()> {
if Self::gated(doc, id)? {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!("account {id} is locked"),
));
}
Ok(())
}
fn fits_in_room(
doc: &AuthDoc,
clan: &str,
account: &str,
bytes: u64,
room: AccountRoom,
) -> Result<()> {
let Some(cap) = room.cap else { return Ok(()) };
let committed = room
.stored
.saturating_add(Self::promised_by(doc, account)?)
.saturating_add(Self::contributed_in(doc, account, Some(clan))?);
let free = cap.saturating_sub(committed);
if bytes > free {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!(
"{account} has {} of its own room unused, and {} is asked for clan {clan}: {} short. \
A clan's room is its members' own; add room to the account, or give less",
human_bytes(free),
human_bytes(bytes),
human_bytes(bytes - free)
),
));
}
Ok(())
}
fn above_floor(
row: &Clan,
clan: &str,
member: &str,
bytes: u64,
used: Option<u64>,
lapsed: &[String],
what: &str,
) -> Result<()> {
let Some(used) = used else {
return Err(refused(
clan,
ClanRefusal::Precondition,
format!(
"what clan {clan} stores could not be measured, so {member} may not {what} now: a room \
that is in use is never given back unseen"
),
));
};
let floor = row.floor_in_force(member, used, lapsed);
if bytes < floor {
let own = row.member(member).map_or(0, |m| m.contribution_bytes);
return Err(refused(
clan,
ClanRefusal::Precondition,
format!(
"clan {clan} stores {} in its room of {}, so {member}'s {} is in use: it may go down to {} \
and no lower, and {member} may not {what} until the clan stores less",
human_bytes(used),
human_bytes(row.room_in_force(lapsed)),
human_bytes(own),
human_bytes(floor)
),
));
}
Ok(())
}
fn index_clan(doc: &mut AuthDoc, principal: &str, clan: &str) -> Result<()> {
let mut names = doc.clan_index.get(principal)?.unwrap_or_default();
if !names.iter().any(|n| n == clan) {
names.push(clan.to_owned());
names.sort();
}
doc.clan_index.insert(principal.to_owned(), names)?;
Ok(())
}
fn unindex_clan(doc: &mut AuthDoc, principal: &str, clan: &str) -> Result<()> {
let Some(mut names) = doc.clan_index.get(principal)? else {
return Ok(());
};
names.retain(|n| n != clan);
if names.is_empty() {
doc.clan_index.remove(principal)?;
} else {
doc.clan_index.insert(principal.to_owned(), names)?;
}
Ok(())
}
}