crates/gunnar-ui-server/proto/gunnar/v1/enterprise.proto
syntax = "proto3";
package gunnar.v1;
// **The owner's enterprise list** (2026-10-05): the account admin's audit
// log, the operator's aggregates and the account admin's grant of a look,
// and the name filter. Nothing here is bought: every account has it.
//
// The account model (owner, 2026-10-03): one account type; the person who
// starts it is its admin; employees are NEW users the admin enrolls; private
// by default; only the account admin makes things public. The OPERATOR (the
// estate's server admin) runs the box and sees totals — never an account's
// repository names, member names or events — unless the account's admin
// approves a time-limited look (`ApproveOperatorAccess`). That rule is in
// gunnar's ACL (a server-scope grant stops at an account's door), not in a
// console: every listing the operator calls is narrowed by the same rule.
//
// gunnar decides everything here; a console only relays and renders.
service Enterprise {
// ── The account admin's audit log ──────────────────────────────────────
//
// Every security-relevant event in the account: logins (passkey, SSO),
// credentials added and removed, invites, enrolments, members added and
// deactivated, visibility changes, repositories created and deleted,
// branch protection, approvals, billing, the operator's looks, names the
// filter stopped. Append-only and hash-chained per account (each entry
// names the one before it, the account hashed in); kept a year by default.
//
// AUTHORISATION: an ADMIN OF THE ACCOUNT — its own person, or a principal
// its admin made Admin over the namespace. A server-scope grant is not:
// the operator never reads an account's log, approved look or not.
rpc AuditLog(AuditLogRequest) returns (AuditLogResponse);
// The same entries, whole, as CSV or JSON (a file to keep). The export is
// itself an entry in the log.
rpc ExportAuditLog(ExportAuditLogRequest) returns (ExportAuditLogResponse);
// ── The operator's view ────────────────────────────────────────────────
//
// **Totals only**: how many accounts, members, repositories; storage used
// and sold; health. No name of anything inside an account. Server `Read`.
rpc Aggregates(AggregatesRequest) returns (AggregatesResponse);
// ── The operator's look, which the account admin grants ────────────────
//
// The operator ASKS (server `Admin`): an account, a reason, how long. It is
// pending — nothing changes — until the account's admin approves it with a
// FRESH user-verified step-up of their own passkey (verified here, as a
// machine enrolment's approval is), or refuses it. An approved look lets
// the operator READ inside the account until it ends on its own or the
// admin ends it. Every step is in the account's audit log.
rpc RequestOperatorAccess(RequestOperatorAccessRequest) returns (RequestOperatorAccessResponse);
// `account` set: that account's looks (its admin). Empty: the caller's
// own requests (the operator).
rpc ListOperatorAccess(ListOperatorAccessRequest) returns (ListOperatorAccessResponse);
// The account admin, with the step-up.
rpc ApproveOperatorAccess(ApproveOperatorAccessRequest) returns (ApproveOperatorAccessResponse);
// The account admin.
rpc RefuseOperatorAccess(RefuseOperatorAccessRequest) returns (RefuseOperatorAccessResponse);
// The account admin, or the operator that asked: ends it now.
rpc RevokeOperatorAccess(RevokeOperatorAccessRequest) returns (RevokeOperatorAccessResponse);
// ── The name filter ────────────────────────────────────────────────────
//
// Account, namespace, member and repository names are put to it where
// gunnar makes them (AddPrincipal, RenamePrincipal, EnrollEmployee,
// CreateNamespace, Repos.Create). A blocked or reserved name is refused
// with a sentence and the account admin is told; a borderline one is held
// for the ACCOUNT ADMIN's review (never the operator's).
//
// Ask without making anything (any authenticated caller): what would the
// filter say?
rpc CheckName(CheckNameRequest) returns (CheckNameResponse);
// The lists in force — gunnar's configuration (server `Read`).
rpc GetNameLists(GetNameListsRequest) returns (NameLists);
// Replace them (server `Admin`).
rpc SetNameLists(SetNameListsRequest) returns (NameLists);
// The account's queue: held names to decide, refused names to know about
// (an admin of the account).
rpc ListNameReviews(ListNameReviewsRequest) returns (ListNameReviewsResponse);
// Allow or refuse a held name (an admin of the account). Allowed, the same
// name passes in this account from now on.
rpc DecideNameReview(DecideNameReviewRequest) returns (DecideNameReviewResponse);
}
// One entry of an account's audit log.
message AuditEntry {
// 0 for the account's first entry, never reused.
uint64 seq = 1;
int64 at_unix_ms = 2;
// `family.verb`: `login`, `credential.added`, `visibility.changed` …
string kind = 3;
// The principal that did it; empty for the system.
string actor = 4;
// What the actor is to this account: `admin`, `member`, `operator`,
// `machine`, `outsider`, `system`.
string actor_role = 5;
// What it was done to: a repository, a principal, a credential.
string target = 6;
string detail = 7;
// `ok` or `refused`.
string outcome = 8;
// The previous entry's hash, and this entry's: the chain.
string prev = 9;
string hash = 10;
}
// What to show. Every field optional.
message AuditFilter {
// Kinds or families (`login`, `visibility`, `repo.deleted`).
repeated string kinds = 1;
string actor = 2;
int64 since_unix_ms = 3;
int64 until_unix_ms = 4;
// A substring of the target, the detail, the actor or the kind.
string text = 5;
}
// Does the account's chain hold, end to end?
message AuditChain {
bool holds = 1;
uint64 entries = 2;
// The newest entry's hash: what an admin records to notice a cut tail.
string tip_hash = 3;
// The last entry retention dropped, whose hash the first kept one names.
optional uint64 anchor_seq = 4;
optional uint64 broken_at_seq = 5;
string what = 6;
}
message AuditLogRequest {
// The account (its namespace).
string account = 1;
AuditFilter filter = 2;
// 0 = 50; at most 1000.
uint32 page_size = 3;
// The previous page's `next_before_seq`: only older entries.
optional uint64 before_seq = 4;
}
message AuditLogResponse {
string account = 1;
// Newest first.
repeated AuditEntry entries = 2;
// Ask with `before_seq` = this for the next page; unset on the last.
optional uint64 next_before_seq = 3;
AuditChain chain = 4;
// How long entries are kept, days.
uint32 retention_days = 5;
// Entries ever written to this account's log.
uint64 written = 6;
}
enum AuditExportFormat {
AUDIT_EXPORT_FORMAT_UNSPECIFIED = 0;
AUDIT_EXPORT_FORMAT_CSV = 1;
AUDIT_EXPORT_FORMAT_JSON = 2;
}
message ExportAuditLogRequest {
string account = 1;
AuditFilter filter = 2;
// UNSPECIFIED is CSV.
AuditExportFormat format = 3;
}
message ExportAuditLogResponse {
bytes body = 1;
// `text/csv; charset=utf-8` or `application/json`.
string content_type = 2;
// `audit-<account>-<UTC date>.csv`
string file_name = 3;
uint64 entries = 4;
}
message AggregatesRequest {}
message AggregatesResponse {
// Accounts: people who own a namespace (employees are members, not accounts).
uint64 accounts = 1;
// Accounts whose admin has enrolled employees.
uint64 enterprise_accounts = 2;
// Employees across every account.
uint64 members = 3;
// Repositories on the whole estate — inside accounts AND the operator's own
// namespaces — and how many of them are public.
uint64 repos = 4;
uint64 public_repos = 5;
// Bytes stored on the whole estate (gunnar's measure, pack + LFS), and the
// room held (the sum of every plan's pack cap; 0 on a box that sells none).
uint64 stored_bytes = 6;
uint64 sold_bytes = 7;
// Accounts holding a paid plan.
uint64 paid_accounts = 8;
// The operator's looks: approved and live, and waiting for an admin.
uint64 looks_live = 9;
uint64 looks_pending = 10;
// `ok`, or `alarm` with how many health alarms the box raises.
string health = 11;
uint32 health_alarms = 12;
int64 at_unix_ms = 13;
// The operator's SHARE of `repos`: what is NOT inside an account (the
// operator's own namespaces). Shown by name elsewhere; already in `repos`.
uint64 operator_repos = 14;
}
enum OperatorAccessState {
OPERATOR_ACCESS_STATE_UNSPECIFIED = 0;
OPERATOR_ACCESS_STATE_PENDING = 1;
OPERATOR_ACCESS_STATE_APPROVED = 2;
OPERATOR_ACCESS_STATE_REFUSED = 3;
OPERATOR_ACCESS_STATE_REVOKED = 4;
OPERATOR_ACCESS_STATE_EXPIRED = 5;
}
// One operator's look into one account.
message OperatorAccess {
string request_id = 1;
// For the eye (`ABC-123`): shown to the operator and beside the request.
string code = 2;
string account = 3;
string operator = 4;
string reason = 5;
// How long the look lasts once approved.
uint32 minutes = 6;
int64 requested_at_unix_ms = 7;
int64 request_expires_at_unix_ms = 8;
OperatorAccessState state = 9;
int64 granted_until_unix_ms = 10;
string decided_by = 11;
int64 decided_at_unix_ms = 12;
}
message RequestOperatorAccessRequest {
string account = 1;
// Required: the account admin reads it before deciding.
string reason = 2;
// 0 = an hour; at most a week (10080).
uint32 minutes = 3;
}
message RequestOperatorAccessResponse {
OperatorAccess request = 1;
}
message ListOperatorAccessRequest {
string account = 1;
}
message ListOperatorAccessResponse {
// Newest first.
repeated OperatorAccess requests = 1;
}
message ApproveOperatorAccessRequest {
string request_id = 1;
// The code shown beside the request: the approval is for THAT request.
string code = 2;
// 0 = the minutes asked for; otherwise 1 to 10080.
uint32 minutes = 3;
// Was the approver's step-up assertion; the approver's identity-core
// session must be stepped up instead.
reserved 4;
}
message ApproveOperatorAccessResponse {
OperatorAccess request = 1;
}
message RefuseOperatorAccessRequest {
string request_id = 1;
}
message RefuseOperatorAccessResponse {
OperatorAccess request = 1;
}
message RevokeOperatorAccessRequest {
string request_id = 1;
}
message RevokeOperatorAccessResponse {
OperatorAccess request = 1;
}
message CheckNameRequest {
// `account`, `member`, `namespace` or `repo`.
string kind = 1;
string name = 2;
// The account it would live in (a held name another admin allowed there
// passes); empty for a new account's own name.
string account = 3;
}
message CheckNameResponse {
// `clean`, `reserved`, `blocked`, `borderline`.
string verdict = 1;
// The sentence a refused person reads; empty when clean.
string sentence = 2;
}
// The filter's lists: gunnar's configuration.
message NameLists {
repeated string blocked = 1;
repeated string borderline = 2;
repeated string reserved = 3;
repeated string reserved_prefixes = 4;
repeated string allowed = 5;
}
message GetNameListsRequest {}
message SetNameListsRequest {
NameLists lists = 1;
}
// One name the filter stopped, in its account's queue.
message NameReview {
string id = 1;
string account = 2;
string kind = 3;
string name = 4;
// Who asked for it.
string actor = 5;
// `blocked`, `reserved`, `borderline`.
string verdict = 6;
// `pending`, `allowed`, `refused`.
string state = 7;
int64 at_unix_ms = 8;
string decided_by = 9;
int64 decided_at_unix_ms = 10;
}
message ListNameReviewsRequest {
string account = 1;
}
message ListNameReviewsResponse {
// Newest first.
repeated NameReview reviews = 1;
uint32 pending = 2;
}
message DecideNameReviewRequest {
string account = 1;
string id = 2;
bool allow = 3;
}
message DecideNameReviewResponse {
NameReview review = 1;
}