crates/gunnar-grpc/src/service/accounts.rs
//! `Accounts`: the account gate, and the per-user hard purge.
//!
//! # Why every RPC here is Admin at SERVER scope
//!
//! [`crate::service::ServiceContext::require_repo_or_namespace`] exists because
//! a repo-scoped check on a repository the catalog has lost refuses everybody,
//! and the answer there was to widen to the namespace. **The pressure here runs
//! the other way.** An account IS a namespace — `AuthTable::owns_account` gives
//! every principal Admin on the namespace named after it, intrinsically and
//! unrevocably, so `Access::namespace(account)` is a check the subject of the
//! call always passes.
//!
//! For `Repos.Purge`, whose blast radius is one repository the caller already
//! owns, that is correct and it was chosen deliberately. For these verbs it
//! would mean:
//!
//! * **`Purge`** — any user can hard-purge their own account with one call, no
//! window and no undo. `Repos.Delete` goes to lengths to make a fat finger
//! recoverable; a self-serve verb that reclaims every repository, every
//! tombstone and every orphan in one request would undo all of it, and a
//! stolen SSH key would be able to end an account rather than merely read it.
//! * **`Gate`** — any user can lock themselves out, and then cannot `Ungate`,
//! because [`crate::seam::Identity::may`] answers `false` for a gated
//! principal at every level including `Server`/`Admin`. A self-brick with no
//! exit but another operator.
//!
//! So `Access::Server` at `GrantLevel::Admin` is not the *wide* choice here, it
//! is the narrowest scope that is **not self-granting**. `Repos.ListOrphans`
//! and `Repos.ReclaimOrphan` reached the same place from the other direction:
//! the answer spans tenants, so the grant must too.
//!
//! `Repos.Gc` argued its way DOWN to `Write` because it destroys nothing and a
//! verb gated tighter than its blast radius gets used never. Every verb here
//! destroys something no backup on this box can restore, so the same reasoning
//! points the opposite way.
use gunnar_proto::accounts_server::Accounts;
use gunnar_proto::GrantLevel;
use gunnar_proto::{
AccountGate, ClaimOperatorRequest, ClaimOperatorResponse, EnrollEmployeeRequest,
EnrollEmployeeResponse, GateAccountRequest, GateAccountResponse, GateReason,
ListAccountGatesRequest, ListAccountGatesResponse, ListEmployeesRequest, ListEmployeesResponse,
PurgeAccountRequest, PurgeAccountResponse, PurgeAccountStatusRequest,
PurgeAccountStatusResponse, PurgeJob, PurgeState, UngateAccountRequest, UngateAccountResponse,
};
use tonic::{Request, Response, Status};
use crate::auth::Access;
use crate::seam::{self, EmployeeAsk, PurgeAccountOptions};
use crate::service::AccountsService;
/// The seam's gate onto the wire.
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,
}
}
/// The seam's job onto the wire.
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,
}
}
/// An account name that is present and not blank, or `INVALID_ARGUMENT`.
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())
}
/// A note or reason that is present, or `INVALID_ARGUMENT`.
///
/// **Required, and refused rather than defaulted.** The note is what the
/// locked-out user is shown in every refusal they get from that moment on. A
/// default of "locked" would produce a lockout nobody — not the user, not the
/// operator reading `ListAccountGates` next month — can explain, and an
/// unexplained lockout is indistinguishable from a bug in the ACL.
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())
}
/// **The most users one request adds** (owner, 2026-10-04: "can be raised
/// up to 10 000").
pub const MOST_USERS: u32 = 10_000;
/// **The room each user holds when none is asked for**, and the least one
/// may hold (owner, 2026-10-04: "default 10 GB, can be raised"): 10 GiB.
pub const ROOM_PER_USER_BYTES: u64 = 10 << 30;
const GIB: u64 = 1 << 30;
/// **The rules a person types against**, for every door at once: the number
/// (1 when unset, at most [`MOST_USERS`]), the name (required for ONE user,
/// not asked for more), the room (default and floor [`ROOM_PER_USER_BYTES`]),
/// and a seat claim (a name, no number, no room: the seat decides).
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,
})
}
/// **The refusal of a short account**: what it has unused, what was asked,
/// and how much is missing — the one sentence every plane shares.
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),
)
}
/// Bytes as a person reads them: `10 GiB`, `1.5 GiB`, `512 MiB`.
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 {
/// A NEW employee's first-login link, the identity core's (IDENTITY-HOST):
/// seven days, no server-wide grant, made by the account's admin; `(id,
/// secret, expires)`, the secret here once and only its hash kept.
fn invite_for(
&self,
by: &str,
principal: &str,
account: &str,
) -> Result<(String, String, i64), Status> {
let identity = self.ctx.identity.clone().ok_or_else(|| {
Status::unavailable("this server serves no identity core, so it mints no link")
})?;
let made = identity
.enrolment(
principal,
by,
Some(crate::service::access::INVITE_DEFAULT_MS / 1000),
)
.map_err(crate::identity::status_of)?;
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "account.employee_invited",
principal = %by,
employee = %principal,
account = %account,
invite = %made.invite_id,
"an employee's first-login link was made with the enrolment"
);
Ok((
made.invite_id,
made.secret,
made.expires_at.saturating_mul(1000),
))
}
/// **The account's room, and what it stores**: its `pack_bytes` cap (the
/// signed fact monetize pushed — the METERED appliance) and what gunnar
/// measured it storing. `None`: nothing caps the account here — the
/// community appliance, or no fact (Free). A cap with no measurement to
/// set against it is refused rather than guessed: a meter with no reading
/// is not zero.
fn room_of(&self, account: &str) -> Result<Option<(u64, u64)>, Status> {
account_room(&self.ctx, account)
}
}
/// **An account's room and what it stores** (`pack_bytes` cap, measured
/// usage), or `None` when nothing caps it on this server — the question
/// `Accounts.EnrollEmployee` and an SSO first login (or a SCIM/CSV
/// provisioning) ask before a NEW employee takes room.
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),
)
}
/// [`account_room`], told whether this estate SELLS room (the feed's `sells_room`): there a
/// PERSON's own account with NO fact holds no room — `Some((0, stored))` — until its fact
/// lands ([`crate::selling::no_room_yet`]); everywhere else no fact is uncapped, as before.
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);
// An unreadable mark says seller: the account stays as it was, never closed by a guess.
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 {
/// **`Accounts.Gate`: lock a user out.** Admin at server scope; see the
/// module docs.
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)?;
// The account's own log says it was locked, and by whom: the one
// operator act an account admin must always be able to see.
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,
}))
}
/// **`Accounts.Ungate`: let them back in.**
///
/// An account that was not gated is a SUCCESS with `lifted` unset, not a
/// `NOT_FOUND`. The caller asked for the account to be open; it is open.
/// Answering not-found would make the honest retry after a partial failure
/// look like a mistake.
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),
}))
}
/// **`Accounts.ListAccountGates`: who is locked out.**
///
/// Admin at server scope like the rest, and for `Repos.ListDeleted`'s
/// reason as well as this service's: the answer spans tenants.
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(),
}))
}
/// **`Accounts.Purge`: gate now, walk later.**
///
/// The response carries both halves on purpose. `gate` is a fact by the
/// time this returns — the user is already locked out — and `job` is a
/// promise. A caller that treated the job as the whole answer and started
/// its own teardown would be racing a purge that has not begun; a caller
/// that treated the gate as the whole answer would think an account with
/// bytes still on it was finished.
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,
},
)?;
// Loud, at info, on the audit target. A hard purge is the one control
// call whose consequence cannot be read back off the box afterwards —
// the rows it removed are the rows that would have said what happened.
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)),
}))
}
/// **`Accounts.PurgeStatus`: where it got to, and whether the lockout is
/// off.**
///
/// Both, in one message, because neither answers the operator's actual
/// question on its own: a DONE job beside a gate that is still in force
/// means the account is empty and still shut, which is either an
/// administrative suspension the purge correctly refused to lift or a
/// lease that has not been released — and those need different actions.
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),
}))
}
/// **`Accounts.EnrollEmployee`: a NEW user, created by and bound to the
/// account** — what makes an account an enterprise account (owner,
/// 2026-10-03). The account admin's: `Admin` over the account's namespace,
/// the rule `Repos.SetVisibility` keeps. An existing principal is refused by
/// the plane, by id and by name; so is an id that names a namespace.
///
/// **"Add users" is this call, with room** (owner, 2026-10-04): the rules a
/// person types against are kept HERE, once, for every door (console,
/// CLI, monetize): [`users_asked`]. The room comes out of what the account
/// holds and has neither stored nor promised ([`Self::room_of`]), and
/// the plane checks it in the same write as the rows it adds.
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",
) {
// **The SELLER's paid grant** (a caller at server scope naming the
// order the customer paid for): a server grant does not reach
// inside an account (2026-10-05), but delivering what the
// account's admin bought is not looking inside it — the new user
// is the one the order names, and it is in the account's log.
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)?;
// A console's name is never an employee's (Stage L, 2026-10-04).
// Refused BEFORE anything is made: the console used to refuse it only
// at the invite, after this call had left the principal behind.
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",
));
}
}
// The name filter (2026-10-05): an employee is named here, inside the
// account — a held name waits for this account's admin.
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)?;
}
}
}
// **A grant the SELLER makes under a paid order is not refused for
// room.** monetize asked this same question (a dry run) before the
// money moved and sold exactly the shortfall; if the account stored a
// little more while the customer paid, the users they paid for are
// still theirs. Only a caller at server scope (monetize's principal,
// an operator) is the seller — an account admin naming an `order` is
// asked like any other.
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)
{
// A NEW principal holds no key, so no attestation is asked for. A
// key that does not register undoes the enrolment: an employee the
// admin cannot hand a key to is not the one that was asked for.
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 {
// **A paid "Add users" is the account admin's act** (2026-10-07):
// the admin ordered it, monetize took the money, and the SELLER
// (at server scope) delivers it here. The account's log names the
// account's admin and says who delivered it under which order —
// never an operator reaching inside the account, which is what
// the seller's own name read as ("alice (operator)").
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;
// No money word in an open build's sentences (open_build_speaks_no_money).
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"
);
}
// **The new named user's invite, in the same call** (the seat claim's
// and the paid grant's): the identity core's link — seven days, no
// server-wide role, made by the caller. Best effort: a refused
// invite is said and never undoes the enrolment (the admin's invite
// path can make another).
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,
}))
}
/// **`Accounts.ListEmployees`**: the account admin's, like enrolling.
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,
}))
}
/// **`Accounts.ClaimOperator`: the estate's operator, the account the first
/// admin passkey is** (2026-10-04). Admin at SERVER scope, like every other
/// verb here that makes or binds an account: the console asks it with its
/// own key when the break-glass token registers the first passkey, and the
/// primary asks it of its own plane when the installer feeds the name. The
/// NAME is gunnar's to decide — the fed one wins over the request's — so
/// what comes back is the principal to bind to, whatever was asked.
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();
// Checked before anything is claimed: a token sent in place of its hash
// is refused by name and never reaches a table.
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
)
),
}))
}
}