use gunnar_proto::accounts_server::Accounts;
use gunnar_proto::{
AccountGate, ClaimOperatorRequest, ClaimOperatorResponse, EnrollEmployeeRequest,
EnrollEmployeeResponse, GateAccountRequest,
GateAccountResponse, GateReason, ListAccountGatesRequest, ListAccountGatesResponse,
ListEmployeesRequest, ListEmployeesResponse, PurgeAccountRequest, PurgeAccountResponse,
PurgeAccountStatusRequest, PurgeAccountStatusResponse, PurgeJob, PurgeState,
UngateAccountRequest, UngateAccountResponse,
};
use gunnar_proto::GrantLevel;
use tonic::{Request, Response, Status};
use crate::auth::Access;
use crate::seam::{self, EmployeeAsk, PurgeAccountOptions};
use crate::service::AccountsService;
fn gate_out(gate: seam::AccountGate) -> AccountGate {
AccountGate {
account: gate.account,
reason: match gate.reason {
seam::GateReason::Administrative => GateReason::Administrative,
seam::GateReason::Purge => GateReason::Purge,
} as i32,
holder: gate.holder,
actor: gate.actor,
note: gate.note,
at_unix_ms: gate.at_unix_ms,
expires_unix_ms: gate.expires_unix_ms,
}
}
fn job_out(job: seam::PurgeJob) -> PurgeJob {
PurgeJob {
job_id: job.job_id,
account: job.account,
state: match job.state {
seam::PurgeState::Queued => PurgeState::Queued,
seam::PurgeState::Running => PurgeState::Running,
seam::PurgeState::Done => PurgeState::Done,
seam::PurgeState::Failed => PurgeState::Failed,
} as i32,
queue_position: job.queue_position,
stores_found: job.stores_found,
stores_purged: job.stores_purged,
bytes_reclaimed: job.bytes_reclaimed,
principal_removed: job.principal_removed,
gate_released: job.gate_released,
error: job.error.unwrap_or_default(),
failures: job.failures,
enqueued_at_unix_ms: job.enqueued_at_unix_ms,
started_at_unix_ms: job.started_at_unix_ms,
finished_at_unix_ms: job.finished_at_unix_ms,
}
}
fn account_of(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())
}
fn reason_of(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: it is what the locked-out user is shown in every refusal, and \
a lockout with no reason is a support ticket"
)));
}
Ok(trimmed.to_owned())
}
pub const MOST_USERS: u32 = 10_000;
pub const ROOM_PER_USER_BYTES: u64 = 10 << 30;
const GIB: u64 = 1 << 30;
pub fn users_asked(request: &EnrollEmployeeRequest) -> Result<EmployeeAsk, Status> {
let count = request.count.max(1);
if count > MOST_USERS {
return Err(Status::invalid_argument(format!(
"`count`: at most {MOST_USERS} users at once, not {count}"
)));
}
let name = request.name.trim();
let id = match request.id.trim() {
"" => name,
id => id,
};
if request.from_seat {
if count > 1 || request.room_bytes > 0 {
return Err(Status::invalid_argument(
"`from_seat` fills ONE seat with the room the seat holds: send no `count` and no \
`room_bytes`",
));
}
if name.is_empty() {
return Err(Status::invalid_argument("`name`: the user who fills the seat needs a name"));
}
} else if count == 1 && name.is_empty() {
return Err(Status::invalid_argument("`name`: one user needs a name"));
} else if count > 1 && (!name.is_empty() || !request.id.trim().is_empty() || !request.public_key.trim().is_empty()) {
return Err(Status::invalid_argument(format!(
"{count} users get no names here: each seat is named when an invite or an import fills \
it. Send `name` only for one user"
)));
}
let room_bytes = match request.room_bytes {
_ if request.from_seat => 0,
0 => ROOM_PER_USER_BYTES,
room if room < ROOM_PER_USER_BYTES => {
return Err(Status::invalid_argument(format!(
"`room_bytes`: each user holds at least {} GiB (it can be raised, not lowered); {} \
was asked",
ROOM_PER_USER_BYTES / GIB,
human_bytes(room)
)))
}
room => room,
};
let named = request.from_seat || count == 1;
Ok(EmployeeAsk {
id: if named { id.to_owned() } else { String::new() },
name: if named { name.to_owned() } else { String::new() },
room_bytes,
seats: if named { 0 } else { count },
from_seat: request.from_seat,
cap: None,
stored: 0,
order: request.order.trim().to_owned(),
dry_run: request.dry_run,
})
}
fn room_short(account: &str, ask: &EmployeeAsk, answer: &crate::seam::EmployeeAnswer) -> String {
let asked = match ask.seats {
0 => format!("1 user × {}", human_bytes(answer.room_bytes)),
seats => format!("{seats} users × {}", human_bytes(answer.room_bytes)),
};
format!(
"{account} has {} of room unused; {asked} need {}: {} short. Add room to the account, or \
ask for fewer users or less room per user",
human_bytes(answer.unused_bytes.unwrap_or(0)),
human_bytes(answer.need_bytes),
human_bytes(answer.shortfall_bytes),
)
}
fn human_bytes(bytes: u64) -> String {
const UNITS: [(u64, &str); 4] = [(1 << 40, "TiB"), (1 << 30, "GiB"), (1 << 20, "MiB"), (1 << 10, "KiB")];
for (unit, name) in UNITS {
if bytes >= unit {
let tenths = (u128::from(bytes) * 10 + u128::from(unit) / 2) / u128::from(unit);
return match tenths % 10 {
0 => format!("{} {name}", tenths / 10),
rest => format!("{}.{rest} {name}", tenths / 10),
};
}
}
format!("{bytes} bytes")
}
impl AccountsService {
fn invite_for(&self, by: &str, principal: &str, account: &str) -> Result<(String, String, i64), Status> {
let now = crate::bus::now_unix_ms();
let expires = now + crate::service::access::INVITE_DEFAULT_MS;
let secret = crate::webauthn::b64url(&crate::webauthn::random_bytes(32));
let invite = self.ctx.control.create_invite(crate::seam::NewInvite {
id: crate::webauthn::hex(&crate::webauthn::random_bytes(12)),
principal_id: principal.to_owned(),
created_by: by.to_owned(),
created_at_unix_ms: now,
expires_at_unix_ms: expires,
note: format!("an employee of {account}, invited by {by}"),
server_level: GrantLevel::Unspecified,
secret_hash: crate::webauthn::sha256_hex(secret.as_bytes()),
})?;
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "account.employee_invited",
principal = %by,
employee = %principal,
invite = %invite.invite_id,
"an employee's invite was made with the enrolment"
);
Ok((invite.invite_id, secret, invite.expires_at_unix_ms))
}
fn room_of(&self, account: &str) -> Result<Option<(u64, u64)>, Status> {
account_room(&self.ctx, account)
}
}
pub(crate) fn account_room(ctx: &crate::service::ServiceContext, account: &str) -> Result<Option<(u64, u64)>, Status> {
account_room_at(ctx, account, ctx.sells_room.load(std::sync::atomic::Ordering::Relaxed))
}
pub(crate) fn account_room_at(ctx: &crate::service::ServiceContext, account: &str, sells_room: bool) -> Result<Option<(u64, u64)>, Status> {
#[cfg(feature = "metered")]
{
use crate::seam::ControlError;
let (has_fact, cap) = match ctx.control.entitlement_get(account) {
Ok(Some(fact)) => (true, fact.caps.get("pack_bytes").copied().filter(|cap| *cap > 0)),
Ok(None) | Err(ControlError::FailedPrecondition(_)) => (false, None),
Err(err) => return Err(err.into()),
};
let cap = match cap {
Some(cap) => cap,
None if sells_room && !has_fact => {
let person = matches!(ctx.control.principal(account), Ok(Some(p)) if p.kind == gunnar_proto::PrincipalKind::User as i32);
let seller = ctx.control.holds_server_grant(account).unwrap_or(true);
if !crate::selling::no_room_yet(sells_room, has_fact, person, seller) {
return Ok(None);
}
0
}
None => return Ok(None),
};
let used = ctx
.control
.usage(Some(account))
.map_err(|err| {
Status::failed_precondition(format!(
"{account}'s room could not be counted: what it stores was not measured \
({err})"
))
})?
.into_iter()
.find(|row| row.namespace == account)
.map_or(0, |row| row.pack_bytes);
Ok(Some((cap, used)))
}
#[cfg(not(feature = "metered"))]
{
let _ = (ctx, account, sells_room);
Ok(None)
}
}
#[tonic::async_trait]
impl Accounts for AccountsService {
async fn gate(
&self,
request: Request<GateAccountRequest>,
) -> Result<Response<GateAccountResponse>, Status> {
let caller = self
.ctx
.authorize(&request, Access::Server, GrantLevel::Admin, "Accounts.Gate")?;
let request = request.into_inner();
let account = account_of(&request.account, "account")?;
let note = reason_of(&request.note, "note")?;
let (gate, was_already_gated) =
self.ctx
.control
.gate_account(&account, &caller.principal_id, ¬e)?;
self.ctx.account_event(
self.ctx.account_of_principal(&account).or_else(|| self.ctx.account_of_namespace(&account)),
crate::enterprise::AccountAuditEvent::by(&caller, "account.gated", account.clone()).detail(note.clone()),
);
Ok(Response::new(GateAccountResponse {
gate: Some(gate_out(gate)),
was_already_gated,
}))
}
async fn ungate(
&self,
request: Request<UngateAccountRequest>,
) -> Result<Response<UngateAccountResponse>, Status> {
let caller = self.ctx.authorize(
&request,
Access::Server,
GrantLevel::Admin,
"Accounts.Ungate",
)?;
let account = account_of(&request.into_inner().account, "account")?;
let lifted = self.ctx.control.ungate_account(&account)?;
if lifted.is_some() {
self.ctx.account_event(
self.ctx.account_of_principal(&account).or_else(|| self.ctx.account_of_namespace(&account)),
crate::enterprise::AccountAuditEvent::by(&caller, "account.ungated", account.clone()),
);
}
Ok(Response::new(UngateAccountResponse {
lifted: lifted.map(gate_out),
}))
}
async fn list_account_gates(
&self,
request: Request<ListAccountGatesRequest>,
) -> Result<Response<ListAccountGatesResponse>, Status> {
self.ctx.authorize(
&request,
Access::Server,
GrantLevel::Admin,
"Accounts.ListAccountGates",
)?;
let gates = self.ctx.control.account_gates()?;
Ok(Response::new(ListAccountGatesResponse {
gates: gates.into_iter().map(gate_out).collect(),
}))
}
async fn purge(
&self,
request: Request<PurgeAccountRequest>,
) -> Result<Response<PurgeAccountResponse>, Status> {
let caller =
self.ctx
.authorize(&request, Access::Server, GrantLevel::Admin, "Accounts.Purge")?;
let request = request.into_inner();
let account = account_of(&request.account, "account")?;
let reason = reason_of(&request.reason, "reason")?;
self.ctx.account_event(
self.ctx.account_of_principal(&account).or_else(|| self.ctx.account_of_namespace(&account)),
crate::enterprise::AccountAuditEvent::by(&caller, "account.purged", account.clone()).detail(reason.clone()),
);
let (job, gate) = self.ctx.control.purge_account(
&account,
PurgeAccountOptions {
principal_id: caller.principal_id.clone(),
reason,
remove_principal: request.remove_principal,
expect_empty: request.expect_empty,
},
)?;
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "control.account_purge",
method = "Accounts.Purge",
principal = %caller.principal_id,
account = %account,
job = %job.job_id,
remove_principal = request.remove_principal,
"an account hard purge was accepted and the account is now locked"
);
Ok(Response::new(PurgeAccountResponse {
job: Some(job_out(job)),
gate: Some(gate_out(gate)),
}))
}
async fn purge_status(
&self,
request: Request<PurgeAccountStatusRequest>,
) -> Result<Response<PurgeAccountStatusResponse>, Status> {
self.ctx.authorize(
&request,
Access::Server,
GrantLevel::Admin,
"Accounts.PurgeStatus",
)?;
let account = account_of(&request.into_inner().account, "account")?;
let (job, gate) = self.ctx.control.purge_account_status(&account)?;
Ok(Response::new(PurgeAccountStatusResponse {
job: job.map(job_out),
gate: gate.map(gate_out),
}))
}
async fn enroll_employee(
&self,
request: Request<EnrollEmployeeRequest>,
) -> Result<Response<EnrollEmployeeResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let account = account_of(&request.account, "account")?;
if let Err(status) = self.ctx.require(
&caller,
Access::namespace(&account),
GrantLevel::Admin,
"Accounts.EnrollEmployee",
) {
let paid = status.code() == tonic::Code::PermissionDenied
&& !request.order.trim().is_empty()
&& self
.ctx
.check(&caller, Access::Server, GrantLevel::Admin)?
.is_none();
if !paid {
return Err(crate::service::repos::account_admin_only_for(
status,
&account,
"the employees of account",
));
}
}
let mut ask = users_asked(&request)?;
for text in [ask.id.as_str(), ask.name.as_str()] {
if !text.is_empty() && crate::service::access::is_reserved_principal_name(text) {
return Err(crate::service::access::reserved_name_refusal(text, "an employee"));
}
}
if !request.dry_run {
for text in [ask.id.as_str(), ask.name.as_str()] {
if !text.is_empty() {
self.ctx.filter_name(&caller, "member", text, Some(account.clone()), false)?;
}
}
}
let seller = !ask.order.is_empty()
&& self
.ctx
.require(&caller, Access::Server, GrantLevel::Admin, "Accounts.EnrollEmployee")
.is_ok();
if !ask.from_seat && !seller {
if let Some((cap, stored)) = self.room_of(&account)? {
ask.cap = Some(cap);
ask.stored = stored;
}
}
let answer = self.ctx.control.enroll_employee(&account, &ask)?;
if answer.shortfall_bytes > 0 && !request.dry_run {
return Err(Status::failed_precondition(room_short(&account, &ask, &answer)));
}
let key = request.public_key.trim();
if let (Some(principal), false, false) = (&answer.principal, key.is_empty(), answer.unchanged) {
if let Err(err) = self.ctx.control.add_key(
&principal.id,
key,
"enrolled",
None,
&caller.principal_id,
None,
) {
let _ = self.ctx.control.remove_principal(&principal.id);
return Err(err.into());
}
}
if !answer.unchanged {
let delivered = seller.then(|| caller.principal_id.clone());
let mut event = crate::enterprise::AccountAuditEvent::by(
&caller,
if ask.seats > 0 { "member.seats_added" } else { "member.added" },
answer.principal.as_ref().map(|p| p.id.clone()).unwrap_or_default(),
)
.detail(if ask.seats > 0 {
format!("{} seats of {} bytes{}", ask.seats, answer.room_bytes, if ask.order.is_empty() { String::new() } else { format!(", order {}", ask.order) })
} else {
format!(
"a NEW user, {} bytes of room{}{}",
answer.room_bytes,
if ask.from_seat { ", into a seat" } else { "" },
if delivered.is_some() && !ask.order.is_empty() { format!(", order {}", ask.order) } else { String::new() }
)
});
if let Some(by) = delivered.filter(|by| *by != account) {
event.actor = account.clone();
event.actor_machine = false;
event.detail = format!("{}, delivered by {by}", event.detail);
}
self.ctx.account_event(Some(account.clone()), event);
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = if ask.seats > 0 { "account.seats_added" } else { "account.employee_enrolled" },
principal = %caller.principal_id,
account = %account,
employee = %answer.principal.as_ref().map(|p| p.id.as_str()).unwrap_or(""),
seats = ask.seats,
from_seat = ask.from_seat,
room_bytes = answer.room_bytes,
order = %ask.order,
"an account admin added users"
);
}
let (invite_id, invite_secret, invite_expires_at_unix_ms) = match (&answer.principal, request.invite, answer.unchanged) {
(Some(principal), true, false) => match self.invite_for(&caller.principal_id, &principal.id, &account) {
Ok(made) => made,
Err(err) => {
tracing::warn!(
target: crate::auth::AUDIT_TARGET,
event = "account.employee_not_invited",
principal = %caller.principal_id,
employee = %principal.id,
reason = %err.message(),
"the employee was enrolled and its invite was refused"
);
(String::new(), String::new(), 0)
}
},
_ => (String::new(), String::new(), 0),
};
Ok(Response::new(EnrollEmployeeResponse {
principal: answer.principal,
room_bytes: answer.room_bytes,
seats: answer.seats,
seats_open: answer.seats_open,
need_bytes: answer.need_bytes,
unused_bytes: answer.unused_bytes,
shortfall_bytes: answer.shortfall_bytes,
unchanged: answer.unchanged,
invite_id,
invite_secret,
invite_expires_at_unix_ms,
}))
}
async fn list_employees(
&self,
request: Request<ListEmployeesRequest>,
) -> Result<Response<ListEmployeesResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let account = account_of(&request.into_inner().account, "account")?;
self.ctx
.require(
&caller,
Access::namespace(&account),
GrantLevel::Admin,
"Accounts.ListEmployees",
)
.map_err(|status| {
crate::service::repos::account_admin_only_for(status, &account, "the employees of account")
})?;
let (seats, seats_open) = self.ctx.control.seats(&account)?;
Ok(Response::new(ListEmployeesResponse {
employees: self.ctx.control.employees(&account)?,
seats,
seats_open,
}))
}
async fn claim_operator(
&self,
request: Request<ClaimOperatorRequest>,
) -> Result<Response<ClaimOperatorResponse>, Status> {
let caller = self.ctx.authorize(
&request,
Access::Server,
GrantLevel::Admin,
"Accounts.ClaimOperator",
)?;
let request = request.into_inner();
let bootstrap = match request.bootstrap_invite_sha256.trim() {
"" => None,
raw => Some(crate::seam::bootstrap_hash(raw)?),
};
let claim = self.ctx.control.claim_operator(&request.name)?;
let invite = match &bootstrap {
Some(hash) => Some(self.ctx.control.operator_bootstrap_invite(
&claim.principal.id,
hash,
crate::bus::now_unix_ms(),
crate::seam::OPERATOR_INVITE_TTL_MS,
)?),
None => None,
};
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "account.operator_claimed",
principal = %caller.principal_id,
operator = %claim.principal.id,
created = claim.created,
adopted_namespace = claim.adopted_namespace,
named_by = claim.named_by,
bootstrap_invite = ?invite,
"the estate's operator was claimed: the first admin's passkey binds to this account"
);
Ok(Response::new(ClaimOperatorResponse {
principal: Some(claim.principal),
created: claim.created,
adopted_namespace: claim.adopted_namespace,
named_by: claim.named_by.to_owned(),
bootstrap_invite: matches!(
invite,
Some(crate::seam::BootstrapInvite::Recorded | crate::seam::BootstrapInvite::Renewed)
),
}))
}
}