platformadmin

package
v0.1.16 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: Apache-2.0 Imports: 32 Imported by: 0

Documentation

Overview

Package platformadmin implements the BACKEND of the admin panel (ADMIN-API-V1): a platform super-admin that lives ABOVE the tenants, plus a consolidated admin API for managing tenants, their users, and their observability.

The guiding principle is that the admin panel is NOT a second permission system. It INHERITS what already exists:

  • auth-as-product (pkg/userauth): the super-admin authenticates with the same argon2id password + TOTP MFA mechanism a tenant user does — but against a SYSTEM schema (appximo_system.platform_admins), not a tenant. The reusable primitives are exported from pkg/userauth (see primitives.go).
  • schema-per-tenant isolation (search_path / tenant_<id> identifiers): a tenant admin physically cannot reach another tenant; this is structural, not coded.
  • the schema RBAC: "tenant admin" is just a tenant user whose role grants broad access — not a new concept.
  • the control plane (pkg/controlplane): tenant create/lookup is WRAPPED, never reimplemented.
  • the observability store (pkg/observability): per-tenant traces/metrics are EXPOSED with the right authorization, never duplicated.

This package adds only what did not exist: the platform-level super-admin (today the bare X-Admin-Key machine credential has no human identity) and the HTTP API that consolidates the above behind one authenticated, audited surface.

Index

Constants

View Source
const DefaultSuperAdminRole = "platform_super_admin"

DefaultSuperAdminRole is the role stamped on a platform admin. It is a marker inside the platform JWT (scope=platform), distinct from any tenant RBAC role.

View Source
const SystemSchema = "appximo_system"

SystemSchema is the Postgres schema that holds platform-level identity. It sits ABOVE every tenant_<id> schema: a platform admin is not a tenant user. The name is a fixed identifier (never tenant-derived), so it can never collide with a tenant schema (those are always tenant_<id>).

Variables

View Source
var (
	ErrInvalidCredentials = errors.New("platformadmin: invalid credentials")
	ErrTooManyAttempts    = errors.New("platformadmin: too many attempts")
	ErrWeakPassword       = errors.New("platformadmin: password too short")
	ErrInvalidEmail       = errors.New("platformadmin: invalid email")
	ErrMFANotEnrolled     = errors.New("platformadmin: mfa not enrolled")
	ErrMFAInvalidCode     = errors.New("platformadmin: invalid mfa code")
	ErrMFAConfig          = errors.New("platformadmin: mfa not configured")
	ErrUnknownRole        = errors.New("platformadmin: role not declared in the schema RBAC")
	ErrTenantNotFound     = errors.New("platformadmin: tenant not found")
	ErrConfirmRequired    = errors.New("platformadmin: explicit confirmation required")
	// ErrAlreadyBootstrapped guards the first-run bootstrap: once ANY platform
	// admin exists, the bootstrap route is permanently closed (admins are then
	// created by an authenticated admin, or the CLI).
	ErrAlreadyBootstrapped = errors.New("platformadmin: an admin already exists")
	// ErrSchemaRejected marks a PersistBootSchema failure caused by an INVALID
	// schema — nothing was persisted and no restart happens (→ 422). Any other
	// persist failure (e.g. an unwritable schema file) is an infrastructure 500.
	ErrSchemaRejected = errors.New("schema rejected")
)

Sentinel errors mapped to HTTP status by the handlers.

View Source
var (
	ErrAdminEmailTaken = errors.New("platformadmin: admin email already registered")
	ErrAdminNotFound   = errors.New("platformadmin: admin not found")
)

ErrAdminEmailTaken is returned by CreateAdmin when the email already exists in the system schema. ErrAdminNotFound when no admin matches a lookup.

View Source
var ErrPlatformToken = errors.New("platformadmin: invalid or missing platform token")

ErrPlatformToken is returned when a platform token is missing/invalid/expired or is not actually a platform-scoped token (e.g. a tenant token presented to an admin route).

View Source
var ErrResourceNotFound = errors.New("platformadmin: resource not found")

ErrResourceNotFound is returned when a tenant has no such resource (or no schema).

Functions

This section is empty.

Types

type Admin

type Admin struct {
	ID            string
	Email         string
	PasswordHash  string
	Role          string
	EmailVerified bool
	CreatedAt     time.Time
	UpdatedAt     time.Time
}

Admin is a stored platform super-admin. PasswordHash is never serialized to a client and never logged (handlers build their own response shape).

type Config

type Config struct {
	// JWTSecret signs platform tokens — the SAME engine secret, one signing key.
	JWTSecret string
	// MFAKey encrypts the TOTP secret at rest (falls back to JWTSecret).
	MFAKey string
	// MFAIssuer is the authenticator-app label (default "Appximo Platform").
	MFAIssuer string
	// SuperAdminRole is the role stamped on platform admins (default
	// platform_super_admin). It is a platform marker, not a tenant RBAC role.
	SuperAdminRole string
	// MinPasswordLength for a new admin (default 12 — a privileged credential).
	MinPasswordLength int

	// RoleExists reports whether a role name is declared in the (boot) schema RBAC.
	// Used to validate a role before assigning it to a tenant user (so the admin API
	// returns a clear error instead of creating a user who can never be authorized).
	// nil ⇒ no validation (any role accepted).
	RoleExists func(role string) bool
	// TenantAdminRole reports whether a tenant RBAC role is "admin-grade" — broad
	// enough that a holder may view their OWN tenant's observability. nil ⇒ no tenant
	// user is treated as an observability admin (only the platform super-admin sees
	// observability). Implemented over the boot RBAC policy (inherits RBAC).
	TenantAdminRole func(role string) bool

	// ServedResources is the set of resource names the engine serves LIVE — the REST
	// routes, GraphQL types and RBAC policy are all compiled from the BOOT --schema
	// (codegen.BuildRouter / gqlhandler.BuildHandler / rbac.RBACMiddleware), globally
	// and at boot. A resource deployed to a tenant but ABSENT here gets its tables
	// provisioned by the migration, but its API is unavailable (403 from RBAC deny-by-
	// default, or 404 when a wildcard role passes RBAC but no route exists) until the
	// engine restarts with a schema that includes it. Exposed
	// read-only at GET /admin/served-resources so the editor can honestly warn before
	// a new-resource deploy. Empty ⇒ the route reports an empty set.
	ServedResources []string

	// FlowHandlerFn, when non-nil, supplies the app's LIVE data-plane router for
	// the flow-test runner (FLOWTEST-S1) — flows execute against the real chain
	// (tenant → JWT → RBAC → generated routes), in-process. It is a function
	// because the router is (re)built at boot and on every hot-swap; nil while
	// the engine is still booting ⇒ the run endpoints answer 503.
	FlowHandlerFn func() http.Handler

	// ServedResourcesFn, when non-nil, supplies the LIVE served-resource list and
	// takes precedence over the static ServedResources slice. It exists for the
	// in-process fleet hot-swap (MT-STRUCT-S4): after a per-app hot-swap the served
	// surface changes WITHOUT a process restart, so the editor's post-deploy verify
	// must read the current surface's resources, not the boot list. Single-engine
	// leaves it nil (a deploy re-execs, so the boot slice is authoritative).
	ServedResourcesFn func() []string

	// ActivationFn, when non-nil, reports HOW a schema deploy activates on this
	// engine: "hot_swap" (in-process fleet, MT-STRUCT-S4/S5 — only this app is
	// recompiled and swapped, no downtime) or "restart" (single-engine graceful
	// re-exec, ~6 s). Surfaced in GET /admin/served-resources so the editor's
	// deploy UI describes the real activation instead of always saying "restart".
	// nil ⇒ "restart" (the historical behavior).
	ActivationFn func() string

	// PersistBootSchema validates a schema and ATOMICALLY persists it as the new
	// BOOT schema (the file the engine loads at start), backing up the previous
	// one for rollback (UI-F4-S2). An invalid schema must be reported wrapped in
	// ErrSchemaRejected (→ 422) with NOTHING written. nil ⇒ the engine cannot
	// self-restart (POST /admin/engine/schema answers 503).
	PersistBootSchema func(raw json.RawMessage) error
	// TriggerRestart initiates the engine's graceful self-restart (drain via
	// readyz→503 + http.Server.Shutdown, then relaunch with the persisted
	// schema). Called only AFTER PersistBootSchema succeeded — never with an
	// invalid schema.
	TriggerRestart func()
}

Config configures the platform admin Service.

type ObsHandler

type ObsHandler interface {
	ServeTenantData(w http.ResponseWriter, r *http.Request)
}

ObsHandler is the slice of the observability server the admin API needs: serve one tenant's observability JSON (already tenant-scoped). Declared as an interface so this package does not import pkg/observability (and to keep it mockable).

type PlatformAuthResult

type PlatformAuthResult struct {
	Admin       PublicAdmin `json:"admin,omitempty"`
	Token       string      `json:"token,omitempty"`
	MFARequired bool        `json:"mfa_required,omitempty"`
	MFAToken    string      `json:"mfa_token,omitempty"`
}

PlatformAuthResult is returned by Login. On a password-only success Token is set; when the admin has MFA, Token is withheld and MFARequired+MFAToken are set.

type PlatformClaims

type PlatformClaims struct {
	AdminID string `json:"aid"`
	Role    string `json:"prole"`
	Scope   string `json:"scope"`
	jwt.RegisteredClaims
}

PlatformClaims is the payload of a platform super-admin JWT. Its claim KEYS (aid/prole/scope) deliberately DIFFER from auth.Claims (user_id/role/tenant_id):

  • A platform token presented to a tenant /api/ route parses (same HS256 secret) but yields an empty auth.Claims (no user_id/role/tenant_id) → the engine RBAC denies it (deny by default). So a platform JWT can NEVER act as a tenant without the super-admin EXPLICITLY selecting a tenant through the admin API.
  • A tenant token presented to an admin route has no "scope" claim → it fails requirePlatformAdmin (403). The two token worlds cannot be confused.

It is signed with the engine's JWT_SECRET (one signing key, one validator family — HS256, exp required), exactly like every other token the engine issues.

type PublicAdmin

type PublicAdmin struct {
	ID        string    `json:"id"`
	Email     string    `json:"email"`
	Role      string    `json:"role"`
	CreatedAt time.Time `json:"created_at"`
}

PublicAdmin is the admin shape returned to clients — never the password hash.

type PublicTenantUser

type PublicTenantUser struct {
	ID            string    `json:"id"`
	Email         string    `json:"email"`
	Role          string    `json:"role"`
	EmailVerified bool      `json:"email_verified"`
	Suspended     bool      `json:"suspended"`
	CreatedAt     time.Time `json:"created_at"`
}

PublicTenantUser is a tenant user as seen by the admin API (no hash).

type ResourceField

type ResourceField struct {
	Name string `json:"name"`
	Type string `json:"type"`
}

ResourceField is one column descriptor (name + declared type) for the data UI.

type ResourceHandler added in v0.1.14

type ResourceHandler interface {
	ServeResources(w http.ResponseWriter, r *http.Request)
	ServeResourcesSnapshot(w http.ResponseWriter, r *http.Request)
}

ResourceHandler is the OPTIONAL second slice of the observability server: the engine's own resources + attribution verdict (CENTINELA-C-S1). Optional (a type assertion, not a widening of ObsHandler) so a test stub that only serves tenant data keeps compiling; an ObsHandler that implements it gets the /admin/resources routes registered.

type ResourceInfo

type ResourceInfo struct {
	Name   string          `json:"name"`
	Fields []ResourceField `json:"fields"`
}

ResourceInfo is a tenant resource the operator can browse.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service is the platform admin backend: super-admin auth (login + MFA) and the consolidated tenant / user / observability admin operations.

func NewService

func NewService(store *Store, users *userauth.Store, cp controlplane.Service, pool *pgxpool.Pool, cfg Config) *Service

NewService builds a platform admin Service. cp wraps the existing control plane (tenant create/lookup); users is the existing per-tenant identity store.

func (*Service) ActivateTenant

func (s *Service) ActivateTenant(ctx context.Context, id string) error

ActivateTenant clears the suspension flag.

func (*Service) ApplyTenantSchema

func (s *Service) ApplyTenantSchema(ctx context.Context, id string, sc *schema.APISchema, approved []string) (*migration.ApplyOutcome, error)

ApplyTenantSchema applies sc to tenant id, executing ONLY the destructive drops whose key is enumerated in approved (everything else stays gated as drift — the fail-safe default). Returns the outcome (applied/gated/unmatched). This is the SAME approved-apply path the control plane's PUT /tenants/{id}/schema uses.

func (*Service) Bootstrap added in v0.1.2

func (s *Service) Bootstrap(ctx context.Context, email, password string) (PlatformAuthResult, error)

Bootstrap creates the FIRST platform admin and signs them in (PHASE4-FIRST-MILE-S1). It exists because the first-run /admin experience was a dead end: a login form with no way to obtain credentials except knowing the CLI. The route that calls this is gated by the ADMIN_KEY (the operator's own boot credential — the same trust level as shell access, where `appximo admin create` already lives) and closes permanently once any admin exists.

func (*Service) Bootstrapped added in v0.1.2

func (s *Service) Bootstrapped(ctx context.Context) (bool, error)

Bootstrapped reports whether at least one platform admin exists. It backs the unauthenticated /admin/auth/status probe the login screen uses to decide between "sign in" and "create the first admin" — after bootstrap it is a constant true, so it discloses nothing an attacker can use.

func (*Service) ConfirmMFA

func (s *Service) ConfirmMFA(ctx context.Context, adminID, code string) ([]string, error)

ConfirmMFA validates the first code and activates MFA, returning backup codes.

func (*Service) CreateAdmin

func (s *Service) CreateAdmin(ctx context.Context, email, password, role string) (PublicAdmin, error)

CreateAdmin provisions a platform super-admin. It is the BOOTSTRAP path (no public signup): the first admin is created via the CLI (gated by the admin key / direct DB access), because a super-admin cannot authenticate with a super-admin that does not yet exist. role defaults to the configured super-admin role.

func (*Service) CreateTenant

CreateTenant wraps the EXISTING control plane (controlplane.Register) — it does not reimplement tenant provisioning. The schema is validated identically to the :9090 path before provisioning.

func (*Service) CreateUser

func (s *Service) CreateUser(ctx context.Context, tenantID, email, password, role string) (PublicTenantUser, error)

CreateUser creates a tenant user with an admin-chosen role (validated against the schema RBAC). Unlike public signup, the role is explicit.

func (*Service) DeleteTenant

func (s *Service) DeleteTenant(ctx context.Context, id, confirm string) error

DeleteTenant DESTRUCTIVELY removes a tenant: it requires an explicit confirmation equal to the tenant id (so a stray DELETE can never wipe a tenant), then drops the tenant's Postgres schema (CASCADE — all its data) and deletes its control- plane rows. There is no undo.

func (*Service) DeleteUser

func (s *Service) DeleteUser(ctx context.Context, tenantID, userID string) error

DeleteUser removes a tenant user.

func (*Service) DisableMFA

func (s *Service) DisableMFA(ctx context.Context, adminID, code, password string) error

DisableMFA turns MFA off, but only with a valid second factor (TOTP/backup) or the admin's password — never the session token alone.

func (*Service) EnableMFA

func (s *Service) EnableMFA(ctx context.Context, adminID string) (secret, uri string, err error)

EnableMFA begins enrollment for an authenticated admin: generates a TOTP secret, stores it encrypted (enabled=false), returns the secret + otpauth URI once.

func (*Service) GetTenant

func (s *Service) GetTenant(ctx context.Context, id string) (TenantDetail, error)

GetTenant returns one tenant's detail, including a live user count.

func (*Service) GetTenantSchema

func (s *Service) GetTenantSchema(ctx context.Context, id string) (*schema.APISchema, error)

GetTenantSchema returns a tenant's stored schema (for loading it back into the visual editor). nil when the tenant exists but has no schema set yet.

func (*Service) IsTenantActive

func (s *Service) IsTenantActive(ctx context.Context, id string) bool

IsTenantActive reports whether a tenant exists and is not suspended. It is the predicate wired into userauth login (TenantActive): a suspended tenant cannot mint new sessions. One PK lookup, on the login path only (never the CRUD hot path). A tenant with no public.tenants row is treated as active (login then fails on the missing user anyway) to avoid coupling the engine's naming-convention tenant model to a hard registry check.

func (*Service) ListData

func (s *Service) ListData(ctx context.Context, tenantID, resource string, params url.Values) (map[string]any, error)

ListData returns a page of a tenant resource's records (READ-ONLY browse). It REUSES the engine's validated, injection-safe query builder (filters/sort/keyset pagination from `params`) and runs it through the tenant-scoped DB — no new query logic, and the tenant_<id> search_path is the isolation boundary. Authorization is the platform super-admin (any tenant); a record's columns are whatever the resource declares. `meta.has_next` uses the keyset cursor convention (fetch per_page; the UI loads more via ?after=<last id>).

func (*Service) ListResources

func (s *Service) ListResources(ctx context.Context, tenantID string) ([]ResourceInfo, error)

ListResources returns the tenant's resources (from its STORED schema — the engine already knows them; this does not re-derive anything) with each field's declared type, so the data UI can render typed columns. The implicit `id` UUID PK is listed first.

func (*Service) ListRoles

func (s *Service) ListRoles(ctx context.Context, tenantID string) ([]string, error)

ListRoles returns the RBAC role names declared in the tenant's stored schema, so the user-management UI can offer a role picker (it only ASSIGNS existing roles — creating roles is the schema editor, out of scope). Sorted.

func (*Service) ListTenants

func (s *Service) ListTenants(ctx context.Context) ([]TenantInfo, error)

ListTenants returns every registered tenant (newest first) with cheap metadata. Row/user counts come from pg_stat_user_tables estimates (auth_ tables split out of data_rows), exactly like the `appximo tenant list` CLI inventory.

func (*Service) ListUsers

func (s *Service) ListUsers(ctx context.Context, tenantID string) ([]PublicTenantUser, error)

ListUsers returns a tenant's users.

func (*Service) Login

func (s *Service) Login(ctx context.Context, email, password string) (PlatformAuthResult, error)

Login verifies a platform admin's credentials and returns a platform token (or an MFA challenge). Uniform on unknown-email vs wrong-password (anti-enumeration, equalized timing). Throttled per email.

func (*Service) MFAVerify

func (s *Service) MFAVerify(ctx context.Context, mfaToken, code string) (PlatformAuthResult, error)

MFAVerify completes a login's second factor and mints the final platform token.

func (*Service) PreviewTenantSchema

func (s *Service) PreviewTenantSchema(ctx context.Context, id string, sc *schema.APISchema, approved []string) (*migration.Preview, error)

PreviewTenantSchema computes the DRY-RUN migration plan for applying sc to tenant id — the safe ops, the data-losing drops with their measured impact (rows lost), the drift and the concerns — evaluated against the given destructive-approval set. It applies NOTHING (the informed-consent surface the destructive gate needs).

func (*Service) Refresh

func (s *Service) Refresh(ctx context.Context, tokenStr string) (string, error)

Refresh re-mints a platform token from a still-valid one.

func (*Service) Register

func (s *Service) Register(r chi.Router, obs ObsHandler, adminKey string)

Register wires the admin API onto the data-plane router r. Routes live under /admin/ (already JWT-skipped and RBAC-passthrough — they do their OWN auth) and are registered individually so they coexist with the existing /admin/backup and /admin/tenants/{id}/reload machine endpoints (no Mount collision). adminKey lets machine callers (DevHub, scripts) use X-Admin-Key on the management routes — the "two paths for two consumers" rule: humans log in, machines present the key. The /admin/auth/* identity routes never accept the key (a human session needs an identity). obs may be nil (observability route then returns 503).

func (*Service) SetFileStore

func (s *Service) SetFileStore(store *files.Store, maxBytes int64, ttl time.Duration, secret []byte)

SetFileStore wires the engine's file store so the admin API can manage a tenant's files (the Studio files manager). Set by app.go after NewService — like SetTenantDB. When unset, the files routes answer 503 (the CLI/bootstrap path never needs them). secret signs the short-lived download tokens (the SAME engine JWT secret files.SignedURLHandler uses); ttl bounds both those tokens and S3 presigned URLs.

func (*Service) SetTenantDB

func (s *Service) SetTenantDB(tdb *db.TenantDB)

SetTenantDB wires the engine's tenant-scoped DB so the admin API can browse a tenant's DATA (read-only). It is set by app.go after NewService. When unset, the data-browsing endpoints return an error (the CLI/bootstrap path never needs it).

func (*Service) SetUserSuspended

func (s *Service) SetUserSuspended(ctx context.Context, tenantID, userID string, suspended bool) error

SetUserSuspended toggles a tenant user's administrative lockout.

func (*Service) SuspendTenant

func (s *Service) SuspendTenant(ctx context.Context, id string) error

SuspendTenant / ActivateTenant flip the control-plane suspended flag. Suspension blocks NEW logins for the tenant's users (enforced on the non-hot login path via the TenantActive predicate wired in app.go); already-issued JWTs remain valid until exp (the documented stateless-JWT trade-off). It deliberately adds NO per-request check to the CRUD/JWT hot path, preserving the measured p50.

func (*Service) UpdateUserRole

func (s *Service) UpdateUserRole(ctx context.Context, tenantID, userID, role string) error

UpdateUserRole changes a tenant user's role (validated against the schema RBAC).

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store persists platform admins (and their MFA enrollment) in the system schema, using the engine's shared pool. DDL is run once per process (lazy, behind a sync.Once-ish guard). It mirrors pkg/userauth's Store shape so the two identity surfaces look and behave the same.

func NewStore

func NewStore(pool *pgxpool.Pool) *Store

NewStore builds a Store over the engine pool.

func (*Store) ConfirmMFA

func (s *Store) ConfirmMFA(ctx context.Context, adminID string, codeHashes []string) error

ConfirmMFA flips enabled=true and stores the backup-code hashes atomically.

func (*Store) ConsumeBackupCode

func (s *Store) ConsumeBackupCode(ctx context.Context, adminID, codeHash string) (bool, error)

ConsumeBackupCode marks a matching unused backup code as used and returns true. false when no unused code matches the hash. Consumption is atomic (a code is one-time).

func (*Store) CountAdmins

func (s *Store) CountAdmins(ctx context.Context) (int, error)

CountAdmins returns the number of platform admins (used by bootstrap to refuse a public/duplicate first-admin path).

func (*Store) CreateAdmin

func (s *Store) CreateAdmin(ctx context.Context, email, passwordHash, role string) (Admin, error)

CreateAdmin inserts a platform admin. email is stored as given (the caller normalizes); a case-insensitive duplicate → ErrAdminEmailTaken. The returned Admin has no PasswordHash set.

func (*Store) DisableMFA

func (s *Store) DisableMFA(ctx context.Context, adminID string) error

DisableMFA clears an admin's secret and backup codes.

func (*Store) GetAdminByEmail

func (s *Store) GetAdminByEmail(ctx context.Context, email string) (Admin, error)

GetAdminByEmail looks an admin up by email (case-insensitive). ErrAdminNotFound when none matches.

func (*Store) GetAdminByID

func (s *Store) GetAdminByID(ctx context.Context, id string) (Admin, error)

GetAdminByID looks an admin up by id. ErrAdminNotFound when none matches.

func (*Store) GetMFA

func (s *Store) GetMFA(ctx context.Context, adminID string) (encSecret string, enabled, found bool, err error)

GetMFA returns the admin's encrypted secret and enabled flag. found=false when there is no enrollment row.

func (*Store) MFAEnabled

func (s *Store) MFAEnabled(ctx context.Context, adminID string) (bool, error)

MFAEnabled reports whether the admin has confirmed (active) MFA.

func (*Store) UpsertMFASecret

func (s *Store) UpsertMFASecret(ctx context.Context, adminID, encSecret string) error

UpsertMFASecret stores (or replaces) an admin's encrypted TOTP secret with enabled=false — enrollment begins but is not active until ConfirmMFA.

type TenantDetail

type TenantDetail struct {
	TenantInfo
}

TenantDetail is a single tenant; its UserCount is exact (one per-tenant query), unlike the list's estimate.

type TenantInfo

type TenantInfo struct {
	ID            string    `json:"id"`
	DisplayName   string    `json:"display_name"`
	Email         string    `json:"email"`
	Plan          string    `json:"plan"`
	Suspended     bool      `json:"suspended"`
	CreatedAt     time.Time `json:"created_at"`
	ResourceCount int       `json:"resource_count"`
	DataRows      int64     `json:"data_rows"`
	UserCount     int64     `json:"user_count"`
}

TenantInfo is the per-tenant summary returned by the tenant list. resource_count is derived from the stored schema (free); data_rows and user_count are pg_stat n_live_tup ESTIMATES (the same inventory `appximo tenant list` prints) — free at list time, never a per-tenant exact COUNT.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL