crates/gunnar-ui-server/proto/gunnar/v1/accounts.proto
syntax = "proto3";
package gunnar.v1;
import "gunnar/v1/common.proto";
// **The per-USER verbs: lock an account out, and empty it.**
//
// `Repos` owns the per-REPOSITORY reclaims — `Delete --purge`, `Purge`,
// `ReclaimOrphan` — and every one of them names one repository. This service
// is the per-ACCOUNT equivalent, and it is a separate service rather than four
// more RPCs on `Repos` because its unit is a *user*, not a path: in gunnar a
// principal intrinsically owns the namespace whose name is its own id, so an
// account is one name that is simultaneously an identity and a namespace, and
// the verbs here act on both halves at once.
//
// ⚠ Everything here is Admin **at server scope**, and the reason is in the
// handlers: a namespace-scoped check would not be *narrower*, it would be
// self-granting. `AuthTable::owns_account` gives every principal Admin on its
// own namespace intrinsically and unrevocably, so a namespace check would mean
// any user can hard-purge themselves with one call and no undo, and can gate
// themselves into an account they can no longer ungate.
service Accounts {
// **Lock a user out. Completely.**
//
// Not read-only, and that is the whole design. The tempting shape is "gated
// means they can still clone" — and in gunnar a read CREATES things: a fetch
// opens the store and lazily explodes objects into the read cache (the same
// cache `Repos.Gc` reclaims), the first read of a pack with no index builds
// one and writes it, a log walk caches. So a gate that permitted reads would
// be a reader putting bytes back into a directory a purge already walked
// past.
//
// The gate is therefore total and it is TWO-DIRECTIONAL: the gated principal
// may do nothing anywhere, including on grants it holds in other people's
// namespaces; and nobody may reach into the gated account, including a
// stranger cloning a public repository out of it. The second half is the one
// that makes `Purge` terminate.
//
// **It is not part of Purge, it is its own verb**, because both callers need
// it on its own: monetize locking a non-paying customer out, an operator
// freezing an account under investigation. `Purge` then USES it rather than
// hiding a flag of its own.
//
// Idempotent: gating an already-gated account re-stamps the row and leaves
// it gated. The newest writer owns it — an operator must be able to suspend
// an account a purge is already holding.
rpc Gate(GateAccountRequest) returns (GateAccountResponse);
// **Let them back in.** Unconditional: it does not consult who took the
// gate, so a lease left behind by a purge that died can always be cleared by
// hand. That is deliberate — the alternative is an account nobody can open.
//
// Answering with `gated: false` for an account that was not gated is a
// success, not a `NOT_FOUND`: the caller asked for the account to be open
// and it is open.
rpc Ungate(UngateAccountRequest) returns (UngateAccountResponse);
// **Who is locked out.** An empty list is a real answer.
//
// Expired purge leases are NOT in it. A lease that has run out is not a
// lockout that is in force, and listing one would tell an operator an
// account is shut when it is open.
rpc ListAccountGates(ListAccountGatesRequest) returns (ListAccountGatesResponse);
// **The hard purge: everything this user owns, gone.**
//
// Every repository in the account, every soft-delete tombstone still holding
// bytes, every orphan store directory with no catalog row, and the namespace
// rows themselves — including nested namespaces under the account's name,
// which live in the same directory subtree and would otherwise sit on the
// disk and on the bill after an operator was told the account was emptied.
// Optionally the principal too; see `remove_principal`.
//
// **There is no window and no undo.** `Repos.Delete` without `purge` leaves a
// dated tombstone precisely so a fat finger is recoverable; this verb is the
// one that says the account is finished, and it reclaims tombstones rather
// than making them.
//
// **It GATES first, synchronously, and it ENQUEUES second.**
//
// The gate is taken before this call returns, so a caller that gets a job id
// back knows the user is already locked out — there is no window in which
// the purge is "accepted" and the account is still live. The walk itself is
// then queued: it is minutes of pure unlink I/O across every repository in
// an account, and an RPC that blocked for it would hold a tonic worker for
// the duration and make a console button look broken. Same argument as
// `Repos.Gc`, opposite queue — see `PurgeAccountResponse.job`.
//
// A second request for an account already queued or running returns the job
// that is already there rather than enqueuing twice.
rpc Purge(PurgeAccountRequest) returns (PurgeAccountResponse);
// **Where an account's purge got to**, including whether the lockout has
// been released. Poll it.
//
// A finished job stays readable for a while after the worker moves on, so a
// poller that was between requests when it completed still sees the result
// rather than an empty answer indistinguishable from "never asked".
rpc PurgeStatus(PurgeAccountStatusRequest) returns (PurgeAccountStatusResponse);
// **Enroll an employee: a NEW user, created by and bound to the account.**
// (owner, 2026-10-03: "a user starts an account. he can choose to enroll new
// users=employees then it becomes an enterprise account".) The id must be
// new — an existing principal can never become an employee ("they might have
// repos already, that would be a risk for the company") — and must not name
// an existing namespace. The employee gets no account of its own, Read on the
// account's namespace, and may not make anything public: that is the account
// admin's. AUTHORISATION: Admin over the account's namespace (its admin).
//
// **"Add users" (owner, 2026-10-04) is this same call**, with room: every
// employee holds `room_bytes` of the ACCOUNT's room (default 10 GiB), and
// `count` > 1 asks for that many SEATS instead of one named user — no names;
// each seat is filled later by `from_seat` (an invite, later an import),
// which creates the named employee and gives it the seat's room. The room
// comes out of what the account holds and has not used or promised: its
// `pack_bytes` cap, minus what it stores, minus the room its employees and
// open seats already hold. Short → FAILED_PRECONDITION naming how much is
// missing, and nothing is written; `dry_run` asks the same question and
// writes nothing either way (the seller asks it before money moves).
rpc EnrollEmployee(EnrollEmployeeRequest) returns (EnrollEmployeeResponse);
// The account's employees. AUTHORISATION: Admin over the namespace.
rpc ListEmployees(ListEmployeesRequest) returns (ListEmployeesResponse);
// **The estate's OPERATOR: the account the first admin passkey IS**
// (owner, 2026-10-04: "a YubiKey is used to register me as an admin user. I
// can THEN select to ADD ssh keys to it" / "The UI shall not DO anything
// such as create a user — it shall CALL gunnar-server").
//
// gunnar decides who the operator is, not the caller: the name the installer
// FED (`operator_name`, the estate's `[operator] name`) wins over the one in
// the request, which is used only on a box nothing named an operator for.
// The answer is that principal — a USER, id = name, with its account
// namespace, gunnar-admin — created by this call when it does not exist yet
// (the primary also claims it itself when the feed arrives, so on a fresh
// estate it already does). Idempotent: a second claim answers the same row,
// `created: false`.
//
// **An ownerless namespace of that name is ADOPTED, not refused.** A first
// push by a gunnar-admin conjures the namespace it pushes into
// (`repo.created_on_first_push`); when that push was the operator's own
// (test2 2026-10-04: `rickard/t2-scratch`, pushed with the installer's
// admin key before anybody had registered), `AddPrincipal` refused the
// operator's own account for ever ("namespace already exists") and the
// first admin's passkey bound to nothing. Only the operator's name is
// adopted, only when no principal holds that id, and only by a server Admin
// — who could already delete that namespace or grant it to anyone.
//
// The principal is created WITHOUT a key: the console's own credential for
// the person is its first key (no attestation owed), and the person's SSH
// keys come after, vouched for by that one. On an installed estate that
// credential is already there when the first passkey binds: the front
// offers it (`operator_console_pubkey`) and the primary enrols it on the
// account it claims at install. A machine principal under the name is
// refused (FAILED_PRECONDITION); no name at all — nothing fed, none asked —
// is FAILED_PRECONDITION too.
//
// AUTHORISATION: Admin at SERVER scope.
rpc ClaimOperator(ClaimOperatorRequest) returns (ClaimOperatorResponse);
}
message ClaimOperatorRequest {
// The name the CALLER would use — the console sends the user the first
// admin passkey was registered under. Ignored when the installer fed the
// operator's name: gunnar's answer is then that principal, whatever this
// says. Empty: only the fed name.
string name = 1;
// **The operator's first invite** (Stage L, 2026-10-04): the lowercase hex
// SHA-256 of the break-glass token the installer handed the owner — never
// the token. gunnar records it as the invite for the operator's FIRST
// passkey (`invite_id` "operator-bootstrap", no server grant: the operator
// already is gunnar-admin), so the console's first-admin registration is
// the identity core's redemption of the token (`IdentityService.RedeemStart`
// with it as `secret`: the core finds the invite by the token's SHA-256),
// decided by gunnar. Ignored, and the answer says so, once the operator holds a
// passkey: the bootstrap is spent. Empty: no invite is recorded. The
// installer feeds the same hash as `operator_invite_sha256`.
string bootstrap_invite_sha256 = 2;
}
message ClaimOperatorResponse {
// The operator, as gunnar holds it now: bind to `id`, pin
// `created_at_unix_ms` beside it.
Principal principal = 1;
// True when THIS call created the principal (and claimed its namespace).
bool created = 2;
// True when the namespace already existed with no principal of that id and
// was adopted as the operator's account rather than refused.
bool adopted_namespace = 3;
// `fed` (the installer's `operator_name`) or `asked` (the request's name).
string named_by = 4;
// True when the operator's first invite is pending after this call (it was
// recorded or kept); false when none was asked for, or the operator already
// holds a passkey (the bootstrap is spent).
bool bootstrap_invite = 5;
}
message EnrollEmployeeRequest {
// The account (= its namespace) the employee belongs to.
string account = 1;
// The new principal's id; empty: the name.
string id = 2;
// Its display name.
string name = 3;
// Optional: an OpenSSH public key line registered on it at once.
string public_key = 4;
// **Room per user**, bytes, held for each employee (or seat) out of the
// account's room. 0 = the server's default, 10 GiB; less than that is
// refused (it can be raised, not lowered).
uint64 room_bytes = 5;
// **How many users.** 0 or 1: ONE named employee (`name` required). 2 up
// to 10 000: that many SEATS, no names (`id`, `name` and `public_key` must
// be empty).
uint32 count = 6;
// **Fill one of the account's open seats** with this NEW named employee
// (the seat claim an invite or an import makes). The employee gets the
// seat's room; `room_bytes` and `count` must be 0. Refused when no seat is
// open.
bool from_seat = 7;
// **Ask, write nothing.** The answer says whether it fits and, if not, how
// much room is missing (`shortfall_bytes`); a name that is taken is still
// refused, as the real call would refuse it.
bool dry_run = 8;
// The ORDER this enrolment belongs to (the seller's reference), so a
// retried grant is answered with the first one instead of granted twice.
// Empty: none.
string order = 9;
// **Also make the new NAMED user's invite**, in this same call (the seat
// claim's and the paid grant's, 2026-10-04): `Access.CreateInvite`'s —
// seven days, no server-wide role — made by the caller. The secret comes
// back ONCE in the answer; a seat grant, a dry run and a repeated order
// make none. A refused invite never undoes the enrolment.
bool invite = 10;
}
message EnrollEmployeeResponse {
// The named employee. Unset for seats and for a dry run.
Principal principal = 1;
// The room each new employee (or seat) holds, bytes.
uint64 room_bytes = 2;
// The account's seats after this call: every seat ever granted, and the
// ones not yet filled.
uint32 seats = 3;
uint32 seats_open = 4;
// The room this call asks of the account (count × room per user; 0 for a
// seat claim, whose room was set aside with the seat), bytes.
uint64 need_bytes = 5;
// The account's room that is neither stored nor promised, before this call,
// bytes. Unset: the account has no cap on this server (nothing limits it).
optional uint64 unused_bytes = 6;
// How much room is missing (0 = it fits). Only a dry run answers with
// more than 0; the real call refuses instead.
uint64 shortfall_bytes = 7;
// True when nothing was written: a dry run, or the repeat of an `order`
// already granted (then the fields above describe that first grant).
bool unchanged = 8;
// The invite `invite` asked for: its id, its secret (here once and never
// again — gunnar keeps only its hash), and when it stops being redeemable.
// Empty when none was made.
string invite_id = 9;
string invite_secret = 10;
int64 invite_expires_at_unix_ms = 11;
}
message ListEmployeesRequest {
string account = 1;
}
message ListEmployeesResponse {
repeated Principal employees = 1;
// The account's seats: every seat ever granted, and the ones not yet filled.
uint32 seats = 2;
uint32 seats_open = 3;
}
// Why an account is locked out.
enum GateReason {
GATE_REASON_UNSPECIFIED = 0;
// Somebody decided: an operator suspending an account, monetize locking a
// non-paying customer out. **No deadline.** It stays until it is lifted,
// because "suspended for non-payment" must not quietly time out.
GATE_REASON_ADMINISTRATIVE = 1;
// A purge is holding it. Carries `expires_unix_ms` and is re-stamped by the
// worker as it goes — see `AccountGate.expires_unix_ms`.
GATE_REASON_PURGE = 2;
}
// One account, locked.
message AccountGate {
// The principal id, which is also the namespace that principal owns.
string account = 1;
GateReason reason = 2;
// Who set it: a principal id for a person, a job id for a purge. Only a
// holder that still matches may lift its own gate, which is what stops a
// purge finishing from silently un-suspending an account somebody suspended
// while it ran.
string holder = 3;
// The principal that asked, for the audit trail.
string actor = 4;
// Free text, shown to the locked-out user in the refusal they get. A lockout
// with no reason is a support ticket.
string note = 5;
int64 at_unix_ms = 6;
// **0 means no deadline** — an administrative gate, in force until lifted.
//
// Non-zero is a purge's LEASE, and it is the thing that stops a dead purge
// from bricking an account. A purge that is SIGKILLed between two unlinks
// leaves a locked account with no process left to unlock it and nobody who
// remembers a row they never saw; a lease runs out on its own instead. The
// worker re-stamps it after every repository it reclaims, so a long purge
// does not unlock underneath itself.
int64 expires_unix_ms = 7;
}
message GateAccountRequest {
// The account: a principal id.
string account = 1;
// Shown to the locked-out user. **Required** — a refusal with no reason
// leaves them nothing to act on, so an empty one is INVALID_ARGUMENT rather
// than a lockout nobody can explain.
string note = 2;
}
message GateAccountResponse {
AccountGate gate = 1;
// True when the account was ALREADY gated when the request arrived. Not an
// error — a caller that wanted the account shut has it shut — but the two
// are different facts and a console showing "locked by you just now" over
// somebody else's older lockout would be lying.
bool was_already_gated = 2;
}
message UngateAccountRequest {
string account = 1;
}
message UngateAccountResponse {
// What was lifted. Unset when the account was not gated.
AccountGate lifted = 1;
}
message ListAccountGatesRequest {}
message ListAccountGatesResponse {
repeated AccountGate gates = 1;
}
message PurgeAccountRequest {
// The account: a principal id, which is also its namespace.
string account = 1;
// Why, for the audit trail and for the lockout message the user sees while
// it runs. **Required**, like `Gate.note`.
string reason = 2;
// **Also remove the principal: its keys, its grants, its tokens, its group
// memberships.** Default false.
//
// False is the right default because the two callers want different things
// and only one of them is reversible-ish. The console's case is "delete this
// user's stored data": the account shell stays, gated, and an `Ungate` gives
// them an empty account back. monetize's case is a customer who is gone, and
// it sets this.
//
// Removing the principal also removes its gate, in the same write — a gate
// keyed by a name nobody holds would lock out whoever is created under that
// name next.
bool remove_principal = 3;
// **Refuse unless the account is empty of live repositories.** For a caller
// that believes it has already emptied the account by other means and wants
// the server to check rather than to destroy.
bool expect_empty = 4;
}
message PurgeAccountResponse {
// The job now responsible for this account — freshly enqueued, or the one
// that was already queued or running when the request arrived.
PurgeJob job = 1;
// **The lockout, as it stands now.** Present on every successful response,
// because the gate is taken BEFORE this returns: a caller holding this
// message knows the user is already locked out, with no window between
// acceptance and enforcement.
AccountGate gate = 2;
}
enum PurgeState {
PURGE_STATE_UNSPECIFIED = 0;
// Waiting its turn. `queue_position` says how many are ahead.
PURGE_STATE_QUEUED = 1;
// The one purge the server is running.
PURGE_STATE_RUNNING = 2;
// Finished, and everything it set out to reclaim is gone.
PURGE_STATE_DONE = 3;
// Finished badly. `error` says how and `failures` says where. **The account
// is in a PARTIAL state** and what is left is what `failures` names.
PURGE_STATE_FAILED = 4;
}
// One account purge, running or completed.
message PurgeJob {
// Stable for the life of the job. Also the gate's `holder` while it runs, so
// a console can tell "the lockout my purge took" from "a lockout somebody
// else put on this account".
string job_id = 1;
string account = 2;
PurgeState state = 3;
// How many jobs are ahead of this one. 0 unless QUEUED.
uint32 queue_position = 4;
// How many repositories, tombstones and orphan stores the walk found. **0
// until the job starts**: it is counted when its turn comes and not when it
// is enqueued, because an account can grow while a job waits and a total
// taken at enqueue time would be a promise the walk cannot keep.
uint32 stores_found = 5;
// How many of those are gone. Reaches `stores_found` on a DONE job.
uint32 stores_purged = 6;
// Bytes that left the filesystem.
uint64 bytes_reclaimed = 7;
// Whether the principal was removed. False on a job that was not asked to.
bool principal_removed = 8;
// **Whether the lockout this job took has been released.**
//
// On the wire, and not merely in a log, because it is the one property that
// decides whether an account is usable again — and the failure mode it
// guards against (a purge that dies holding the gate) produces a job that
// never reports at all. A FAILED job with `gate_released: true` is a purge
// that went wrong and left the account open; a job stuck RUNNING with it
// false is one to go and look at.
//
// **False is also correct on a job that found an ADMINISTRATIVE gate already
// in place** — the purge did not take that gate and must not lift it, so the
// account stays shut on somebody else's authority. `gate` on the status
// response says whose.
bool gate_released = 9;
// Only set when FAILED.
string error = 10;
// One line per store the purge could not reclaim, naming it and why. **The
// recovery list**: everything here is still on the disk and still on the
// bill, and `Repos.ListOrphans` will show it.
repeated string failures = 11;
int64 enqueued_at_unix_ms = 12;
// 0 until the job leaves QUEUED.
int64 started_at_unix_ms = 13;
// 0 until the job leaves RUNNING.
int64 finished_at_unix_ms = 14;
}
message PurgeAccountStatusRequest {
string account = 1;
}
message PurgeAccountStatusResponse {
// Unset when the server is holding no job for this account: none was ever
// asked for, or the last one finished long enough ago to have been dropped.
// Absence and "it did nothing" are different answers and this is which.
PurgeJob job = 1;
// The lockout on this account right now, if any. Unset means the account is
// open — which, beside a DONE job, is the pair of facts an operator is
// actually looking for.
AccountGate gate = 2;
}