use gunnar_proto::clans_server::Clans;
use gunnar_proto::{
AcceptClanInviteRequest, Clan, ClanResponse, ClanRole, CreateClanRequest,
DeclineClanInviteRequest, DissolveClanRequest, DissolveClanResponse, GetClanRequest,
GrantLevel, InviteToClanRequest, LeaveClanRequest, ListClansRequest, ListClansResponse,
RemoveClanMemberRequest, SetClanAdminRequest, SetClanContributionRequest,
};
use gunnar_proto::{AnswerRepoOfferRequest, AnswerRepoOfferResponse, OfferRepoRequest};
use tonic::{Request, Response, Status};
use crate::auth::Caller;
use crate::clans::{ClanPlane, ClanRoom};
use crate::service::accounts::account_room;
use crate::service::ClansService;
fn named(raw: &str, field: &'static str) -> Result<String, Status> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(Status::invalid_argument(format!("`{field}` is required")));
}
Ok(trimmed.to_owned())
}
impl ClansService {
fn plane(&self) -> Result<&dyn ClanPlane, Status> {
self.ctx
.control
.clans()
.ok_or_else(|| Status::unimplemented("Clans: this server keeps no clans"))
}
fn room(&self, account: &str) -> Result<ClanRoom, Status> {
Ok(match account_room(&self.ctx, account)? {
Some((cap, stored)) => ClanRoom {
cap: Some(cap),
stored,
},
None => ClanRoom::default(),
})
}
fn used(&self, clan: &str) -> Option<u64> {
match self.ctx.control.usage(Some(clan)) {
Ok(rows) => rows
.into_iter()
.find(|row| row.namespace == clan)
.map(|row| row.pack_bytes.saturating_add(row.lfs_bytes)),
Err(_) => None,
}
}
fn audit_offer(
&self,
caller: &Caller,
event: &'static str,
clan: &str,
repo: &str,
to: &str,
offered_by: &str,
) {
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event,
principal = %caller.principal_id,
clan = %clan,
repo = %repo,
to = %to,
"a repository offered out of a clan"
);
let detail = if event == "clan.repo_moved" {
format!("clan {clan}, repository {repo}, now {to}/{repo}")
} else {
format!("clan {clan}, repository {repo}, account {to}")
};
let mut logs = vec![clan.to_owned()];
let offerer = self.ctx.account_of_principal(offered_by);
let actor = self.ctx.account_of_principal(&caller.principal_id);
for account in [offerer, Some(to.to_owned()), actor].into_iter().flatten() {
if !logs.contains(&account) {
logs.push(account);
}
}
let target = format!("{clan}/{repo}");
for account in logs {
self.ctx.account_event(
Some(account),
crate::enterprise::AccountAuditEvent::by(caller, event, target.clone())
.detail(detail.clone()),
);
}
}
fn seen(&self, caller: &str, mut clan: Clan, room_left: Option<u64>) -> Clan {
let role = match clan.members.iter().find(|m| m.principal_id == caller) {
Some(m) if m.admin => ClanRole::Admin,
Some(_) => ClanRole::Member,
None if clan.invites.iter().any(|i| i.principal_id == caller) => ClanRole::Invited,
None => ClanRole::Unspecified,
};
if role != ClanRole::Admin {
clan.invites.retain(|i| i.principal_id == caller);
clan.offers.clear();
}
let own = clan
.members
.iter()
.find(|m| m.principal_id == caller)
.map_or(0, |m| m.contribution_bytes);
let used = self.used(&clan.name);
clan.used_bytes = used;
clan.your_role = role as i32;
clan.your_contribution_bytes = own;
let me_lapsed = clan
.members
.iter()
.any(|m| m.principal_id == caller && m.lapsed);
let others_in_force: u64 = clan
.members
.iter()
.filter(|m| m.principal_id != caller && !m.lapsed)
.map(|m| m.contribution_bytes)
.fold(0, u64::saturating_add);
clan.your_floor_bytes = match role {
ClanRole::Admin | ClanRole::Member if me_lapsed => used.map(|_| 0),
ClanRole::Admin | ClanRole::Member => {
used.map(|used| used.saturating_sub(others_in_force))
}
_ => None,
};
clan.your_room_left_bytes = room_left;
clan
}
fn room_left(&self, caller: &str) -> Option<u64> {
let room = self.room(caller).ok()?;
self.plane()
.ok()?
.clan_room_left(caller, room)
.ok()
.flatten()
}
fn answer(&self, caller: &Caller, clan: Clan) -> Response<ClanResponse> {
let left = self.room_left(&caller.principal_id);
Response::new(ClanResponse {
clan: Some(self.seen(&caller.principal_id, clan, left)),
})
}
fn audit(&self, caller: &Caller, event: &'static str, clan: &str, other: &str, bytes: u64) {
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event,
principal = %caller.principal_id,
clan = %clan,
other = %other,
bytes,
"a clan changed"
);
let detail = match (other.is_empty(), event) {
(_, "clan.created" | "clan.joined" | "clan.contribution") => {
format!("clan {clan}, giving {bytes} bytes")
}
(false, _) => format!("clan {clan}, account {other}"),
(true, _) => format!("clan {clan}"),
};
let target = if other.is_empty() { clan } else { other };
let entry = |target: &str| {
crate::enterprise::AccountAuditEvent::by(caller, event, target).detail(detail.clone())
};
self.ctx.account_event(Some(clan.to_owned()), entry(target));
let mine = self.ctx.account_of_principal(&caller.principal_id);
if let Some(own) = mine.clone().filter(|own| own != clan) {
self.ctx.account_event(Some(own), entry(target));
}
if !other.is_empty() && other != caller.principal_id {
if let Some(theirs) = self
.ctx
.account_of_principal(other)
.filter(|a| a != clan && Some(a) != mine.as_ref())
{
self.ctx.account_event(Some(theirs), entry(clan));
}
}
}
}
#[tonic::async_trait]
impl Clans for ClansService {
async fn create(
&self,
request: Request<CreateClanRequest>,
) -> Result<Response<ClanResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Clans.Create")?;
let request = request.into_inner();
let name = named(&request.name, "name")?;
if crate::service::access::is_reserved_principal_name(&name) {
return Err(crate::service::access::reserved_name_refusal(
&name, "a clan",
));
}
self.ctx
.filter_name(&caller, "namespace", &name, None, false)?;
let room = self.room(&caller.principal_id)?;
let clan = self.plane()?.clan_create(
&name,
&caller.principal_id,
request.contribution_bytes,
room,
)?;
self.audit(
&caller,
"clan.created",
&name,
"",
request.contribution_bytes,
);
Ok(self.answer(&caller, clan))
}
async fn get(
&self,
request: Request<GetClanRequest>,
) -> Result<Response<ClanResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Read, "Clans.Get")?;
let name = named(&request.into_inner().clan, "clan")?;
let absent =
|| Status::not_found(format!("there is no clan {name} you are in or invited to"));
let clan = self.plane()?.clan_get(&name)?.ok_or_else(absent)?;
let inside = clan
.members
.iter()
.any(|m| m.principal_id == caller.principal_id)
|| clan
.invites
.iter()
.any(|i| i.principal_id == caller.principal_id);
if !inside {
return Err(absent());
}
Ok(self.answer(&caller, clan))
}
async fn list(
&self,
request: Request<ListClansRequest>,
) -> Result<Response<ListClansResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Read, "Clans.List")?;
let left = self.room_left(&caller.principal_id);
let plane = self.plane()?;
let clans = plane
.clans_of(&caller.principal_id)?
.into_iter()
.map(|clan| self.seen(&caller.principal_id, clan, left))
.collect();
let offers = plane.clan_offers_to(&caller.principal_id)?;
Ok(Response::new(ListClansResponse { clans, offers }))
}
async fn invite(
&self,
request: Request<InviteToClanRequest>,
) -> Result<Response<ClanResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Clans.Invite")?;
let request = request.into_inner();
let clan = named(&request.clan, "clan")?;
let principal = named(&request.principal_id, "principal_id")?;
let row = self
.plane()?
.clan_invite(&clan, &caller.principal_id, &principal)?;
self.audit(&caller, "clan.invited", &clan, &principal, 0);
Ok(self.answer(&caller, row))
}
async fn accept(
&self,
request: Request<AcceptClanInviteRequest>,
) -> Result<Response<ClanResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Clans.Accept")?;
let request = request.into_inner();
let clan = named(&request.clan, "clan")?;
let room = self.room(&caller.principal_id)?;
let row = self.plane()?.clan_answer(
&clan,
&caller.principal_id,
true,
request.contribution_bytes,
room,
)?;
self.audit(
&caller,
"clan.joined",
&clan,
"",
request.contribution_bytes,
);
Ok(self.answer(&caller, row))
}
async fn decline(
&self,
request: Request<DeclineClanInviteRequest>,
) -> Result<Response<ClanResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Clans.Decline")?;
let request = request.into_inner();
let clan = named(&request.clan, "clan")?;
let target = match request.principal_id.trim() {
"" => caller.principal_id.clone(),
other => other.to_owned(),
};
let plane = self.plane()?;
if target == caller.principal_id {
let row =
plane.clan_answer(&clan, &caller.principal_id, false, 0, ClanRoom::default())?;
self.audit(&caller, "clan.declined", &clan, "", 0);
let mut row = self.seen(&caller.principal_id, row, None);
row.members.clear();
return Ok(Response::new(ClanResponse { clan: Some(row) }));
}
let row = plane.clan_withdraw(&clan, &caller.principal_id, &target)?;
self.audit(&caller, "clan.invite_withdrawn", &clan, &target, 0);
Ok(self.answer(&caller, row))
}
async fn set_contribution(
&self,
request: Request<SetClanContributionRequest>,
) -> Result<Response<ClanResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Clans.SetContribution")?;
let request = request.into_inner();
let clan = named(&request.clan, "clan")?;
let room = self.room(&caller.principal_id)?;
let used = self.used(&clan);
let row = self.plane()?.clan_set_contribution(
&clan,
&caller.principal_id,
request.contribution_bytes,
room,
used,
)?;
self.audit(
&caller,
"clan.contribution",
&clan,
"",
request.contribution_bytes,
);
Ok(self.answer(&caller, row))
}
async fn leave(
&self,
request: Request<LeaveClanRequest>,
) -> Result<Response<ClanResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Clans.Leave")?;
let clan = named(&request.into_inner().clan, "clan")?;
let used = self.used(&clan);
let row = self
.plane()?
.clan_leave(&clan, &caller.principal_id, used)?;
self.audit(&caller, "clan.left", &clan, "", 0);
let mut row = self.seen(&caller.principal_id, row, None);
row.members.clear();
row.invites.clear();
Ok(Response::new(ClanResponse { clan: Some(row) }))
}
async fn remove_member(
&self,
request: Request<RemoveClanMemberRequest>,
) -> Result<Response<ClanResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Clans.RemoveMember")?;
let request = request.into_inner();
let clan = named(&request.clan, "clan")?;
let member = named(&request.principal_id, "principal_id")?;
let used = self.used(&clan);
let row = self
.plane()?
.clan_remove(&clan, &caller.principal_id, &member, used)?;
self.audit(&caller, "clan.member_removed", &clan, &member, 0);
Ok(self.answer(&caller, row))
}
async fn set_admin(
&self,
request: Request<SetClanAdminRequest>,
) -> Result<Response<ClanResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Clans.SetAdmin")?;
let request = request.into_inner();
let clan = named(&request.clan, "clan")?;
let member = named(&request.principal_id, "principal_id")?;
let row =
self.plane()?
.clan_set_admin(&clan, &caller.principal_id, &member, request.admin)?;
self.audit(
&caller,
if request.admin {
"clan.admin_made"
} else {
"clan.admin_unmade"
},
&clan,
&member,
0,
);
Ok(self.answer(&caller, row))
}
async fn dissolve(
&self,
request: Request<DissolveClanRequest>,
) -> Result<Response<DissolveClanResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Clans.Dissolve")?;
let clan = named(&request.into_inner().clan, "clan")?;
let row = self.plane()?.clan_dissolve(&clan, &caller.principal_id)?;
self.audit(&caller, "clan.dissolved", &clan, "", 0);
Ok(Response::new(DissolveClanResponse { clan: Some(row) }))
}
async fn offer_repo(
&self,
request: Request<OfferRepoRequest>,
) -> Result<Response<ClanResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Clans.OfferRepo")?;
let request = request.into_inner();
let clan = named(&request.clan, "clan")?;
let repo = named(&request.repo, "repo")?;
let account = named(&request.account, "account")?;
let row = self
.plane()?
.clan_offer_repo(&clan, &caller.principal_id, &repo, &account)?;
self.audit_offer(
&caller,
"clan.repo_offered",
&clan,
&repo,
&account,
&caller.principal_id,
);
Ok(self.answer(&caller, row))
}
async fn answer_repo_offer(
&self,
request: Request<AnswerRepoOfferRequest>,
) -> Result<Response<AnswerRepoOfferResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require_self(&caller, GrantLevel::Admin, "Clans.AnswerRepoOffer")?;
let request = request.into_inner();
let clan = named(&request.clan, "clan")?;
let repo = named(&request.repo, "repo")?;
let plane = self.plane()?;
let absent =
|| Status::not_found(format!("there is no offer of {clan}/{repo} you may answer"));
let offer = plane.clan_repo_offer(&clan, &repo)?.ok_or_else(absent)?;
let to = offer.to_account.clone();
let offered_by = offer.offered_by.clone();
let account_admin = self
.ctx
.check(
&caller,
crate::auth::Access::namespace(&to),
GrantLevel::Admin,
)?
.is_none();
if !account_admin {
let clan_admin = plane.clan_role(&caller.principal_id, &clan)? == Some(ClanRole::Admin);
if !clan_admin {
return Err(absent());
}
if request.accept {
return Err(Status::permission_denied(format!(
"{clan}/{repo} is offered to account {to}: only its admin takes it"
)));
}
plane.clan_withdraw_offer(&clan, &caller.principal_id, &repo)?;
self.audit_offer(
&caller,
"clan.repo_offer_withdrawn",
&clan,
&repo,
&to,
&offered_by,
);
return Ok(Response::new(AnswerRepoOfferResponse {
offer: Some(offer),
repo: None,
}));
}
self.ctx.require(
&caller,
crate::auth::Access::namespace(&to),
GrantLevel::Admin,
"Clans.AnswerRepoOffer",
)?;
if !request.accept {
let offer = plane.clan_decline_offer(&clan, &repo, &to)?;
self.audit_offer(
&caller,
"clan.repo_offer_declined",
&clan,
&repo,
&to,
&offered_by,
);
return Ok(Response::new(AnswerRepoOfferResponse {
offer: Some(offer),
repo: None,
}));
}
let room = self.room(&to)?;
if let (Some(stored), Some(left)) = (offer.stored_bytes, plane.clan_room_left(&to, room)?) {
if stored > left {
return Err(Status::failed_precondition(format!(
"account {to} has {left} bytes of its own room unused, and {clan}/{repo} holds {stored}: \
{} bytes short. Add room to the account, or free some, before taking it",
stored - left
)));
}
}
let (offer, moved) = plane.clan_take_offer(&clan, &repo, &to, &caller.principal_id)?;
self.ctx
.bus
.publish(gunnar_proto::event::Payload::RepoDeleted(
gunnar_proto::RepoDeleted {
repo: Some(gunnar_proto::RepoPath::new(&clan, &repo)),
principal_id: caller.principal_id.clone(),
},
));
self.ctx
.bus
.publish(gunnar_proto::event::Payload::RepoCreated(
gunnar_proto::RepoCreated {
repo: Some(moved.clone()),
principal_id: caller.principal_id.clone(),
},
));
self.audit_offer(&caller, "clan.repo_moved", &clan, &repo, &to, &offered_by);
Ok(Response::new(AnswerRepoOfferResponse {
offer: Some(offer),
repo: Some(moved),
}))
}
}