quota

package
v0.0.1 Latest Latest
Warning

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

Go to latest
Published: Sep 13, 2026 License: MIT Imports: 6 Imported by: 0

Documentation

Overview

Package quota answers the question pkg/authz does not.

RBAC decides whether an identity *may* act. Nothing decided how *much* it may consume, so on a shared hub one tenant could open unlimited projects, enrol unlimited executors, hold unlimited sessions, and — the expensive one — start unlimited concurrent runs against a token budget the whole organisation shares. pkg/globalbudget exists but is keyed by project, which is the wrong axis for a multi-tenant deployment: a user who can create projects can create budget headroom.

This package is the identity-keyed half. It is deliberately stdlib-only apart from pkg/authz (whose Subject and ClaimKind it reuses so that quota bindings read exactly like role bindings in the same config file). The SQLite adapter lives in pkg/quotastore, the same seam as oidcauth.SessionStore/sessionstore and secretbroker.Store/secretstore.

The two kinds of resource

Gauges (projects, concurrent tasks, executors, sessions) go up on admission and back down on release. They are the ones that can drift: a hub that crashes mid-run leaves a counter claiming a task is still running forever, which would slowly starve the tenant it was meant to protect. Enforcer therefore treats the persisted gauge as a cache and rebuilds it from live state at startup — see Enforcer.Reconcile.

Daily counters (tokens, cost) only ever go up, inside a UTC-day bucket. They are the opposite case: they must survive a restart exactly, because re-deriving "spend so far today" from anything other than the recorded increments would hand a compromised account a fresh budget on every crash.

Why the most restrictive binding wins a tie

Resolution is per-resource and most-specific-wins: a limit set directly on a subject beats one set on their email, which beats one set on a role, which beats one set on a group, which beats the deployment default. Within one tier — the case that actually happens, a user in several groups — the *smallest* limit wins.

That is the opposite of how role bindings resolve (strongest role wins) and the difference is deliberate. A role is a grant, so unioning grants is the safe reading of "member of both". A quota is a ceiling, so unioning ceilings would mean any tenant could lift their own cap by joining one more group — turning group membership into a privilege-escalation primitive. The minimum is also order-independent, which matters because the alternative (first match wins) makes a security property depend on YAML line order.

Index

Constants

This section is empty.

Variables

AllResources lists every resource in a stable order: config validation, the admin panel's column order, and the /metrics gauge order all read it.

Functions

func SubjectForIdentity

func SubjectForIdentity(id string) *authz.Subject

SubjectForIdentity reconstructs the claims an identity key encodes. It is exported for callers that hold an identity string but no live subject — an API token's minter, a project registry owner — and resolves email and sub bindings but not group ones, which the key does not carry.

Types

type Binding

type Binding struct {
	// Claim selects what Value is compared against: group, role, email, sub.
	Claim authz.ClaimKind

	// Value is the claim value to match, normalized like authz's.
	Value string

	// Limits are the ceilings this binding contributes.
	Limits Limits
}

Binding assigns limits to everyone matching one claim. It mirrors authz.Binding minus the scope fields: a quota is a property of an identity across the whole hub, not of an identity within one project. Scoping it per project would defeat the point, since the tenant chooses how many projects they have.

type Config

type Config struct {
	// Defaults apply to every identity that no binding covers. Leaving it
	// empty means "unlimited by default", which is the correct behaviour
	// for the single-tenant hub this feature must not disturb.
	Defaults Limits

	// Bindings assign narrower (or wider) limits per claim.
	Bindings []Binding
}

Config is the deployment's quota policy.

type CounterRow

type CounterRow struct {
	Identity string
	Resource Resource
	// Bucket is the UTC day ("2006-01-02") for daily resources and "" for
	// gauges. Keeping it in the key rather than resetting rows at midnight
	// means a day rollover needs no scheduled job and no clock the process
	// has to stay awake for: yesterday's row simply stops being read.
	Bucket    string
	Value     float64
	UpdatedAt time.Time
}

CounterRow is one persisted usage counter.

type Denial

type Denial struct {
	Identity  string
	Resource  Resource
	Limit     float64
	Used      float64
	Requested float64

	// RetryAfter is non-zero when waiting alone will clear the denial: a
	// running task will finish, a UTC day will roll over. It is zero when
	// the tenant or an admin must actually do something — delete a project,
	// revoke an executor, raise the cap — because a Retry-After that will
	// never come true trains clients to hammer a wall.
	RetryAfter time.Duration

	// Source is where the breached limit came from, for the error detail.
	Source string
}

Denial is the typed refusal returned by Admit. It carries everything the HTTP layer needs to build the response and everything an operator needs to understand the refusal without reading the server log.

func (*Denial) Error

func (d *Denial) Error() string

func (*Denial) Transient

func (d *Denial) Transient() bool

Transient reports whether waiting will clear this denial.

type Effective

type Effective struct {
	// Identity is the key limits are accounted against — authz.Subject's
	// Label(), the same string oidcauth.Identity.OwnerKey() produces, so a
	// project's Owner and a quota's identity are always the same value.
	Identity string

	// Limits are the ceilings in force.
	Limits Limits

	// Sources says where each ceiling came from ("override", "group:eng",
	// "default"). The admin panel shows it so "why is this user capped at
	// two projects?" is answerable without reading YAML.
	Sources map[Resource]string
}

Effective is the resolved ceiling set for one identity, with provenance.

func (Effective) Limit

func (e Effective) Limit(r Resource) (float64, bool)

Limit returns the ceiling for r and whether one applies.

type Enforcer

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

Enforcer holds the live counters and decides admissions. Safe for concurrent use; the zero value is not usable, call NewEnforcer.

func NewEnforcer

func NewEnforcer(resolver *Resolver, store Store, opts ...Option) *Enforcer

NewEnforcer builds an Enforcer over resolver and store. A nil store makes the enforcer purely in-memory, which is what `cloop ui` falls back to when the control-plane database cannot be opened: caps still hold for the life of the process rather than silently vanishing.

func (*Enforcer) Admit

func (e *Enforcer) Admit(subject *authz.Subject, res Resource, n float64) (Effective, error)

Admit reserves n units of res for subject, or returns a *Denial.

The reservation and the limit check happen in one critical section, which is the whole point: concurrent callers cannot both observe headroom that only one of them can have.

func (*Enforcer) CheckSpend

func (e *Enforcer) CheckSpend(subject *authz.Subject) error

CheckSpend reports whether identity has headroom left in its daily budgets without reserving anything. Used before dispatching a run: the exact cost is unknown up front, so the gate is "has this tenant already blown its budget?" rather than a reservation.

func (*Enforcer) ClearOverride

func (e *Enforcer) ClearOverride(identity string) (bool, error)

ClearOverride drops an identity's edit, returning it to policy.

func (*Enforcer) Denials

func (e *Enforcer) Denials() map[Resource]uint64

Denials returns the per-resource refusal counts for this process.

func (*Enforcer) Effective

func (e *Enforcer) Effective(subject *authz.Subject) Effective

Effective resolves subject's ceilings without touching any counter.

func (*Enforcer) Enabled

func (e *Enforcer) Enabled() bool

Enabled reports whether any policy is in force. When false every Admit succeeds, which is what keeps a single-tenant hub untouched.

func (*Enforcer) Load

func (e *Enforcer) Load() error

Load reads overrides and counters from the store. Stale day buckets are dropped on the way in and pruned from storage, so a long-lived hub does not accumulate a row per identity per day forever.

func (*Enforcer) Reconcile

func (e *Enforcer) Reconcile(live LiveState) error

Reconcile replaces every gauge counter from live state.

This runs at startup because the alternative — trusting the persisted counter — fails in the direction that hurts: a hub killed while three runs were in flight comes back believing that tenant is still using three slots, and keeps believing it forever. Nothing decrements a counter for a task whose process no longer exists. Rebuilding from what is actually running, enrolled and signed in makes a crash cost a moment of accuracy instead of a permanently narrowed tenant.

Daily counters are deliberately untouched: they are cumulative, they have no live equivalent to rebuild from, and re-deriving them would hand a compromised account a fresh token budget on every restart.

func (*Enforcer) Release

func (e *Enforcer) Release(identity string, res Resource, n float64)

Release returns n units of a gauge resource. Releasing more than is held clamps at zero rather than going negative: a double release (a run that reports completion twice) must not mint headroom.

func (*Enforcer) SessionAdmission

func (e *Enforcer) SessionAdmission(subject *authz.Subject, existing int) int

SessionAdmission computes how many of an identity's existing sessions must be evicted to make room for one more.

Sessions are the one resource enforced by eviction rather than refusal, and the reason is a lockout: refusing the login of a user who is already at their session cap leaves them with no session, and the self-service remedy (POST /api/session/logout-all) requires one. Evicting the least recently used session preserves the invariant the cap exists for — no identity ever holds more than N — without ever locking anyone out of their own account.

func (*Enforcer) SetOverride

func (e *Enforcer) SetOverride(identity string, limits Limits, actor string) error

SetOverride records an admin's per-identity edit.

func (*Enforcer) Snapshot

func (e *Enforcer) Snapshot(extra []string) []View

Snapshot returns one View per identity the enforcer knows about: everyone with an override, everyone holding a counter, everyone seen this process, plus any extra identities the caller supplies (the project registry knows about owners who have not made a request yet).

func (*Enforcer) Spend

func (e *Enforcer) Spend(identity string, tokens, usd float64)

Spend records consumption of a daily resource after the fact. Unlike Admit it never refuses: the tokens are already gone, and refusing to record them would be the one bug that lets a tenant exceed a budget indefinitely.

func (*Enforcer) Usage

func (e *Enforcer) Usage(identity string) Usage

Usage returns identity's live consumption across every resource.

func (*Enforcer) ViewFor

func (e *Enforcer) ViewFor(subject *authz.Subject) View

ViewFor returns one identity's limits and usage.

type Kind

type Kind int

Kind distinguishes the two accounting models described in the package doc.

const (
	// KindGauge rises on admission and falls on release.
	KindGauge Kind = iota

	// KindDaily accumulates within a UTC-day bucket and never falls.
	KindDaily
)

type Limits

type Limits map[Resource]float64

Limits is a sparse set of ceilings. A resource absent from the map is unlimited; a resource present with 0 means zero is allowed, which is a real and useful setting (a tenant who may run but may not enrol hardware).

Sparseness is what makes partial overrides work: an admin can raise one user's max_projects without restating — and accidentally freezing — every other ceiling that user inherits from their group.

func (Limits) Clone

func (l Limits) Clone() Limits

Clone returns a copy safe to hand to a caller that may mutate it.

func (Limits) Get

func (l Limits) Get(r Resource) (float64, bool)

Get returns the ceiling for r and whether one is set.

func (Limits) Normalize

func (l Limits) Normalize() (Limits, error)

Normalize returns a copy with unknown resources rejected and negative ceilings treated as "unlimited" rather than "deny everything".

The negative case is a footgun worth disarming explicitly: an operator who types -1 means "no limit" in almost every system that has ever had a quota, and reading it as a ceiling of -1 would deny every request from that tenant with no obvious cause.

func (Limits) Resources

func (l Limits) Resources() []Resource

Resources returns the resources l constrains, in AllResources order.

type LiveState

type LiveState struct {
	Projects  map[string]float64
	Tasks     map[string]float64
	Executors map[string]float64
	Sessions  map[string]float64
}

LiveState is ground truth for the gauge resources, gathered from the things that actually exist rather than from a counter that may have drifted.

Every map is identity → count. An identity absent from a map holds none of that resource; the whole gauge table is replaced, not merged, so a counter left behind by a crash mid-run is erased rather than decremented forever.

type Option

type Option func(*Enforcer)

Option configures an Enforcer.

func WithClock

func WithClock(now func() time.Time) Option

WithClock replaces the clock, for tests and for day-rollover assertions.

func WithStoreErrorHandler

func WithStoreErrorHandler(fn func(error)) Option

WithStoreErrorHandler installs a callback for persistence failures.

type Override

type Override struct {
	Identity  string
	Limits    Limits
	UpdatedAt time.Time
	UpdatedBy string
}

Override is one admin's per-identity edit.

type Resolver

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

Resolver turns a Config into per-identity limits. It is immutable after New and safe for concurrent use.

func New

func New(cfg Config) (*Resolver, error)

New validates cfg and returns a Resolver.

Validation is strict for the same reason authz.New's is: a typo in a resource name or a claim kind produces a binding that matches nothing, which presents as "the quota I configured is not being enforced" — the failure mode a quota system can least afford to have silently.

func (*Resolver) Configured

func (r *Resolver) Configured() bool

Configured reports whether any policy was set. A hub with no quota config enforces nothing, which keeps single-tenant local use exactly as it was.

func (*Resolver) Resolve

func (r *Resolver) Resolve(subject *authz.Subject, override Limits) Effective

Resolve computes the ceilings for subject, with override applied on top.

override is the admin's per-identity edit from the store. It is applied last and per-resource, so an admin can raise one ceiling for one person without inheriting responsibility for the rest of their limits.

func (*Resolver) ResolveAll

func (r *Resolver) ResolveAll(subjects []*authz.Subject, overrides map[string]Limits) []Effective

ResolveAllSubjects is a convenience for the admin panel: resolve a batch of identities in one pass, sorted by identity for stable rendering.

type Resource

type Resource string

Resource names one thing an identity can consume. The string values are the wire and YAML form and are part of the API contract: never rename one.

const (
	// ResProjects caps how many projects an identity may own at once.
	ResProjects Resource = "max_projects"

	// ResConcurrentTasks caps how many runs an identity may have executing
	// at once. This is the cap that actually protects the fleet: every
	// concurrent run holds an executor slot and spends tokens.
	ResConcurrentTasks Resource = "max_concurrent_tasks"

	// ResConcurrentReproductions caps how many hermetic reproductions
	// (Task 20221) an identity may have executing at once.
	//
	// Deliberately its own gauge rather than a share of ResConcurrentTasks.
	// A reproduction costs about what the run it reproduces cost — a full
	// sandbox, a full model call, and the project's test suite on two trees —
	// so an auditor sweeping a quarter of commits would drain the same pool the
	// tenant's real work draws from. Counting them apart means a reproduction
	// storm slows reproductions and nothing else, which is the non-destructive
	// contract extended from the repository to the fleet's capacity.
	ResConcurrentReproductions Resource = "max_concurrent_reproductions"

	// ResExecutors caps how many executors an identity may enrol. Enrolling
	// a device grants it the right to run workloads, so an unbounded tenant
	// can grow the trusted compute base without an admin ever looking.
	ResExecutors Resource = "max_executors"

	// ResSessions caps concurrent signed-in sessions per identity. Enforced
	// by evicting the oldest rather than by refusing the newest — see
	// SessionAdmission.
	ResSessions Resource = "max_sessions"

	// ResDailyTokens caps input+output tokens spent per UTC day.
	ResDailyTokens Resource = "daily_token_budget"

	// ResDailyCostUSD caps estimated USD spend per UTC day.
	ResDailyCostUSD Resource = "daily_cost_usd"
)

func (Resource) Integral

func (r Resource) Integral() bool

Integral reports whether r counts whole things. Used only for rendering: "3 projects" should not print as "3.0".

func (Resource) Kind

func (r Resource) Kind() Kind

Kind reports how r is accounted.

func (Resource) Valid

func (r Resource) Valid() bool

Valid reports whether r is a known resource.

type Store

type Store interface {
	// LoadOverrides returns every per-identity override.
	LoadOverrides() ([]Override, error)

	// PutOverride writes (or replaces) one identity's override. Empty
	// limits are stored as an explicit "no overrides" rather than deleting
	// the row, so the admin panel can distinguish "reset to policy" from
	// "never edited".
	PutOverride(o Override) error

	// DeleteOverride removes one identity's override, reporting whether a
	// row existed.
	DeleteOverride(identity string) (bool, error)

	// LoadCounters returns every persisted counter, including stale day
	// buckets; the caller decides which are still current.
	LoadCounters() ([]CounterRow, error)

	// PutCounter writes one counter's value.
	PutCounter(c CounterRow) error

	// ReplaceGauges atomically swaps the whole set of gauge counters
	// (bucket "") for the supplied rows. Used by Reconcile: a per-row
	// update could not delete the rows for identities that no longer hold
	// anything, which is exactly the drift reconciliation exists to remove.
	ReplaceGauges(rows []CounterRow) error

	// PruneCountersBefore deletes daily counters older than bucket.
	PruneCountersBefore(bucket string) error
}

Store persists overrides and counters. The SQLite implementation is pkg/quotastore; keeping it behind an interface is what lets this package stay free of a driver and lets tests run without a database.

type Usage

type Usage map[Resource]float64

Usage is one identity's live consumption.

type View

type View struct {
	Identity string              `json:"identity"`
	Limits   Limits              `json:"limits,omitempty"`
	Usage    Usage               `json:"usage"`
	Sources  map[Resource]string `json:"sources,omitempty"`

	// Overridden reports whether an admin has edited this identity, so the
	// panel can offer "reset to policy" only where there is something to
	// reset.
	Overridden bool `json:"overridden"`
}

View pairs an identity's ceilings with its consumption. It is what the admin panel renders and what /metrics exports.

Jump to

Keyboard shortcuts

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