Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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;
}