Documentation
¶
Overview ¶
Package limes is an object-capability authorisation primitive designed for agentic workloads.
A Capability is a short-lived, signed token granting its bearer a specific set of operations on a specific set of resources, with explicit budget and lifetime caveats. It is delegable — a parent issues a strictly narrower child to a sub-agent — and individually revocable. Compared with long-lived API keys or role-based access it gives:
- Per-call attribution: every call carries the capability ID, so audit and cost roll up by it, down to which run of which agent spent what.
- Budget enforcement at the authorisation boundary: the Meter refuses a charge once a ceiling is exhausted, rather than business logic discovering it three calls deep.
- Mid-flight revocation: a compromised agent's capability is revoked, together with everything it delegated.
- Sub-capabilities without a round trip: an orchestrator narrows its own authority for each sub-agent it spawns, with no admin API in the path.
Wire format is JWT compact serialization with Ed25519 signatures (`alg: Ed25519`, RFC 9864); docs/standards.md lists every standard followed. Verification is local: the signature is checked against a resolved public key, so consumers scale horizontally without contending on a shared store.
What you implement ¶
Three contracts are required; a consumer that implements them needs nothing else:
- Store — capability records and revocations. IsRevoked answers for the capability's whole delegation chain. No transactions required; an in-memory map is a valid implementation.
- UsageStore — request and spend counters and the charges ledger; the union of Meter, TenantBudgets and UsageHousekeeping. Every charge and request also counts against each ancestor. Generic in TX, the consumer's transaction handle; see "No-transaction mode" below.
- KeyResolver — public verification keys. StaticKeyResolver ships as a working implementation and supports rotation via SetKey / RemoveKey; RemoteJWKSResolver fetches an issuer's JWKS for verifiers that run apart from it.
Three more are needed only with the feature they serve: BiscuitRevocationStore to accept Biscuit tokens, CopyUsageReader to show a Biscuit copy's own counters, and ReplayCache to share DPoP replay state across replicas (MemoryReplayCache ships for one process).
A Capability crosses the API by value, in and out: no caller can alter one another holds, and one that is not there is an error, never nil.
Every amount is Nanos, an int64 count of billionths of the unit (UnitCode, an ISO 4217 code; XXX for a budget that is not money), so spend sums, compares and refunds exactly, with no rounding left to the consumer.
Every read names what it reads — Store.Get a capability, Meter.GetUsage the counters, TenantBudgets.GetTenantBudget a ceiling — so one type may implement Store and UsageStore together, or each apart as memstore does.
No-transaction mode ¶
Meter.Charge and Meter.Settle take an onCharged callback that runs inside the charge, receiving the consumer's transaction handle, so a side effect such as an outbox write commits atomically with the spend. The module never constructs, inspects or constrains that handle — it only threads it back. That is what keeps this package free of any database dependency.
A consumer with no transactional storage instantiates UsageStore[struct{}] and always passes nil for onCharged. Everything else behaves identically; only the atomic-side-effect guarantee is forgone, and that limitation is store-dependent rather than a degradation of the primitive.
Guarantees ¶
Rejections are matchable sentinels, so a consumer can map each to its own transport status, or let StandardVerifier.ErrorResponse map it to HTTP. A charge rejected by any ceiling — the capability's, an ancestor's, the tenant's — leaves every counter and the ledger unmutated, so retrying after a rejection is safe. Delegation can only narrow: any widening returns ErrDelegationTooWide, and a differing unit code returns ErrUnitCodeMismatch rather than converting.
Enforcing caveats ¶
Caveats.Check and Caveats.CheckSource are the single definition of what the caveats mean at use time; delegation narrowing (Narrows) uses the same resource matcher (MatchResource), so the two cannot disagree.
See README.md for a five-minute walkthrough and example/ for it as a runnable program.
Example (Charge) ¶
package main
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/memstore"
)
// The issuer's name, and the audience every example's tokens are minted for.
const (
exampleIssuerName = "example-issuer"
exampleAudience = "my-service"
)
// exampleStack is an issuer and a verifier over one in-memory store — the
// setup ExampleIssuer_Issue shows in full.
type exampleStack struct {
issuer *limes.Issuer
verifier *limes.StandardVerifier
records *memstore.Store[struct{}]
usage *memstore.UsageStore[struct{}]
tenantID uuid.UUID
}
func newExampleStack() exampleStack {
kid, pub, priv, err := limes.GenerateEd25519Keypair()
if err != nil {
log.Fatal(err)
}
records := memstore.New[struct{}]()
signer, err := limes.NewEd25519Signer(kid, priv)
if err != nil {
log.Fatal(err)
}
issuer, err := limes.NewIssuer(limes.IssuerConfig{Signer: signer, Store: records, IssuerName: exampleIssuerName})
if err != nil {
log.Fatal(err)
}
verifier, err := limes.NewStandardVerifier(limes.VerifierConfig{
Keys: limes.NewStaticKeyResolver(map[string]ed25519.PublicKey{kid: pub}),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
AcceptBiscuit: true,
BiscuitRevocations: records,
DPoP: &limes.DPoPVerifier{Replay: limes.NewMemoryReplayCache(0)},
})
if err != nil {
log.Fatal(err)
}
return exampleStack{
issuer: issuer, verifier: verifier, records: records,
usage: memstore.NewUsage[struct{}](records), tenantID: uuid.New(),
}
}
// issue mints a capability for an agent: get and share under corpus/public/,
// 25.00 USD and 500 requests, for 15 minutes.
func (s exampleStack) issue() (limes.Capability, string) {
c, token, err := s.issuer.Issue(context.Background(), limes.IssueRequest{
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalAgent, TenantID: s.tenantID, Subject: "research-orchestrator",
},
Audience: []string{exampleAudience},
TTL: 15 * time.Minute,
Caveats: limes.Caveats{
Ops: []limes.Op{limes.OpGet, limes.OpShare},
ResourcePrefixes: []string{"corpus/public/"},
MaxRequests: 500,
MaxBudgetAmount: limes.MustParseAmount("25.00"),
UnitCode: "USD",
},
})
if err != nil {
log.Fatal(err)
}
return c, token
}
func main() {
ctx := context.Background()
s := newExampleStack()
c, _ := s.issue()
charge := func(amount string) (limes.ChargeReceipt, error) {
return s.usage.Charge(ctx, limes.ChargeRequest{
CapabilityID: c.ID, TenantID: s.tenantID, Amount: limes.MustParseAmount(amount),
MaxBudget: c.Caveats.MaxBudgetAmount, UnitCode: "USD", Op: "search",
}, nil)
}
receipt, err := charge("0.35")
fmt.Println(err, receipt.Spent)
// Over the ceiling: refused, and no counter moves.
_, err = charge("999")
fmt.Println(errors.Is(err, limes.ErrBudgetExceeded))
refund, err := s.usage.Refund(ctx, limes.RefundRequest{ChargeID: receipt.ChargeID})
fmt.Println(err, refund.Refunded)
}
Output: <nil> 0.35 true <nil> 0.35
Example (Revoke) ¶
package main
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/memstore"
)
// The issuer's name, and the audience every example's tokens are minted for.
const (
exampleIssuerName = "example-issuer"
exampleAudience = "my-service"
)
// exampleStack is an issuer and a verifier over one in-memory store — the
// setup ExampleIssuer_Issue shows in full.
type exampleStack struct {
issuer *limes.Issuer
verifier *limes.StandardVerifier
records *memstore.Store[struct{}]
usage *memstore.UsageStore[struct{}]
tenantID uuid.UUID
}
func newExampleStack() exampleStack {
kid, pub, priv, err := limes.GenerateEd25519Keypair()
if err != nil {
log.Fatal(err)
}
records := memstore.New[struct{}]()
signer, err := limes.NewEd25519Signer(kid, priv)
if err != nil {
log.Fatal(err)
}
issuer, err := limes.NewIssuer(limes.IssuerConfig{Signer: signer, Store: records, IssuerName: exampleIssuerName})
if err != nil {
log.Fatal(err)
}
verifier, err := limes.NewStandardVerifier(limes.VerifierConfig{
Keys: limes.NewStaticKeyResolver(map[string]ed25519.PublicKey{kid: pub}),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
AcceptBiscuit: true,
BiscuitRevocations: records,
DPoP: &limes.DPoPVerifier{Replay: limes.NewMemoryReplayCache(0)},
})
if err != nil {
log.Fatal(err)
}
return exampleStack{
issuer: issuer, verifier: verifier, records: records,
usage: memstore.NewUsage[struct{}](records), tenantID: uuid.New(),
}
}
// issue mints a capability for an agent: get and share under corpus/public/,
// 25.00 USD and 500 requests, for 15 minutes.
func (s exampleStack) issue() (limes.Capability, string) {
c, token, err := s.issuer.Issue(context.Background(), limes.IssueRequest{
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalAgent, TenantID: s.tenantID, Subject: "research-orchestrator",
},
Audience: []string{exampleAudience},
TTL: 15 * time.Minute,
Caveats: limes.Caveats{
Ops: []limes.Op{limes.OpGet, limes.OpShare},
ResourcePrefixes: []string{"corpus/public/"},
MaxRequests: 500,
MaxBudgetAmount: limes.MustParseAmount("25.00"),
UnitCode: "USD",
},
})
if err != nil {
log.Fatal(err)
}
return c, token
}
func main() {
ctx := context.Background()
s := newExampleStack()
c, token := s.issue()
err := s.records.Revoke(ctx, limes.RevokeRequest{
ID: c.ID, Reason: "agent looping", Actor: "operator@example.com", CascadeChildren: true,
})
fmt.Println(err)
_, err = s.verifier.Verify(ctx, limes.VerifyRequest{Token: token, Audience: exampleAudience})
fmt.Println(errors.Is(err, limes.ErrRevoked))
}
Output: <nil> true
Index ¶
- Constants
- Variables
- func AccessTokenHash(token string) string
- func AllByPrincipal(ctx context.Context, l CapabilityLister, req ListByPrincipalRequest) iter.Seq2[Capability, error]
- func AllTenantBudgets(ctx context.Context, l TenantBudgetLister, req ListTenantBudgetsRequest) iter.Seq2[TenantBudgetSummary, error]
- func Attenuate(token string, a Attenuation) (string, error)
- func DPoPSigningAlgorithms() []string
- func DeriveKeyID(pub ed25519.PublicKey) string
- func GenerateEd25519Keypair() (kid string, pub ed25519.PublicKey, priv ed25519.PrivateKey, err error)
- func HasDotSegment(s string) bool
- func IsBiscuit(token string) bool
- func IsUnitCode(u string) bool
- func JWKSHandler(keys func() map[string]ed25519.PublicKey, maxAge time.Duration) http.Handler
- func KeyThumbprint(pub crypto.PublicKey) (string, error)
- func LoadPrivateKey(path string) (ed25519.PrivateKey, error)
- func MarshalJWKS(keys map[string]ed25519.PublicKey) ([]byte, error)
- func MatchResource(prefix, uri string) bool
- func Narrows(parent, child Capability) error
- func NewDPoPProof(key crypto.Signer, req DPoPProofRequest) (string, error)
- func NormaliseUnitCode(u string) (string, error)
- func PageLimit(requested int32) int32
- func ParseJWKS(raw []byte) (map[string]ed25519.PublicKey, error)
- func ValidateAmount(amount Nanos) error
- func ValidateExternalRef(ref string) error
- func VerifySignature(token string, pub ed25519.PublicKey) error
- func WriteErrorResponse(w http.ResponseWriter, resp ErrorResponse)
- type AgentPrincipal
- type Attenuation
- type AuthScheme
- type AuthorizeRequest
- type BiscuitCopy
- type BiscuitRevocation
- type BiscuitRevocationLookup
- type BiscuitRevocationStore
- type BumpRequest
- type CacheOption
- type CachedBiscuitRevocationChecker
- type CachedRevocationChecker
- type Capability
- type CapabilityLister
- type Caveats
- type ChargeReceipt
- type ChargeRecord
- type ChargeRequest
- type CheckRequest
- type CopyCeiling
- type CopyUsage
- type CopyUsageReader
- type DPoPJWK
- type DPoPNonces
- type DPoPProof
- type DPoPProofRequest
- type DPoPVerifier
- type DelegateRequest
- type Effect
- type ErrorResponse
- type HMACNonces
- type IssueRequest
- type Issuer
- type IssuerConfig
- type JWK
- type JWKSDocument
- type KeyResolver
- type ListByPrincipalRequest
- type ListTenantBudgetsRequest
- type MemoryReplayCache
- type Meter
- type MeteringOption
- type Nanos
- type Op
- type OverrunPolicy
- type Principal
- type PrincipalType
- type Problem
- type Reason
- type Record
- type RefundReceipt
- type RefundRequest
- type RemoteJWKSConfig
- type RemoteJWKSResolver
- type ReplayCache
- type ReplayCacheOption
- type Reservation
- type ReserveRequest
- type Revocation
- type RevocationLookup
- type RevokeBiscuitRequest
- type RevokeRequest
- type SetTenantBudgetRequest
- type SettleRequest
- type Signer
- type SigningKey
- type StandardVerifier
- func (v *StandardVerifier) Authorize(ctx context.Context, req AuthorizeRequest) (Capability, error)
- func (v *StandardVerifier) BiscuitCopy(ctx context.Context, token string) (BiscuitCopy, error)
- func (v *StandardVerifier) DPoPNonce(ctx context.Context) (string, error)
- func (v *StandardVerifier) ErrorResponse(ctx context.Context, err error, scheme AuthScheme) ErrorResponse
- func (v *StandardVerifier) Verify(ctx context.Context, req VerifyRequest) (Capability, error)
- type StaticKeyResolver
- type Store
- type TenantBudget
- type TenantBudgetCursor
- type TenantBudgetLister
- type TenantBudgetSummary
- type TenantBudgets
- type Usage
- type UsageHousekeeping
- type UsageStore
- type Verifier
- type VerifierConfig
- type VerifyRequest
Examples ¶
Constants ¶
const ( // HMACNonceKeyBytes is the least key length NewHMACNonces accepts: the // output size of the HMAC's hash. HMACNonceKeyBytes = sha256.Size // DefaultDPoPNonceLifetime is how long a nonce is accepted when // NewHMACNonces is given no lifetime. DefaultDPoPNonceLifetime = 5 * time.Minute // HMACNonceClockSkew is how far ahead of a replica's clock a nonce's // issuance may lie: another replica's clock may run ahead, and the // issuance is kept to the second. HMACNonceClockSkew = 5 * time.Second )
HMACNonce sizes and defaults.
const ( DefaultListLimit = 50 MaxListLimit = 500 )
Page sizes of ListByPrincipal: Limit 0 or below means DefaultListLimit, and anything above MaxListLimit is clamped to it.
const AbstractUnitCode = "XXX"
AbstractUnitCode is the unit of a budget that is not money — requests, credits, anything metered: ISO 4217's XXX, "no currency involved".
const AuthorizationHeader = "Authorization"
AuthorizationHeader is the request header a token travels in.
const DPoPHeader = "DPoP"
DPoPHeader is the HTTP header a proof travels in.
const DPoPNonceHeader = "DPoP-Nonce"
DPoPNonceHeader is the HTTP response header a nonce travels in (RFC 9449 §8).
const DPoPTokenType = "dpop+jwt"
DPoPTokenType is the JWT "typ" of a proof.
const DefaultDPoPWindow = time.Minute
DefaultDPoPWindow is how far a proof's iat may be from the verifier's clock.
const DefaultMaxTokenBytes = 16 << 10
DefaultMaxTokenBytes bounds the token a verifier will look at. A capability with a few dozen resource entries is a couple of kilobytes; anything far larger is refused before any decoding is spent on it.
const DefaultReplayEntriesPerKey = 1_000
DefaultReplayEntriesPerKey is how many live proof ids one key may hold in a MemoryReplayCache: a thousand requests a window from one key.
const DefaultReservationTTL = 5 * time.Minute
DefaultReservationTTL is how long a reservation lasts when the request does not say.
const DefaultRevocationCacheEntries = 100_000
DefaultRevocationCacheEntries bounds the cache. Past it, the oldest entry makes room for the new one: a flood of distinct ids costs extra lookups, never unbounded memory, and never the answers cached for everyone else.
const DefaultRevocationCacheTTL = 2 * time.Second
DefaultRevocationCacheTTL bounds how long a cached answer is served. A revocation therefore takes effect within this window on every verifier that did not perform it.
const DefaultUnitCode = "USD"
DefaultUnitCode is the unit of a request or stored record that names none, so one written before it carried a unit keeps its meaning.
const MaxExternalRefBytes = 200
MaxExternalRefBytes bounds ChargeRequest.ExternalRef: long enough for any UUID, ULID or provider call id, short enough to index.
const MaxIJSONInteger = 1<<53 - 1
MaxIJSONInteger is the largest integer a token carries: 2^53 − 1, the largest an IEEE 754 double holds exactly. RFC 7493 (I-JSON) §2.2 keeps numbers within it so that every JSON reader — JavaScript's among them — reads the same value; larger amounts travel as decimal strings (Nanos).
const MediaTypeProblem = "application/problem+json"
MediaTypeProblem is RFC 9457 §3's media type for problem details.
const NanosPerUnit = 1_000_000_000
NanosPerUnit is how many nanos make one unit.
const TokenType = "limes-cap-v3+jwt" //nolint:gosec // G101: a JWT type label, not a credential
TokenType is the JWT "typ" header every capability token carries. The verifier requires it, so a different kind of EdDSA JWT signed by the same key — an ID token, another product's token — cannot be presented as a capability. It names the format's version: a token of another version is refused here, before a claim of it is read under the wrong schema.
const WWWAuthenticateHeader = "WWW-Authenticate"
WWWAuthenticateHeader is the response header a challenge travels in.
Variables ¶
var ( ErrOpNotAllowed error = &caveatError{"limes: operation not allowed"} ErrResourceNotAllowed error = &caveatError{"limes: resource not allowed"} ErrSourceIPNotAllowed error = &caveatError{"limes: source IP not allowed"} ErrIdempotencyKeyRequired error = &caveatError{"limes: idempotency key required"} ErrTaintedReadNotAllowed error = &caveatError{"limes: tainted read not allowed"} )
Request-time caveat rejections. Each also matches ErrCaveatViolation.
var ( // ErrDPoPRequired — the capability is key-bound and no proof came with it. ErrDPoPRequired error = &popError{"limes: DPoP proof required"} // ErrDPoPInvalid — a proof came, and it does not prove possession for // this token, method, URL and moment. ErrDPoPInvalid error = &popError{"limes: invalid DPoP proof"} // ErrDPoPReplayed — a proof's jti was already used inside the window. ErrDPoPReplayed error = &popError{"limes: DPoP proof replayed"} )
Proof-of-possession rejections. Each also matches ErrInvalidSignature, so a consumer that maps only that sentinel still refuses them.
var ( ErrInvalidSignature = errors.New("limes: invalid signature") ErrExpired = errors.New("limes: expired") ErrNotYetValid = errors.New("limes: not yet valid") ErrRevoked = errors.New("limes: revoked") ErrCaveatViolation = errors.New("limes: caveat violation") ErrAudienceMismatch = errors.New("limes: audience mismatch") ErrBudgetExceeded = errors.New("limes: budget exceeded") ErrDelegationTooWide = errors.New("limes: child wider than parent") // ErrUnitCodeMismatch — two units that would have to be one: a // delegated child declaring a unit_code other than its parent's, or an // amount in a unit other than the one a counter (a capability's, an // ancestor's, a tenant's) already counts in. Nothing converts between // currencies, so cross-currency delegation is refused at issuance and a // cross-currency charge at the Meter. ErrUnitCodeMismatch = errors.New("limes: unit_code mismatch") // ErrInvalidRequest is an Issue or Delegate request that is malformed in // itself: a missing tenant, an empty audience entry, a negative TTL, a // thumbprint that is not one, a not-before past the expiry, caveats that // do not validate. Nothing was minted. Every such error matches it, so a // caller maps the whole family to its "bad request" without listing it. ErrInvalidRequest = errors.New("limes: invalid request") // ErrUnknownTenant is an issuance for a tenant that does not exist — // typically one whose provisioning has not finished. The Store returns it. ErrUnknownTenant = errors.New("limes: unknown tenant") // ErrTenantDeleted is an issuance for a tenant in the trash: it exists, // so its rows may be restored, but nothing new is minted for it. The // Store returns it. ErrTenantDeleted = errors.New("limes: tenant deleted") )
Errors exposed to callers, which switch on them with errors.Is. A consumer maps them to its transport, or lets StandardVerifier.ErrorResponse map them to HTTP.
var ( // ErrInvalidAmount — an amount that is negative or above MaxNanos, a // decimal that is not one (ParseAmount), or a charge that would take a // counter past MaxNanos. Refunds are explicit; a negative charge is not // one. ErrInvalidAmount = errors.New("limes: invalid amount") // ErrChargeNotFound — Refund named a charge with no ledger row. ErrChargeNotFound = errors.New("limes: charge not found") // ErrRefundExceedsCharge — Refund would return more than the charge // took, counting earlier refunds of the same charge. ErrRefundExceedsCharge = errors.New("limes: refund exceeds charge") // ErrReservationNotFound — Settle named a reservation that does not // exist, was released, or has expired. One already settled is not an // error: Settle returns its charge again (ChargeReceipt.Replayed). ErrReservationNotFound = errors.New("limes: reservation not found") // ErrRequestLimitExceeded — capability used MaxRequests times, // next attempt was blocked. ErrRequestLimitExceeded = errors.New("limes: request limit exceeded") // ErrUsageNotFound — no usage row exists for this capability. // Get-only; bump/charge always inserts on first hit. ErrUsageNotFound = errors.New("limes: usage row not found") // ErrTenantBudgetExceeded — the tenant aggregate cap has been // hit. Distinct from ErrBudgetExceeded because the remedy differs: // an operator raises a tenant's ceiling with SetTenantBudget, while a // capability's ceiling is in its token and lifting it means issuing a // new one. ErrTenantBudgetExceeded = errors.New("limes: tenant aggregate budget exceeded") // ErrTenantBudgetNotFound — Get-only; the tenant has no cap // configured AND has never been charged. Distinct from "cap = 0" // (which means "configured but unlimited"). ErrTenantBudgetNotFound = errors.New("limes: tenant budget row not found") // ErrTenantBudgetVersionMismatch — SetTenantBudget's OCC guard refused the // write: the stored resource_version is not the one the caller read, so // someone else changed the cap in between. The caller re-reads and decides, // rather than silently overwriting a change it never saw. ErrTenantBudgetVersionMismatch = errors.New("limes: tenant budget resource_version mismatch") )
Usage-related sentinels. ErrBudgetExceeded already lives in types.go (declared before the runtime enforcement landed); reused here.
var ErrAlreadyExists = errors.New("limes: already exists")
ErrAlreadyExists is Store.Insert given an id already on record.
var ErrAuthSchemeMismatch error = &popError{"limes: token presented under the wrong scheme"}
ErrAuthSchemeMismatch — the scheme the token came under is not the one its binding calls for: a key-bound token as Bearer, which RFC 9449 §7.2 treats as a downgrade, or an unbound one as DPoP. Matches ErrInvalidSignature.
var ErrBiscuitAttenuation error = &popError{"limes: invalid Biscuit attenuation"}
ErrBiscuitAttenuation — an attenuation block the verifier cannot honour: outside the vocabulary, malformed, or widening its parent. Matches ErrInvalidSignature, so consumers that map only that sentinel refuse it.
var ErrCopyCountersNotMetered = fmt.Errorf("%w: copy limits need a verifier with MeterCopies", ErrBiscuitAttenuation)
ErrCopyCountersNotMetered — a Biscuit sets a copy's own request or budget limit, and the verifier was not told that its Meter counts copies. The limit would go unenforced, so the token is refused instead.
var ErrCursorRepeated = errors.New("limes: a page returned the cursor it was fetched with")
ErrCursorRepeated is a page that hands back the cursor it was fetched with. Following it would fetch the same page forever, so the walk stops there.
var ErrDPoPNonceRequired error = &popError{"limes: DPoP nonce required"}
ErrDPoPNonceRequired — the verifier requires a server-provided nonce and the proof carried none, or one it did not issue or that has expired. The response carries a fresh one (StandardVerifier.DPoPNonce). Matches ErrInvalidSignature, as every proof-of-possession refusal does.
var ErrEffectConflict error = &requestError{"limes: declared effect conflicts with the operation"}
ErrEffectConflict is a declared effect that contradicts a built-in operation's own ("put" declared a read), or one this package does not define. A declaration may name a built-in operation's effect, never change it: a write declared a read would shed IdempotencyKeyRequired. It is a programming error in the consumer, so it matches ErrInvalidRequest.
var ErrExternalRefConflict error = &requestError{"limes: external ref already names a different charge"}
ErrExternalRefConflict — Charge named an ExternalRef its capability already charged under, for a different amount, unit or tenant. One reference names one cost; replaying the first receipt would report the second as booked. Matches ErrInvalidRequest.
var ErrInvalidCaveats error = &requestError{"limes: invalid caveats"}
ErrInvalidCaveats is returned when a caveat bag is malformed: a budget outside 0..MaxNanos, a request limit outside 0..MaxIJSONInteger, an unknown unit, an empty resource entry, an unparsable CIDR, an operation name that is not well formed. It is a request error, not an authorisation decision — the capability was never minted — so it also matches ErrInvalidRequest.
ErrJWKSUnavailable is returned by RemoteJWKSResolver when it holds no key set recent enough to trust: the endpoint has been failing for longer than MaxStale, or never answered. The verifier turns it into ErrInvalidSignature — an issuer we cannot reach verifies nothing.
var ErrMalformedAuthorization = errors.New("limes: malformed Authorization header")
ErrMalformedAuthorization — an Authorization header that is not one scheme this module reads followed by one token68 credential. It is a malformed request (RFC 6750 §3.1 invalid_request), not a bad token.
var ErrNotFound = errors.New("limes: not found")
ErrNotFound is the typed not-found return from Store.Get. It lives in the core package rather than in a store implementation so that any consumer — in-memory, relational, or otherwise — can satisfy the Store contract using only what this package publishes.
The verifier never returns it: an id not on record is simply not revoked.
var ErrUnknownKID = errors.New("limes: unknown kid")
ErrUnknownKID is the typed not-found return from KeyResolver. The verifier converts it to ErrInvalidSignature for caller-facing errors; keeping the typed return lets ops dashboards distinguish "we don't know this kid" from "signature mismatch".
Functions ¶
func AccessTokenHash ¶
AccessTokenHash is the proof's `ath`: base64url(SHA-256(token)).
func AllByPrincipal ¶ added in v0.2.0
func AllByPrincipal(ctx context.Context, l CapabilityLister, req ListByPrincipalRequest) iter.Seq2[Capability, error]
AllByPrincipal walks ListByPrincipal page by page, from req.Cursor, and yields every capability in its order. req.Limit sets the page size, not how many are yielded.
An error is yielded once, with a zero Capability, and ends the walk; breaking out of the loop fetches no further page. The cursor is the contract for paging across requests — an API handing a page token to its client — and this is for a caller that wants the whole list in one go.
Example ¶
package main
import (
"context"
"crypto/ed25519"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/memstore"
)
// The issuer's name, and the audience every example's tokens are minted for.
const (
exampleIssuerName = "example-issuer"
exampleAudience = "my-service"
)
// exampleStack is an issuer and a verifier over one in-memory store — the
// setup ExampleIssuer_Issue shows in full.
type exampleStack struct {
issuer *limes.Issuer
verifier *limes.StandardVerifier
records *memstore.Store[struct{}]
usage *memstore.UsageStore[struct{}]
tenantID uuid.UUID
}
func newExampleStack() exampleStack {
kid, pub, priv, err := limes.GenerateEd25519Keypair()
if err != nil {
log.Fatal(err)
}
records := memstore.New[struct{}]()
signer, err := limes.NewEd25519Signer(kid, priv)
if err != nil {
log.Fatal(err)
}
issuer, err := limes.NewIssuer(limes.IssuerConfig{Signer: signer, Store: records, IssuerName: exampleIssuerName})
if err != nil {
log.Fatal(err)
}
verifier, err := limes.NewStandardVerifier(limes.VerifierConfig{
Keys: limes.NewStaticKeyResolver(map[string]ed25519.PublicKey{kid: pub}),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
AcceptBiscuit: true,
BiscuitRevocations: records,
DPoP: &limes.DPoPVerifier{Replay: limes.NewMemoryReplayCache(0)},
})
if err != nil {
log.Fatal(err)
}
return exampleStack{
issuer: issuer, verifier: verifier, records: records,
usage: memstore.NewUsage[struct{}](records), tenantID: uuid.New(),
}
}
// issue mints a capability for an agent: get and share under corpus/public/,
// 25.00 USD and 500 requests, for 15 minutes.
func (s exampleStack) issue() (limes.Capability, string) {
c, token, err := s.issuer.Issue(context.Background(), limes.IssueRequest{
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalAgent, TenantID: s.tenantID, Subject: "research-orchestrator",
},
Audience: []string{exampleAudience},
TTL: 15 * time.Minute,
Caveats: limes.Caveats{
Ops: []limes.Op{limes.OpGet, limes.OpShare},
ResourcePrefixes: []string{"corpus/public/"},
MaxRequests: 500,
MaxBudgetAmount: limes.MustParseAmount("25.00"),
UnitCode: "USD",
},
})
if err != nil {
log.Fatal(err)
}
return c, token
}
func main() {
ctx := context.Background()
s := newExampleStack()
for range 3 {
s.issue()
}
// One page per capability, to show the walk crossing pages.
const pageSize = 1
n := 0
for _, err := range limes.AllByPrincipal(ctx, s.records, limes.ListByPrincipalRequest{
TenantID: s.tenantID, PrincipalType: limes.PrincipalAgent, Subject: "research-orchestrator",
Limit: pageSize,
}) {
if err != nil {
log.Fatal(err)
}
n++
}
fmt.Println(n)
}
Output: 3
func AllTenantBudgets ¶ added in v0.2.0
func AllTenantBudgets(ctx context.Context, l TenantBudgetLister, req ListTenantBudgetsRequest) iter.Seq2[TenantBudgetSummary, error]
AllTenantBudgets walks ListTenantBudgets page by page, from req.Cursor, and yields every row in its order, as AllByPrincipal does. Spend moves between pages, so a tenant may be yielded twice or not at all; see TenantBudgets.ListTenantBudgets.
func Attenuate ¶
func Attenuate(token string, a Attenuation) (string, error)
Attenuate appends a block that narrows token. It needs no key and no network; the result is a new token, and the input still works as before.
Example ¶
package main
import (
"context"
"crypto/ed25519"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/memstore"
)
// The issuer's name, and the audience every example's tokens are minted for.
const (
exampleIssuerName = "example-issuer"
exampleAudience = "my-service"
)
// exampleStack is an issuer and a verifier over one in-memory store — the
// setup ExampleIssuer_Issue shows in full.
type exampleStack struct {
issuer *limes.Issuer
verifier *limes.StandardVerifier
records *memstore.Store[struct{}]
usage *memstore.UsageStore[struct{}]
tenantID uuid.UUID
}
func newExampleStack() exampleStack {
kid, pub, priv, err := limes.GenerateEd25519Keypair()
if err != nil {
log.Fatal(err)
}
records := memstore.New[struct{}]()
signer, err := limes.NewEd25519Signer(kid, priv)
if err != nil {
log.Fatal(err)
}
issuer, err := limes.NewIssuer(limes.IssuerConfig{Signer: signer, Store: records, IssuerName: exampleIssuerName})
if err != nil {
log.Fatal(err)
}
verifier, err := limes.NewStandardVerifier(limes.VerifierConfig{
Keys: limes.NewStaticKeyResolver(map[string]ed25519.PublicKey{kid: pub}),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
AcceptBiscuit: true,
BiscuitRevocations: records,
DPoP: &limes.DPoPVerifier{Replay: limes.NewMemoryReplayCache(0)},
})
if err != nil {
log.Fatal(err)
}
return exampleStack{
issuer: issuer, verifier: verifier, records: records,
usage: memstore.NewUsage[struct{}](records), tenantID: uuid.New(),
}
}
// issue mints a capability for an agent: get and share under corpus/public/,
// 25.00 USD and 500 requests, for 15 minutes.
func (s exampleStack) issue() (limes.Capability, string) {
c, token, err := s.issuer.Issue(context.Background(), limes.IssueRequest{
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalAgent, TenantID: s.tenantID, Subject: "research-orchestrator",
},
Audience: []string{exampleAudience},
TTL: 15 * time.Minute,
Caveats: limes.Caveats{
Ops: []limes.Op{limes.OpGet, limes.OpShare},
ResourcePrefixes: []string{"corpus/public/"},
MaxRequests: 500,
MaxBudgetAmount: limes.MustParseAmount("25.00"),
UnitCode: "USD",
},
})
if err != nil {
log.Fatal(err)
}
return c, token
}
func main() {
ctx := context.Background()
s := newExampleStack()
c, _ := s.issue()
full, err := s.issuer.Biscuit(context.Background(), c)
if err != nil {
log.Fatal(err)
}
// The holder narrows it offline: no key, no network.
narrow, err := limes.Attenuate(full, limes.Attenuation{
Ops: []limes.Op{limes.OpGet},
ResourcePrefixes: []string{"corpus/public/2026/"},
})
if err != nil {
log.Fatal(err)
}
got, err := s.verifier.Verify(ctx, limes.VerifyRequest{Token: narrow, Audience: exampleAudience})
fmt.Println(err, got.ID == c.ID, got.Caveats.Ops, got.Caveats.ResourcePrefixes)
}
Output: <nil> true [get] [corpus/public/2026/]
func DPoPSigningAlgorithms ¶ added in v0.3.0
func DPoPSigningAlgorithms() []string
DPoPSigningAlgorithms are the JWS algorithms a DPoP proof may be signed with, as a challenge advertises them (RFC 9449 §7.1): the fully-specified names (RFC 9864). The deprecated "EdDSA" is still read, never advertised.
func DeriveKeyID ¶ added in v0.3.0
DeriveKeyID is the kid of a key published with none named: its RFC 7638 thumbprint. The same key always gets the same kid, wherever it is derived, and two keys never share one.
func GenerateEd25519Keypair ¶
func GenerateEd25519Keypair() (kid string, pub ed25519.PublicKey, priv ed25519.PrivateKey, err error)
GenerateEd25519Keypair is a convenience for boot wiring: returns a new Ed25519 keypair plus the kid to publish it under, DeriveKeyID's.
func HasDotSegment ¶ added in v0.3.0
HasDotSegment reports whether s has a "/"-separated segment that is "." or "..", counting "%2e" (any case) as a dot — the segments RFC 3986 resolves away, which make a resource's text name somewhere other than it reads. Caveats.Validate refuses a resource with one, and no request naming one matches a restricted capability.
func IsBiscuit ¶
IsBiscuit reports whether token is in the Biscuit form rather than a JWT: a JWT has exactly two dots, base64url has none.
func IsUnitCode ¶ added in v0.3.0
IsUnitCode reports whether u is an ISO 4217 currency code, in its canonical upper-case spelling: "USD", "JPY", "XXX". Empty is NOT one — callers that want the empty-means-default semantics resolve through NormaliseUnitCode first.
func JWKSHandler ¶ added in v0.3.0
JWKSHandler serves keys() as a JWK Set document (RFC 7517), the way the RemoteJWKSResolver on the other end reads it: under its registered media type (§8.5), with a strong ETag of its bytes that answers If-None-Match with 304 (RFC 9110 §13.1.2), and fresh for maxAge (RFC 9111 §5.2.2.1). Keep maxAge below the time between publishing a new key and signing with it, and between withdrawing an old key and the end of its tokens' TTL; a negative maxAge is served as zero.
func KeyThumbprint ¶
KeyThumbprint is PublicJWK followed by Thumbprint: what to put in an IssueRequest's ConfirmationJKT to bind a capability to the holder of pub.
func LoadPrivateKey ¶
func LoadPrivateKey(path string) (ed25519.PrivateKey, error)
LoadPrivateKey reads an Ed25519 private key from a PEM-encoded PKCS#8 file at the supplied path. Production wires this against a Secret-mounted RO volume; dev calls GenerateEd25519Keypair instead and skips this path entirely.
Only PKCS#8 under PEM type "PRIVATE KEY" is read — not a raw seed, not another PEM type — because that is OpenSSL's default output. A wrong format is loud rather than read one way here and another there.
func MarshalJWKS ¶
MarshalJWKS produces a JWKS document for the supplied kid → public key map, with entries sorted by kid so the same key set always serialises to the same bytes (cacheable, diffable, ETag-able).
func MatchResource ¶
MatchResource reports whether uri falls under prefix, matched at a path- segment boundary: "corpus/public" covers "corpus/public" and "corpus/public/x", but not "corpus/public-secret". A prefix that already ends in "/" matches anything beneath it. An empty prefix matches nothing — a caveat that means "everything" is written by leaving the list empty, not by an empty entry, so a stray "" can never silently widen a capability.
A uri with a dot segment — "." or "..", percent-encoded or not — matches nothing: what it names depends on how the consumer resolves it, so the match it seems to make by its text proves nothing about where it leads.
This is the single definition of a resource match. Delegation narrowing and request-time enforcement both go through it, so the two can never disagree about what a prefix reaches.
Example ¶
package main
import (
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
fmt.Println(limes.MatchResource("corpus/public", "corpus/public/x"))
fmt.Println(limes.MatchResource("corpus/public", "corpus/public-secret"))
}
Output: true false
func Narrows ¶
func Narrows(parent, child Capability) error
Narrows reports whether `child` is a narrowing of `parent`: every operation child allows is allowed by parent; every resource child can touch is reachable by parent; the budget / request count are no larger, and the validity window starts no earlier and ends no later; the audience is a subset; tainted-read is no broader. Issuer.Delegate runs this check before signing the child token, so a misuse fails at issue time, not later.
The function is exposed at package level so external tooling can dry-run a delegation without going through the issuer.
func NewDPoPProof ¶
func NewDPoPProof(key crypto.Signer, req DPoPProofRequest) (string, error)
NewDPoPProof signs a proof for one request. key is any crypto.Signer whose public key is Ed25519 or ECDSA P-256.
Example ¶
package main
import (
"context"
"crypto/ed25519"
"crypto/rand"
"errors"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/memstore"
)
// The issuer's name, and the audience every example's tokens are minted for.
const (
exampleIssuerName = "example-issuer"
exampleAudience = "my-service"
)
// exampleStack is an issuer and a verifier over one in-memory store — the
// setup ExampleIssuer_Issue shows in full.
type exampleStack struct {
issuer *limes.Issuer
verifier *limes.StandardVerifier
records *memstore.Store[struct{}]
usage *memstore.UsageStore[struct{}]
tenantID uuid.UUID
}
func newExampleStack() exampleStack {
kid, pub, priv, err := limes.GenerateEd25519Keypair()
if err != nil {
log.Fatal(err)
}
records := memstore.New[struct{}]()
signer, err := limes.NewEd25519Signer(kid, priv)
if err != nil {
log.Fatal(err)
}
issuer, err := limes.NewIssuer(limes.IssuerConfig{Signer: signer, Store: records, IssuerName: exampleIssuerName})
if err != nil {
log.Fatal(err)
}
verifier, err := limes.NewStandardVerifier(limes.VerifierConfig{
Keys: limes.NewStaticKeyResolver(map[string]ed25519.PublicKey{kid: pub}),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
AcceptBiscuit: true,
BiscuitRevocations: records,
DPoP: &limes.DPoPVerifier{Replay: limes.NewMemoryReplayCache(0)},
})
if err != nil {
log.Fatal(err)
}
return exampleStack{
issuer: issuer, verifier: verifier, records: records,
usage: memstore.NewUsage[struct{}](records), tenantID: uuid.New(),
}
}
// issue mints a capability for an agent: get and share under corpus/public/,
// 25.00 USD and 500 requests, for 15 minutes.
func (s exampleStack) issue() (limes.Capability, string) {
c, token, err := s.issuer.Issue(context.Background(), limes.IssueRequest{
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalAgent, TenantID: s.tenantID, Subject: "research-orchestrator",
},
Audience: []string{exampleAudience},
TTL: 15 * time.Minute,
Caveats: limes.Caveats{
Ops: []limes.Op{limes.OpGet, limes.OpShare},
ResourcePrefixes: []string{"corpus/public/"},
MaxRequests: 500,
MaxBudgetAmount: limes.MustParseAmount("25.00"),
UnitCode: "USD",
},
})
if err != nil {
log.Fatal(err)
}
return c, token
}
func main() {
ctx := context.Background()
s := newExampleStack()
const method, url = "POST", "https://data.example.com/objects/get"
// The capability is bound to a key the agent keeps.
_, agentKey, _ := ed25519.GenerateKey(rand.Reader)
jkt, _ := limes.KeyThumbprint(agentKey.Public())
_, token, err := s.issuer.Issue(ctx, limes.IssueRequest{
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{Type: limes.PrincipalAgent, TenantID: s.tenantID, Subject: "worker"},
Audience: []string{exampleAudience}, TTL: 15 * time.Minute,
Caveats: limes.Caveats{Ops: []limes.Op{limes.OpGet}},
ConfirmationJKT: jkt,
})
if err != nil {
log.Fatal(err)
}
// Without a proof the token is refused: a copy of it is useless.
_, err = s.verifier.Verify(ctx, limes.VerifyRequest{Token: token, Audience: exampleAudience})
fmt.Println(errors.Is(err, limes.ErrDPoPRequired))
// The client signs a fresh proof per request.
proof, _ := limes.NewDPoPProof(agentKey, limes.DPoPProofRequest{Method: method, URL: url, Token: token, Now: time.Now()})
req := limes.VerifyRequest{
Token: token, Audience: exampleAudience,
DPoP: limes.DPoPProof{Proof: proof, Method: method, URL: url},
}
_, err = s.verifier.Verify(ctx, req)
fmt.Println(err)
// The same proof a second time is a replay.
_, err = s.verifier.Verify(ctx, req)
fmt.Println(errors.Is(err, limes.ErrDPoPReplayed))
}
Output: true <nil> true
func NormaliseUnitCode ¶
NormaliseUnitCode applies the empty-means-default policy and validates the result. Returns the canonical unit string and an error when the supplied non-empty value is not an ISO 4217 code.
func ParseJWKS ¶
ParseJWKS decodes a JWKS document into a kid → public key map. Entries that are not Ed25519 (a future kty), that lack a kid, or whose key does not decode to an Ed25519 public key are skipped rather than failing the document: one malformed or foreign entry must not take every other key — and so every token signed with them — out of service during a rotation. Two entries naming one kid are both skipped. A document that is not JSON at all, or names a member twice, is an error.
func ValidateAmount ¶
ValidateAmount reports whether a charge or refund amount is usable: not negative and at most MaxNanos. Implementations call it before touching state.
func ValidateExternalRef ¶
ValidateExternalRef reports whether ref is usable as ChargeRequest.ExternalRef: empty, or printable text within MaxExternalRefBytes.
func VerifySignature ¶
VerifySignature checks the Ed25519 signature on a compact token against the supplied public key. Time / audience / revocation gates are NOT applied here — Verify in the production verifier wraps this call with the full check chain.
func WriteErrorResponse ¶ added in v0.3.0
func WriteErrorResponse(w http.ResponseWriter, resp ErrorResponse)
WriteErrorResponse writes resp: its status, challenges, nonce and problem.
Types ¶
type AgentPrincipal ¶
type AgentPrincipal struct {
// AgentType is a human-meaningful label ("research-orchestrator",
// "code-reviewer-bot"). Used for grouping, dashboards, anomaly
// baselines.
AgentType string `json:"agent_type,omitempty"`
// AgentVersion pins the build / git-sha of the agent runtime.
AgentVersion string `json:"agent_version,omitempty"`
// RunID is a UUID minted by the agent host at session start.
RunID uuid.UUID `json:"run_id,omitzero"`
// ParentAgentID is set when this agent was spawned by another via
// `OpShare`. Empty for top-level agents.
ParentAgentID uuid.UUID `json:"parent_agent_id,omitzero"`
// Model identifies the LLM behind the agent ("example-model-1",
// "gpt-5-pro"). Used for cost attribution.
Model string `json:"model,omitempty"`
// MCPClient is the agent host's user-agent ("example-host@1.4.0",
// "cursor@0.44"). Useful for compatibility tracking.
MCPClient string `json:"mcp_client,omitempty"`
}
AgentPrincipal carries the fields specific to AI-agent principals. Threaded into audit + lineage so per-tool-call replay reconstructs the call graph without joining across half a dozen tables.
type Attenuation ¶
type Attenuation struct {
// Ops the token keeps.
Ops []Op
// ResourcePrefixes and ResourceURIs the token keeps. Setting either
// replaces both.
ResourcePrefixes []string
ResourceURIs []string
// Planes (audiences) the token keeps.
Planes []string
// ExpiresAt, when earlier than the token's, becomes its expiry.
ExpiresAt time.Time
// ConfirmationJKT binds the token to a key (KeyThumbprint). Only an
// unbound token can be bound offline: a bound one stays bound to its key,
// because rebinding needs no key and anyone could do it.
ConfirmationJKT string
// MaxRequests and MaxBudget give this copy limits of its own, counted
// apart from its siblings' and within the capability's: requests made
// with it, and its spend in the capability's unit. 0 sets none. Each
// must be within every limit already in force.
MaxRequests int64
MaxBudget Nanos
}
Attenuation is one offline narrowing. Empty fields leave that dimension as it is; set ones replace it and must be within what the token already allows, or the verifier refuses the whole token.
type AuthScheme ¶ added in v0.3.0
type AuthScheme string
AuthScheme is the authentication scheme a token was presented under.
const ( // AuthSchemeUnspecified is a token that did not come from an // Authorization header; VerifyRequest checks no scheme for it. AuthSchemeUnspecified AuthScheme = "" // AuthSchemeBearer presents a bearer token (RFC 6750). AuthSchemeBearer AuthScheme = "Bearer" // AuthSchemeDPoP presents a key-bound token (RFC 9449 §7.1). AuthSchemeDPoP AuthScheme = "DPoP" )
func ParseAuthorization ¶ added in v0.3.0
func ParseAuthorization(header string) (AuthScheme, string, error)
ParseAuthorization reads an Authorization header value: the scheme, matched without regard to case (RFC 9110 §11.1), and its token68 credential.
type AuthorizeRequest ¶ added in v0.3.0
type AuthorizeRequest struct {
VerifyRequest
CheckRequest
// ClientAddr is the client address the consumer observed. A capability
// constrained to source networks refuses the zero address.
ClientAddr netip.Addr
}
AuthorizeRequest is a VerifyRequest and the operation it is presented for.
type BiscuitCopy ¶
type BiscuitCopy struct {
// CapabilityID is the capability the Biscuit seals.
CapabilityID uuid.UUID
// RevocationID is the id of the copy's last block: listing it revokes
// this copy and every copy attenuated from it.
RevocationID []byte
// Limits are the request and budget limits in force on the copy — its
// own and those of the copies it was attenuated from — innermost first;
// CopyUsageReader reads their counters.
Limits []CopyCeiling
}
BiscuitCopy identifies one copy of a capability's Biscuit.
type BiscuitRevocation ¶
type BiscuitRevocation struct {
CapabilityID uuid.UUID
RevocationID []byte
RevokedAt time.Time
Reason string
Actor string
}
BiscuitRevocation is one revoked Biscuit copy, as RevokeBiscuit wrote it.
type BiscuitRevocationLookup ¶
type BiscuitRevocationLookup interface {
// IsBiscuitRevoked reports whether any of revocationIDs — the blocks
// of one copy, authority first — has been revoked.
IsBiscuitRevoked(ctx context.Context, revocationIDs [][]byte) (bool, error)
}
BiscuitRevocationLookup answers whether any of a Biscuit's revocation ids is revoked. The ids it is asked about come from a Biscuit whose signature chain has already verified.
type BiscuitRevocationStore ¶
type BiscuitRevocationStore interface {
BiscuitRevocationLookup
// RevokeBiscuit lists one revocation id of a capability's Biscuit.
// Idempotent. Returns ErrNotFound when the capability is not on record
// — or not visible to the caller — so a caller cannot revoke a copy of a
// capability it cannot see, and an ErrInvalidRequest for an empty
// revocation id.
RevokeBiscuit(ctx context.Context, req RevokeBiscuitRequest) error
// GetBiscuitRevocation reads back what RevokeBiscuit wrote for one
// revocation id. ErrNotFound when the id is not revoked, or belongs to a
// capability the caller cannot see.
GetBiscuitRevocation(ctx context.Context, revocationID []byte) (BiscuitRevocation, error)
}
BiscuitRevocationStore persists revoked Biscuit copies. It is separate from Store so that a consumer without Biscuits implements nothing more.
type BumpRequest ¶
type BumpRequest struct {
CapabilityID uuid.UUID
// TenantID is the capability's tenant. Used for attribution (metrics);
// it is not an authorisation input.
TenantID uuid.UUID
// MaxRequests is the capability's own ceiling, from the verified
// token. 0 = unlimited. Ancestors' ceilings come from their records.
MaxRequests int64
// Copies are the presented Biscuit copy's own limits, from the verified
// token (Capability.Copies), innermost first.
Copies []CopyCeiling
}
BumpRequest is the input to Meter.Bump.
type CacheOption ¶
type CacheOption func(*cacheConfig)
CacheOption configures a CachedRevocationChecker or a CachedBiscuitRevocationChecker.
func WithCacheClock ¶
func WithCacheClock(now func() time.Time) CacheOption
WithCacheClock replaces time.Now; tests use it to step through expiry.
func WithMaxEntries ¶
func WithMaxEntries(n int) CacheOption
WithMaxEntries bounds the number of cached entries. ≤ 0 keeps the default.
type CachedBiscuitRevocationChecker ¶
type CachedBiscuitRevocationChecker struct {
// contains filtered or unexported fields
}
CachedBiscuitRevocationChecker caches IsBiscuitRevoked answers per token — keyed by its whole list of revocation ids — under the same TTL, bound and sharing of concurrent misses as CachedRevocationChecker, and is cleared the same way: a revoked id is part of the key of every token carrying it, so only Clear reaches them all.
func NewCachedBiscuitRevocationChecker ¶
func NewCachedBiscuitRevocationChecker(upstream BiscuitRevocationLookup, ttl time.Duration, opts ...CacheOption) *CachedBiscuitRevocationChecker
NewCachedBiscuitRevocationChecker wires a cache over an upstream lookup. ttl and the options apply as they do to NewCachedRevocationChecker.
func (*CachedBiscuitRevocationChecker) Clear ¶
func (c *CachedBiscuitRevocationChecker) Clear()
Clear drops every entry; wire it to the same revocation notification as CachedRevocationChecker.Clear.
func (*CachedBiscuitRevocationChecker) IsBiscuitRevoked ¶
func (c *CachedBiscuitRevocationChecker) IsBiscuitRevoked(ctx context.Context, revocationIDs [][]byte) (bool, error)
IsBiscuitRevoked serves a fresh cached answer, or asks upstream once on behalf of every concurrent caller asking about the same token. An upstream error is returned and never cached.
func (*CachedBiscuitRevocationChecker) Sweep ¶
func (c *CachedBiscuitRevocationChecker) Sweep() int
Sweep drops every expired entry and returns how many it dropped.
type CachedRevocationChecker ¶
type CachedRevocationChecker struct {
// contains filtered or unexported fields
}
CachedRevocationChecker is a small in-memory cache for IsRevoked answers.
A cached answer about a capability is also an answer about its ancestors, so revoking a parent reaches a child's cached "live" entry only when that entry expires — within the TTL, like any other revocation seen from another process — or when Clear drops it. There is no per-id drop: the cache does not know which entries are a revoked id's descendants.
Concurrent misses for the same id share one upstream call.
func NewCachedRevocationChecker ¶
func NewCachedRevocationChecker(upstream RevocationLookup, ttl time.Duration, opts ...CacheOption) *CachedRevocationChecker
NewCachedRevocationChecker wires a cache over an upstream lookup. ttl of 0 falls back to DefaultRevocationCacheTTL; ttl < 0 disables caching (every call hits upstream — useful for tests).
func (*CachedRevocationChecker) Clear ¶
func (c *CachedRevocationChecker) Clear()
Clear drops every entry. A consumer that learns of a revocation from elsewhere — a database notification, a message bus — calls it so the next check goes upstream at once instead of waiting out the TTL. It drops everything, not only the revoked id, because an answer cached for a descendant is also an answer about the revoked ancestor.
func (*CachedRevocationChecker) IsRevoked ¶
IsRevoked serves a fresh cached answer, or asks upstream once on behalf of every concurrent caller asking about the same id. An upstream error is returned to all of them and is never cached.
func (*CachedRevocationChecker) Sweep ¶
func (c *CachedRevocationChecker) Sweep() int
Sweep drops every expired entry and returns how many it dropped. Optional housekeeping — lookups skip stale entries, and every new answer drops the expired ones before it.
type Capability ¶
type Capability struct {
// ID is the unique identifier (UUIDv7 — sortable by issuance time
// so per-tenant indexes stay tight).
ID uuid.UUID
// Issuer is the service instance that minted the capability. Verifiers
// accept tokens whose Issuer is in the trusted set.
Issuer string
// Subject is the principal the capability authorises.
Subject Principal
// Audience pins which services the capability is valid for: a
// verifier refuses a token whose audience does not include the one it
// verifies for (VerifyRequest.Audience).
Audience []string
// Caveats is the conjunction of restrictions — every caveat must
// be satisfied by the request, and child capabilities can only
// add further caveats, never relax one.
Caveats Caveats
// IssuedAt / NotBefore / ExpiresAt are standard JWT-style times.
IssuedAt time.Time
NotBefore time.Time
ExpiresAt time.Time
// ParentID, if non-zero, points at the parent capability whose
// holder delegated this one. Narrowing is checked once, at
// delegation. At use, the lineage matters twice: revoking any
// ancestor revokes this capability (Store.IsRevoked answers for the
// whole chain), and every charge also debits each ancestor, so a
// parent's budget bounds the spend of everything delegated from it.
ParentID uuid.UUID
// Generation is an issuer-assigned counter (1 for a fresh root, 1 for
// every delegated child) carried in the token for audit. A consumer
// that rotates a capability may reissue with Generation+1 and revoke
// the previous one, and compare generations in its own records.
//
// It is NOT a fence token: nothing in this package compares it
// against a stored current value, so an older generation stays usable
// until it expires or is revoked. Revocation is the mechanism that
// stops a superseded capability.
Generation int64
// ConfirmationJKT, when set, binds the capability to a key: the RFC 7638
// thumbprint of the public key whose holder alone may present it. Every
// request must then carry a DPoP proof signed by that key (dpop.go). A
// delegated child of a bound capability is bound too. Carried in the
// token as `cnf.jkt`, and absent from tokens that are not bound.
ConfirmationJKT string
// BiscuitRoot is set only on the token sealed inside a Biscuit (see
// Issuer.Biscuit): the base64url Ed25519 public key that roots the
// Biscuit's signature chain. A token carrying it is refused when
// presented on its own, so it cannot be lifted out of an attenuated
// Biscuit to shed the attenuation.
BiscuitRoot string
// Copies are the request and budget limits the attenuation blocks of a
// verified Biscuit set, innermost block first. Each is counted on its
// own, beside the capability's counters, by every Meter call that is
// handed them. Set by the verifier only; never part of a token or a
// stored record.
Copies []CopyCeiling `json:"-"`
}
Capability is the in-memory representation of a verified token. The wire form is a JWT; this struct is what callers receive from Verifier.Verify and what the Issuer fills before signing.
func Decode ¶
func Decode(token string) (Capability, error)
Decode parses a compact-form token without verifying the signature or applying time / audience / revocation gates. Use only for tooling that displays a token; production code goes through Verifier.Verify, which never reads a claim before the signature has checked out.
type CapabilityLister ¶ added in v0.2.0
type CapabilityLister interface {
// ListByPrincipal returns one page and the cursor of the next, empty
// on the last page (Store.ListByPrincipal).
ListByPrincipal(ctx context.Context, req ListByPrincipalRequest) ([]Capability, string, error)
}
CapabilityLister lists a principal's capabilities a page at a time. Every Store is one.
type Caveats ¶
type Caveats struct {
// Ops is the set of operations the capability authorises. The
// issuer rejects an empty set: a capability that allows nothing is
// a mistake, not a credential.
Ops []Op `json:"ops"`
// ResourcePrefixes restricts the capability to URIs under any prefix
// in the slice, matched at a "/" segment boundary (MatchResource):
// "a/b" covers "a/b" and "a/b/c" but not "a/bc". Empty slice =
// unrestricted within the tenant; an empty ENTRY is invalid.
ResourcePrefixes []string `json:"resource_prefixes,omitempty"`
// ResourceURIs pins the capability to specific URIs. Used by
// hand-off flows where the parent passes exactly the artifacts the
// child needs.
ResourceURIs []string `json:"resource_uris,omitempty"`
// MaxRequests caps the total number of times the capability may
// be used. 0 = unlimited within the TTL.
MaxRequests int64 `json:"max_requests,omitzero"`
// MaxBudgetAmount is the cost budget the capability authorises, in
// the currency identified by UnitCode, exact to the nano. The Meter
// counts spend against it; once exhausted, a charge returns
// ErrBudgetExceeded.
MaxBudgetAmount Nanos `json:"max_budget,omitzero"`
// UnitCode pins the currency or unit: an ISO 4217 code (IsUnitCode),
// XXX ("no currency") for a budget that is not money. Empty value is
// interpreted as the default ("USD") at the application boundary; the
// in-memory shape is happy to carry either form.
UnitCode string `json:"unit_code,omitempty"`
// AllowTaintedRead permits non-mutating operations on resources the
// consumer has flagged with prompt-injection / PII / secrets signals.
// Off by default. Enforced by Caveats.Check, which can only act on a
// taint signal the consumer supplies in CheckRequest.ResourceTainted
// — a consumer that tracks no taint flags gets no protection from
// this caveat, and should say so rather than imply it.
AllowTaintedRead bool `json:"allow_tainted_read,omitzero"`
// IdempotencyKeyRequired forces the bearer to send an explicit
// idempotency key on mutating operations (CheckRequest.Mutating).
// Enforced by Caveats.Check from CheckRequest.HasIdempotencyKey.
IdempotencyKeyRequired bool `json:"idempotency_key_required,omitzero"`
// SourceIPCIDR optionally pins the capability to clients whose
// observed IP is in the listed CIDRs. Empty = unconstrained.
// Enforced by Caveats.CheckSource; delegation requires each child
// network to lie inside a parent network.
SourceIPCIDR []string `json:"source_ip_cidr,omitempty"`
}
Caveats is a typed bag of restrictions. Empty values are interpreted as "no restriction on that axis"; explicit bounds are AND-combined.
The json tags are the token's wire format (the limes_caveats claim), so renaming a field changes nothing a token carries. A verifier refuses a member it does not know: a caveat it cannot enforce must not pass as one it did.
A child capability may only narrow caveats — set a tighter prefix, drop ops, lower the budget, shorten TTL. Issuer.Delegate enforces this through Narrows before it signs, and the verifier does for every Biscuit attenuation block, so an attempt to widen is refused, never minted.
func (Caveats) AllowsResource ¶
AllowsResource reports whether uri is reachable under the resource caveats: unrestricted caveats allow any uri; restricted ones require an exact ResourceURIs entry or a covering ResourcePrefixes entry. An empty uri, or one with a dot segment (HasDotSegment), is never allowed by restricted caveats — an operation that cannot name what it touches cannot be shown to stay inside the scope.
func (Caveats) Check ¶
func (c Caveats) Check(req CheckRequest) error
Check evaluates every per-operation caveat against req and returns the first violation, as one of the sentinels above, or nil. A declared Effect that Op.ResolveEffect refuses returns ErrEffectConflict, which is a programming error rather than a caveat violation. It is the one definition of what the caveats mean at use time; a consumer that hand-rolls the same checks drifts from it.
The source-IP caveat is per connection rather than per operation, so it is checked separately by CheckSource. Budget and request-count caveats are stateful and enforced by the UsageStore.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
caveats := limes.Caveats{
Ops: []limes.Op{limes.OpGet},
ResourcePrefixes: []string{"corpus/public/"},
}
fmt.Println(caveats.Check(limes.CheckRequest{Op: limes.OpGet, Resource: "corpus/public/report.pdf"}))
// A prefix matches at a "/" boundary only.
err := caveats.Check(limes.CheckRequest{Op: limes.OpGet, Resource: "corpus/public-secret/x"})
fmt.Println(errors.Is(err, limes.ErrResourceNotAllowed), errors.Is(err, limes.ErrCaveatViolation))
err = caveats.Check(limes.CheckRequest{Op: limes.OpPut, Resource: "corpus/public/report.pdf"})
fmt.Println(errors.Is(err, limes.ErrOpNotAllowed))
}
Output: <nil> true true true
func (Caveats) CheckSource ¶
CheckSource evaluates the source-IP caveat against the client address the consumer observed. An unknown address (the zero netip.Addr) fails a constrained capability closed: "we could not tell" is not "it matched".
func (Caveats) RestrictsResources ¶
RestrictsResources reports whether the caveats confine the capability to a set of resources. False means "any resource within the tenant".
type ChargeReceipt ¶
type ChargeReceipt struct {
// ChargeID identifies the ledger row; pass it to Meter.Refund.
ChargeID uuid.UUID
// Spent is the capability's spend as this call returns (its subtree's,
// when it has delegated children).
Spent Nanos
// Replayed reports that the charge was already in the ledger — the same
// ExternalRef, or a reservation already settled — so this call moved
// nothing and ChargeID names the earlier charge.
Replayed bool
// Overrun reports that the charge crossed at least one ceiling, which
// only OverrunRecord allows.
Overrun bool
// TenantID and UnitCode are the charge's tenant and unit, so a caller
// holding only a receipt — of a Settle, say, whose request names only the
// reservation — can attribute it. A refusal by a ceiling or by onCharged
// still sets them.
TenantID uuid.UUID
UnitCode string
}
ChargeReceipt is the result of a committed charge.
type ChargeRecord ¶
type ChargeRecord struct {
ChargeID uuid.UUID
CapabilityID uuid.UUID
// Amount is what the charge took; Refunded is what has been returned
// from it since.
Amount Nanos
Refunded Nanos
UnitCode string
// ExternalRef is the charge's ChargeRequest.ExternalRef; empty for none.
ExternalRef string
// Overrun reports that the charge crossed a ceiling (OverrunRecord).
Overrun bool
}
ChargeRecord is one charge as the ledger holds it.
type ChargeRequest ¶
type ChargeRequest struct {
CapabilityID uuid.UUID
// TenantID is the capability's tenant: its aggregate ceiling applies
// and the ledger row is filed under it. Required.
TenantID uuid.UUID
// Amount is the cost, in UnitCode. Within 0..MaxNanos (ValidateAmount).
Amount Nanos
// MaxBudget is the capability's own ceiling, from the verified token.
// 0 = unlimited.
MaxBudget Nanos
// UnitCode is the currency or unit; empty means DefaultUnitCode. No
// conversion happens: a charge in a unit other than one a counter it
// moves already counts in — the capability's, an ancestor's, the
// tenant's — returns ErrUnitCodeMismatch and moves nothing. So does a
// reservation.
UnitCode string
// Op and Actor are stamped on the ledger row for attribution only;
// both may be empty and neither affects enforcement.
Op string
Actor string
// Copies are the presented Biscuit copy's own limits (Capability.Copies).
Copies []CopyCeiling
// ExternalRef names the cost in the consumer's own records — the id of
// the call or the event that reported it — and makes the charge
// idempotent on (CapabilityID, ExternalRef). Empty: every call charges.
// At most MaxExternalRefBytes long.
ExternalRef string
// Overrun is what happens when Amount would cross a ceiling.
Overrun OverrunPolicy
}
ChargeRequest is the input to Meter.Charge.
type CheckRequest ¶
type CheckRequest struct {
// Op is the operation being performed.
Op Op
// Effect is what Op does to state, as the server declares it. Leave it
// EffectUnspecified for a built-in operation; see Op.ResolveEffect.
Effect Effect
// Resource is the URI the operation touches. Leave it empty only for an
// operation that is not bound to one resource; a resource-restricted
// capability refuses such an operation (see Caveats.AllowsResource).
// For an operation over a set — a listing, a batch — pass the URI or
// prefix that bounds the whole set.
Resource string
// HasIdempotencyKey reports whether the request carried an idempotency
// key. Consulted only when the caveats require one for a mutating
// operation.
HasIdempotencyKey bool
// ResourceTainted reports whether the consumer has flagged the resource
// with a prompt-injection / PII / secrets signal. A consumer with no
// taint tracking leaves it false — AllowTaintedRead then protects
// nothing, which is the consumer's to disclose.
ResourceTainted bool
}
CheckRequest describes one operation a bearer is attempting, in the terms the caveats are written in. The consumer fills it from its own request.
func (CheckRequest) Mutating ¶
func (req CheckRequest) Mutating() (bool, error)
Mutating reports whether the operation may change state, resolving its declared Effect. A consumer that gates anything else on the operation's effect — a lookup it runs only for reads — asks here, so that it and Check cannot disagree.
type CopyCeiling ¶
type CopyCeiling struct {
// RevocationID is the block's revocation id.
RevocationID []byte
// MaxRequests bounds the requests made with the copy; 0 = no limit here.
MaxRequests int64
// MaxBudget bounds the copy's spend, in the capability's unit; 0 = no
// limit here.
MaxBudget Nanos
}
CopyCeiling is a limit one attenuation block of a Biscuit set on the copy it ends and every copy attenuated from it, all of which carry the block's revocation id. Its counters are keyed by that id, so siblings attenuated apart count apart, while the capability's own counters still bound them all together.
type CopyUsage ¶
type CopyUsage struct {
RevocationID []byte
CapabilityID uuid.UUID
RequestCount int64
SpentAmount Nanos
ReservedAmount Nanos
}
CopyUsage is the counters of one Biscuit copy with limits of its own (see Meter, "Copies of a Biscuit"): the copy and every copy attenuated from it.
type CopyUsageReader ¶
type CopyUsageReader interface {
// CopyUsage returns the counters of each copy named that has been used,
// in no particular order; a copy never used has no counters and is
// absent from the result.
CopyUsage(ctx context.Context, revocationIDs [][]byte) ([]CopyUsage, error)
}
CopyUsageReader reads the counters of Biscuit copies. It is separate from Meter so that a Meter which does not count copies implements nothing more.
type DPoPJWK ¶
type DPoPJWK struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y,omitempty"`
}
DPoPJWK is the public key a proof carries in its header: an Ed25519 (OKP) or P-256 (EC) key.
type DPoPNonces ¶ added in v0.3.0
type DPoPNonces interface {
// Nonce returns a nonce to send in the DPoP-Nonce header.
Nonce(ctx context.Context) (string, error)
// Valid reports whether nonce is one Nonce issued that is still current.
Valid(ctx context.Context, nonce string) bool
}
DPoPNonces issues the nonces a verifier hands out and recognises them when a proof carries one back.
type DPoPProof ¶ added in v0.3.0
type DPoPProof struct {
// Proof is the DPoP header's value; empty when the request had none.
Proof string
// Method is the HTTP method, e.g. "POST".
Method string
// URL is the request URL, as the server saw it (see
// DPoPVerifier.MatchPathOnly).
URL string
}
DPoPProof is a request's proof of possession and the request it must match: what VerifyRequest.DPoP carries for a key-bound capability.
type DPoPProofRequest ¶ added in v0.3.0
type DPoPProofRequest struct {
// Method and URL are the request's, the method exactly as sent: it is
// case-sensitive (RFC 9110 §9.1).
Method string
URL string
// Token is the capability the request presents.
Token string
// Nonce is the last DPoP-Nonce the server sent, empty before it has
// sent one.
Nonce string
// Now is the proof's iat.
Now time.Time
}
DPoPProofRequest is the request a proof is signed for.
type DPoPVerifier ¶
type DPoPVerifier struct {
// Window bounds |now − iat|. Default DefaultDPoPWindow.
Window time.Duration
// Replay refuses a jti seen before. Required: without it a captured
// proof is good for the whole window, and NewStandardVerifier refuses
// the configuration.
Replay ReplayCache
// Now is the clock; default the verifier's.
Now func() time.Time
// Nonces, when set, requires every proof to carry a nonce it issued
// (RFC 9449 §8); a proof without one is refused with
// ErrDPoPNonceRequired, and StandardVerifier.DPoPNonce gives the nonce
// to send back.
Nonces DPoPNonces
// MatchPathOnly compares only the path of DPoPProof.URL, and lets the
// proof's path carry extra leading segments — for a server behind a
// proxy, which cannot know the scheme, host or mount prefix its clients
// address it by.
MatchPathOnly bool
}
DPoPVerifier is how a StandardVerifier checks the proofs of key-bound capabilities (VerifierConfig.DPoP).
type DelegateRequest ¶
type DelegateRequest struct {
// Parent is the verified capability the child narrows.
Parent Capability
// Subject is who the child authorises; its TenantID defaults to the
// parent's.
Subject Principal
// Audience defaults to the parent's, and must be a subset of it.
Audience []string
// Caveats are the child's; see InheritCaveats.
Caveats Caveats
// InheritCaveats copies the parent's caveats verbatim and ignores
// Caveats. It must be asked for: an earlier version inherited silently
// whenever Caveats.Ops was empty, which replaced every other field the
// caller HAD set — a budget of 2 became the parent's 25 without a word.
InheritCaveats bool
// TTL defaults to IssuerConfig.DefaultTTL, and is cut so the child
// expires no later than the parent.
TTL time.Duration
// NotBefore is raised, when earlier, to the parent's start: its
// NotBefore, else its IssuedAt.
NotBefore time.Time
// ConfirmationJKT binds the child to a key — typically the sub-agent's.
// Empty keeps the parent's binding, if it has one: a bound parent's
// child is always bound.
ConfirmationJKT string
}
DelegateRequest narrows from a parent capability the caller already holds. The issuer enforces narrowing (Narrows) before persisting so a runtime widening attempt fails before any token is emitted.
type Effect ¶
type Effect uint8
Effect is what an operation does to state. Check uses it twice: a mutating operation may need an idempotency key (Caveats.IdempotencyKeyRequired), and a read may be refused on a tainted resource (Caveats.AllowTaintedRead).
The server that performs the operation declares the effect, from the same place it defines the operation (a method option, a tool registry), and never from anything the bearer sends.
const ( // EffectUnspecified takes the operation's default: the fixed effect of a // built-in operation, and EffectWrite for a consumer-defined one, whose // effect this package cannot know — so an undeclared operation errs // towards the caveats that tighten mutations. EffectUnspecified Effect = iota // EffectRead leaves state unchanged. EffectRead // EffectWrite may change state. EffectWrite )
type ErrorResponse ¶ added in v0.3.0
type ErrorResponse struct {
// Status is the HTTP status.
Status int
// Challenges are the WWW-Authenticate values, one per scheme; none for a
// refusal that is not about authentication.
Challenges []string
// DPoPNonce is the nonce to send in the DPoP-Nonce header, when the
// refusal asks for one.
DPoPNonce string
// Problem is the body.
Problem Problem
}
ErrorResponse is how to answer a refused request.
type HMACNonces ¶ added in v0.3.0
type HMACNonces struct {
// contains filtered or unexported fields
}
HMACNonces is a DPoPNonces that keeps no state: a nonce is its issuance time and an HMAC of it, so every replica holding the same keys issues and accepts the same nonces. Rotate by putting the new key first: it issues, and the old ones are still accepted until their nonces expire.
func NewHMACNonces ¶ added in v0.3.0
func NewHMACNonces(lifetime time.Duration, keys ...[]byte) (*HMACNonces, error)
NewHMACNonces builds an HMACNonces. keys[0] signs new nonces; every key checks. Each key is at least HMACNonceKeyBytes long; lifetime ≤ 0 is DefaultDPoPNonceLifetime.
type IssueRequest ¶
type IssueRequest struct {
Subject Principal
// IssuedBy is the principal REQUESTING the capability — an operator for a
// root issuance, the parent's holder for a delegation. Distinct from
// Subject, which is who the capability authorises. Required: a capability
// nobody requested cannot be audited, and the store rejects an empty one.
IssuedBy Principal
Audience []string
Caveats Caveats
TTL time.Duration // 0 → DefaultTTL
NotBefore time.Time // zero → now
Generation int64 // 0 → 1
// ConfirmationJKT binds the capability to the key with this RFC 7638
// thumbprint (KeyThumbprint); empty issues a bearer capability.
ConfirmationJKT string
}
IssueRequest is the input shape for Issuer.Issue. Subject (with its TenantID), IssuedBy, Audience and Caveats are required; TTL, NotBefore, Generation and ConfirmationJKT are optional, and the issuer fills in ID, IssuedAt, ExpiresAt, Issuer and Generation. Caveats are validated (Caveats.Validate) before anything is persisted.
type Issuer ¶
type Issuer struct {
// contains filtered or unexported fields
}
Issuer mints capabilities. Two responsibilities, in this order:
- Validate the request (caveats sane, parent narrowing if delegated).
- Persist the audit row, then sign and return the token.
The persist-then-sign order is deliberate. If we signed first and the store write failed, an emitted token would have no record — audit and admin reads would find nothing behind it, and Revoke, which refuses an id not on record, could not stop it. Persist-first means the token in the caller's hand is always reflected in storage; a failed sign just means the row is unused (cleaned up by housekeeping at the natural expiry).
func NewIssuer ¶
func NewIssuer(cfg IssuerConfig) (*Issuer, error)
NewIssuer validates wiring and returns a ready Issuer.
func (*Issuer) Biscuit ¶
Biscuit returns the Biscuit form of a capability this issuer issued: the same capability — same ID, caveats, expiry and binding, revoked together with it — in a token its holder can attenuate offline. One copy can also be revoked on its own; see BiscuitRevocationStore.
func (*Issuer) Delegate ¶
func (i *Issuer) Delegate(ctx context.Context, req DelegateRequest) (Capability, string, error)
Delegate mints a child capability narrower than the supplied parent. The parent must already be a verified Capability — callers run it through Verifier.Verify first; the Issuer trusts its in-memory shape. It does not trust it to be current, though: a parent that has expired, or that the store reports revoked (itself or any ancestor), delegates nothing; nor does a Biscuit copy with limits of its own (Parent.Copies), which returns ErrDelegationTooWide.
Example ¶
package main
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/memstore"
)
// The issuer's name, and the audience every example's tokens are minted for.
const (
exampleIssuerName = "example-issuer"
exampleAudience = "my-service"
)
// exampleStack is an issuer and a verifier over one in-memory store — the
// setup ExampleIssuer_Issue shows in full.
type exampleStack struct {
issuer *limes.Issuer
verifier *limes.StandardVerifier
records *memstore.Store[struct{}]
usage *memstore.UsageStore[struct{}]
tenantID uuid.UUID
}
func newExampleStack() exampleStack {
kid, pub, priv, err := limes.GenerateEd25519Keypair()
if err != nil {
log.Fatal(err)
}
records := memstore.New[struct{}]()
signer, err := limes.NewEd25519Signer(kid, priv)
if err != nil {
log.Fatal(err)
}
issuer, err := limes.NewIssuer(limes.IssuerConfig{Signer: signer, Store: records, IssuerName: exampleIssuerName})
if err != nil {
log.Fatal(err)
}
verifier, err := limes.NewStandardVerifier(limes.VerifierConfig{
Keys: limes.NewStaticKeyResolver(map[string]ed25519.PublicKey{kid: pub}),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
AcceptBiscuit: true,
BiscuitRevocations: records,
DPoP: &limes.DPoPVerifier{Replay: limes.NewMemoryReplayCache(0)},
})
if err != nil {
log.Fatal(err)
}
return exampleStack{
issuer: issuer, verifier: verifier, records: records,
usage: memstore.NewUsage[struct{}](records), tenantID: uuid.New(),
}
}
// issue mints a capability for an agent: get and share under corpus/public/,
// 25.00 USD and 500 requests, for 15 minutes.
func (s exampleStack) issue() (limes.Capability, string) {
c, token, err := s.issuer.Issue(context.Background(), limes.IssueRequest{
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalAgent, TenantID: s.tenantID, Subject: "research-orchestrator",
},
Audience: []string{exampleAudience},
TTL: 15 * time.Minute,
Caveats: limes.Caveats{
Ops: []limes.Op{limes.OpGet, limes.OpShare},
ResourcePrefixes: []string{"corpus/public/"},
MaxRequests: 500,
MaxBudgetAmount: limes.MustParseAmount("25.00"),
UnitCode: "USD",
},
})
if err != nil {
log.Fatal(err)
}
return c, token
}
func main() {
ctx := context.Background()
s := newExampleStack()
parent, _ := s.issue()
worker := limes.Principal{Type: limes.PrincipalAgent, TenantID: s.tenantID, Subject: "sub-worker"}
child, _, err := s.issuer.Delegate(ctx, limes.DelegateRequest{
Parent: parent,
Subject: worker,
TTL: 2 * time.Minute,
Caveats: limes.Caveats{
Ops: []limes.Op{limes.OpGet},
ResourcePrefixes: []string{"corpus/public/2026/"},
MaxRequests: 50,
MaxBudgetAmount: limes.MustParseAmount("2.00"),
UnitCode: "USD",
},
})
fmt.Println(err, child.ParentID == parent.ID)
// Asking for more than the parent holds is refused.
_, _, err = s.issuer.Delegate(ctx, limes.DelegateRequest{
Parent: parent,
Subject: worker,
TTL: time.Minute,
Caveats: limes.Caveats{
Ops: []limes.Op{limes.OpGet},
MaxBudgetAmount: limes.MustParseAmount("999"),
UnitCode: "USD",
},
})
fmt.Println(errors.Is(err, limes.ErrDelegationTooWide))
}
Output: <nil> true true
func (*Issuer) Issue ¶
func (i *Issuer) Issue(ctx context.Context, req IssueRequest) (Capability, string, error)
Issue mints a top-level capability. Generation defaults to 1; see Capability.Generation for what it does and does not promise.
Returns the typed Capability and the compact-form token in one call — callers usually need both: the token to ship to the agent, the struct to log / display.
Example ¶
package main
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/memstore"
)
// The issuer's name, and the audience every example's tokens are minted for.
const (
exampleIssuerName = "example-issuer"
exampleAudience = "my-service"
)
func main() {
ctx := context.Background()
kid, pub, priv, _ := limes.GenerateEd25519Keypair()
records := memstore.New[struct{}]() // your Store
signer, _ := limes.NewEd25519Signer(kid, priv)
issuer, _ := limes.NewIssuer(limes.IssuerConfig{
Signer: signer, Store: records, IssuerName: exampleIssuerName,
})
verifier, _ := limes.NewStandardVerifier(limes.VerifierConfig{
Keys: limes.NewStaticKeyResolver(map[string]ed25519.PublicKey{kid: pub}),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
})
c, token, err := issuer.Issue(ctx, limes.IssueRequest{
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalAgent, TenantID: uuid.New(), Subject: "research-orchestrator",
},
Audience: []string{exampleAudience},
TTL: 15 * time.Minute,
Caveats: limes.Caveats{
Ops: []limes.Op{limes.OpGet},
ResourcePrefixes: []string{"corpus/public/"},
MaxBudgetAmount: limes.MustParseAmount("25.00"),
UnitCode: "USD",
},
})
if err != nil {
log.Fatal(err)
}
got, err := verifier.Verify(ctx, limes.VerifyRequest{Token: token, Audience: exampleAudience})
fmt.Println(err == nil, got.ID == c.ID)
_, err = verifier.Verify(ctx, limes.VerifyRequest{Token: token, Audience: "another-service"})
fmt.Println(errors.Is(err, limes.ErrAudienceMismatch))
}
Output: true true true
type IssuerConfig ¶
type IssuerConfig struct {
// Signer mints tokens. Production wraps a KMS-held private key;
// dev / tests use NewEd25519Signer with a local generated key.
Signer Signer
// Store persists issuance + revocation rows.
Store Store
// IssuerName is the value placed in the `iss` claim. Verifiers
// require this to be in their TrustedIssuers list.
IssuerName string
// DefaultTTL caps the lifetime when IssueRequest.TTL is zero.
// Default 15 minutes when zero — short enough that revocation
// propagation latency rarely matters.
DefaultTTL time.Duration
// Now is the clock; tests override.
Now func() time.Time
}
IssuerConfig is the wiring for NewIssuer.
type JWK ¶
type JWK struct {
Kty string `json:"kty"` // "OKP"
Crv string `json:"crv"` // "Ed25519"
Kid string `json:"kid"` // matches jwtHeader.Kid
Alg string `json:"alg,omitempty"` // "Ed25519" (RFC 9864); "EdDSA" is read
X string `json:"x"` // base64url-encoded raw public key
}
JWK is one entry in the JWKS — exactly the fields needed to verify an Ed25519 signature; we do not embed `use` or `key_ops` because the kid + alg pair fully constrains use here.
type JWKSDocument ¶
type JWKSDocument struct {
Keys []JWK `json:"keys"`
}
JWKSDocument is the on-the-wire shape served at the issuer's JWKS endpoint (JWKSHandler; typically `/.well-known/jwks.json`). Verifiers in other services fetch and cache this document (RemoteJWKSResolver) so capability verification stays local without an extra call per request.
Format: RFC 7517 with kty=OKP and crv=Ed25519 (RFC 8037), alg=Ed25519 (RFC 9864).
Serve capability keys from their own document, separate from any IdP JWKS: capability tokens use a distinct algorithm, issuer set and rotation cadence, and one shared document would conflate two trust roots.
type KeyResolver ¶
type KeyResolver interface {
// PublicKey returns the Ed25519 public key for the given kid.
// Returns ErrUnknownKID if the key set does not contain the kid;
// the verifier maps that to ErrInvalidSignature so an unknown
// kid never short-circuits to "trusted".
PublicKey(ctx context.Context, kid string) (ed25519.PublicKey, error)
}
KeyResolver resolves a JWKS `kid` to the public key the verifier should use. StaticKeyResolver holds keys in memory; RemoteJWKSResolver fetches them from an issuer's JWKS endpoint with a cache.
type ListByPrincipalRequest ¶
type ListByPrincipalRequest struct {
// TenantID, PrincipalType and Subject name the principal; all three are
// required (Validate).
TenantID uuid.UUID
PrincipalType PrincipalType
Subject string
// IncludeExpired and IncludeRevoked also list capabilities that have
// expired, or that are revoked themselves (not through an ancestor).
IncludeExpired bool
IncludeRevoked bool
// Cursor is the one the previous page returned; empty for the first.
Cursor string
// Limit is the page size; see PageLimit.
Limit int32
}
ListByPrincipalRequest is the input shape for Store.ListByPrincipal.
func (ListByPrincipalRequest) Validate ¶
func (r ListByPrincipalRequest) Validate() error
Validate reports whether the request names a principal. Every error matches ErrInvalidRequest.
type ListTenantBudgetsRequest ¶
type ListTenantBudgetsRequest struct {
ThresholdPct float64
UnlimitedOnly bool
ExcludeInactive bool
Limit int32
// Cursor is the one the previous page returned; empty for the first.
Cursor string
}
ListTenantBudgetsRequest is the input shape for UsageStore.ListTenantBudgets.
type MemoryReplayCache ¶
type MemoryReplayCache struct {
// contains filtered or unexported fields
}
MemoryReplayCache is a bounded in-process ReplayCache. A deployment with several replicas behind a balancer that does not pin a client to one replica can see a proof replayed on another replica within the window; keep the window short, use server nonces (DPoPVerifier.Nonces), or back ReplayCache with a shared store.
Every key gets a share of the cache of its own (WithEntriesPerKey), so the holder of one key-bound token cannot fill it with proofs and lock every other key out: past its share, only that key's proofs are refused.
func NewMemoryReplayCache ¶
func NewMemoryReplayCache(maxEntries int, opts ...ReplayCacheOption) *MemoryReplayCache
NewMemoryReplayCache bounds the cache at maxEntries (≤ 0: 100 000), and each key at DefaultReplayEntriesPerKey unless an option says otherwise.
func (*MemoryReplayCache) Seen ¶
Seen implements ReplayCache. Expired ids are dropped as they lapse, soonest first, so a full cache costs a lookup rather than a scan. A key past its share, or a cache full of live ids, refuses the new id as a replay rather than accept it unrecorded — a full cache errs towards refusing.
type Meter ¶
type Meter[TX any] interface { // Bump increments the request counter of the capability and of // each ancestor, and returns the capability's new count. When any // ceiling in the chain would be crossed it returns // ErrRequestLimitExceeded and nothing is mutated. Bump(ctx context.Context, req BumpRequest) (newCount int64, err error) // Charge adds req.Amount to the spend counters of the capability, of // each ancestor and of the tenant aggregate, writes one charges-ledger // row, and runs onCharged — ALL atomically. Checks, in order: // // 1. Each Biscuit copy's own budget (req.Copies), innermost first → // ErrBudgetExceeded. // 2. The capability's own ceiling (req.MaxBudget, from the verified // token) → ErrBudgetExceeded. // 3. Each ancestor's ceiling → ErrBudgetExceeded. // 4. The tenant aggregate ceiling → ErrTenantBudgetExceeded. // // Any rejection, or an error from onCharged, leaves every counter and // the ledger untouched. onCharged runs inside the charge, atomically // with it, and receives the consumer's transaction handle, so a side // effect such as an outbox write commits with the spend. Pass nil to // skip it. It must not call back into the Meter: an implementation may // hold its counters while onCharged runs, so a nested call can wait on // itself. // // The returned receipt carries the ledger row's ID; Refund takes it. // // With req.ExternalRef set, Charge is idempotent on (CapabilityID, // ExternalRef): a charge whose pair is already in the ledger moves // nothing, does not run onCharged, and returns that charge's receipt // with Replayed set. The pair names one cost: arriving again with a // different Amount, UnitCode or TenantID it returns // ErrExternalRefConflict and moves nothing. With req.Overrun = OverrunRecord, a charge that // would cross a ceiling is committed anyway and the receipt reports it // (ChargeReceipt.Overrun); see OverrunPolicy. Charge(ctx context.Context, req ChargeRequest, onCharged func(ctx context.Context, tx TX) error) (ChargeReceipt, error) // Refund returns spend from one recorded charge to every counter that // charge debited (capability, ancestors, tenant aggregate), and records // the refund against the charge. req.Amount = 0 refunds whatever // remains, which makes a full refund idempotent; a partial refund that // would take the total refunded past the charge returns // ErrRefundExceedsCharge and changes nothing. An unknown charge ID // returns ErrChargeNotFound. The receipt carries the amount actually // refunded. // // A charge counted in a tenant accounting period that has since been // closed (SetTenantBudget with ResetSpend) is refunded to the // capability, its ancestors and its copies, but not to the tenant // aggregate: the current period never counted it. // // For a cost only known afterwards, prefer Reserve/Settle: a // reservation lapses on its own if the caller never settles it, where // an estimate charged up front stays charged. Refund(ctx context.Context, req RefundRequest) (RefundReceipt, error) // Reserve holds req.Amount against the capability, each ancestor and // the tenant aggregate, checking every ceiling against spend plus // what is already held. Holds count against later charges and // reservations exactly as spend does, until the reservation is // settled, released or expires. Rejections return the Charge // sentinels and hold nothing. Reserve(ctx context.Context, req ReserveRequest) (Reservation, error) // Settle ends a reservation and charges the actual cost in its place, // atomically: the hold is released and req.Amount is charged as // Charge would, with onCharged run inside. The actual cost may be // below, equal to or above the hold; above it, the excess must fit // every ceiling, or Settle returns the Charge sentinel and the // reservation stays in place for the caller to settle lower or // release — unless req.Overrun is OverrunRecord, when the cost is // charged anyway and the receipt reports it. // // Settle is idempotent on the reservation: settling one already // settled moves nothing, does not run onCharged, and returns the // receipt of the charge that settled it, with Replayed set. An unknown, // released or expired reservation returns ErrReservationNotFound, and so // does the zero id, which names none — never a direct charge. Settle(ctx context.Context, req SettleRequest, onCharged func(ctx context.Context, tx TX) error) (ChargeReceipt, error) // GetCharge returns one charge of the ledger, by the ChargeID its // receipt carried — the id Refund takes. ErrChargeNotFound when there // is none. GetCharge(ctx context.Context, chargeID uuid.UUID) (ChargeRecord, error) // ChargeByRef returns the charge a capability took under externalRef // (ChargeRequest.ExternalRef), so a reporter can reconcile its own // records with the ledger. ErrChargeNotFound when there is none. ChargeByRef(ctx context.Context, capID uuid.UUID, externalRef string) (ChargeRecord, error) // GetReservation returns a reservation still holding budget — open, // or expired and not yet released, since until ReleaseExpired runs it // still counts (ExpiresAt tells which). ErrReservationNotFound once it // is settled or released. GetReservation(ctx context.Context, reservationID uuid.UUID) (Reservation, error) // ListReservations returns every reservation GetReservation would // return that was made against the capability itself — not its // delegated children — soonest to expire first. ListReservations(ctx context.Context, capID uuid.UUID) ([]Reservation, error) // Release ends a reservation without charging. Idempotent: releasing // one that is gone is a no-op. Release(ctx context.Context, reservationID uuid.UUID) error // GetUsage returns the current snapshot. Returns ErrUsageNotFound when no // row exists for the capability — typically means it's never been // used (no requests, no charges). For a capability with delegated // children the counters include the children's usage. GetUsage(ctx context.Context, capID uuid.UUID) (Usage, error) }
Meter enforces the MaxRequests and MaxBudgetAmount caveats and keeps the charges ledger. Separated from Store so the issuance / revocation surface stays focused on identity, not runtime state.
Delegation trees ¶
A capability's counters cover its whole subtree: Bump and Charge apply to the capability AND to every ancestor in its delegation chain, and each ancestor's own ceiling — read from its stored record, not from the caller — must admit the increment. So an orchestrator holding 25.00 that delegates 2.00 to each of a hundred workers can still spend 25.00 in total, not 200.00. Narrowing at delegation bounds each child; this bounds the sum. An ancestor whose record is gone (purged after expiry) no longer bounds anything — its children expired with it, since a child cannot outlive its parent.
Copies of a Biscuit ¶
A Biscuit's holder can give a copy limits of its own (CopyCeiling), which arrive as Copies on BumpRequest, ChargeRequest and ReserveRequest. Each is counted under its revocation id, apart from its siblings, and checked before the capability's ceilings; the capability's counters still take every copy's requests and spend. A charge or reservation records the copies it debited, so Refund, Settle, Release and ReleaseExpired return to them without being handed them again. A Meter that does not count copies must not be paired with a verifier that sets VerifierConfig.MeterCopies.
All methods are concurrency-safe. A rejected call mutates nothing, so a retry after rejection is safe.
type MeteringOption ¶ added in v0.3.0
type MeteringOption func(*meteringConfig)
MeteringOption configures WithMetering.
func WithMeterProvider ¶ added in v0.3.0
func WithMeterProvider(p metric.MeterProvider) MeteringOption
WithMeterProvider creates the instruments on p rather than on the global provider (otel.GetMeterProvider).
func WithTenantAttribute ¶ added in v0.3.0
func WithTenantAttribute() MeteringOption
WithTenantAttribute labels every measurement with tenant_id. Off by default: it multiplies each series by the number of tenants.
type Nanos ¶
type Nanos int64
Nanos is an amount of a currency or metering unit (see UnitCode) in billionths of the unit: 1.5 USD is 1_500_000_000. Every amount the module counts — a charge, a ceiling, a hold, a refund — is Nanos, so spend sums, compares and refunds exactly, however many charges it takes. Float64 appears only at the edges, for display (Float64) and for a caller whose prices arrive as floats (AmountFromFloat).
In JSON, and so in a capability token, an amount is a decimal number of units in a string ("max_budget": "1.5"), written and read without a float64 in between.
const MaxNanos Nanos = 999_999_999_999_999_999
MaxNanos is the largest amount the module counts: just under a billion units, which is what a column of numeric(18,9) holds. Bounding every amount and counter by it leaves the sum of any two inside int64.
func AmountFromFloat ¶
AmountFromFloat converts an amount held as a float64 to Nanos, rounding to the nearest nano, halves to even. It reads the float as the shortest decimal that identifies it — 0.1 as "0.1", the sum 0.1+0.2 as "0.30000000000000004" — so an amount written with at most nine decimals and fifteen significant digits converts exactly. It refuses what ValidateAmount refuses, and NaN and the infinities.
func MustParseAmount ¶
MustParseAmount is ParseAmount for an amount written into the program, a price or a test value: it panics on one ParseAmount refuses.
func ParseAmount ¶
ParseAmount reads a decimal amount of units — "0.35", "25", "1e-7" — into Nanos exactly. It refuses a negative amount, one finer than a nano and one beyond MaxNanos, rather than rounding any of them.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
a, _ := limes.ParseAmount("0.1")
b, _ := limes.ParseAmount("0.2")
fmt.Println(a+b, int64(a+b))
// Finer than a nano is refused rather than rounded.
_, err := limes.ParseAmount("0.0000000001")
fmt.Println(errors.Is(err, limes.ErrInvalidAmount))
}
Output: 0.3 300000000 true
func (Nanos) Float64 ¶
Float64 is n in units, the nearest float64. For display and metrics only: never count with it.
func (Nanos) MarshalJSON ¶
MarshalJSON writes n as a JSON string holding a decimal number of units, "1.5": a string, so that a reader whose numbers are float64 — JavaScript's JSON.parse among them — cannot round it.
func (Nanos) MarshalText ¶
MarshalText writes n as a decimal number of units, as String does.
func (Nanos) String ¶
String writes n as a decimal number of units with no trailing zeros: "1.5", "0.000000001", "25".
func (*Nanos) UnmarshalJSON ¶
UnmarshalJSON reads what MarshalJSON writes, exactly, as ParseAmount does: a JSON string, never a number.
func (*Nanos) UnmarshalText ¶
UnmarshalText reads a decimal number of units exactly, as ParseAmount does: configuration that carries an amount — a YAML scalar, an environment variable — reads it without a float64 in between.
type Op ¶
type Op string
Op is an operation an agent may perform. The package defines a small built-in set (below); a consumer adds its own as namespaced names of the form "<namespace>:<name>" — "tool:search", "mcp:github/create_issue" — which Op.Validate accepts. A consumer-defined operation is treated as state-changing unless the server declares otherwise (Effect). Fine-grained method gating can still layer admin-authored policy on top.
const ( OpGet Op = "get" // read one resource OpPut Op = "put" // write one resource OpList Op = "list" // list resources under a prefix OpDelete Op = "delete" // delete a resource OpPresign Op = "presign" // mint a pre-signed URL for a resource OpTag Op = "tag" // label a resource OpSearch Op = "search" // query an index OpEmbed Op = "embed" // compute embeddings OpManage Op = "manage" // administer: tenants, policy )
The built-in operations. Get, List, Search and Embed read; the rest may change state (Op.ResolveEffect).
func (Op) Mutating ¶
Mutating reports whether op may change state when no effect is declared for it (see Op.ResolveEffect). Built-in read operations are not mutating; every other operation is. OpPresign is read-only by itself — what a presigned URL may do is gated by the get/put assertion that accompanies it.
func (Op) ResolveEffect ¶
ResolveEffect returns the effect Check applies to op, given the effect the consumer declared for it. A built-in operation keeps its own effect, and a declaration may only repeat it; a consumer-defined operation takes the declaration, or EffectWrite when there is none.
type OverrunPolicy ¶
type OverrunPolicy uint8
OverrunPolicy is what a charge does when its amount would cross a ceiling — a Biscuit copy's, the capability's, an ancestor's or the tenant's.
const ( // OverrunReject refuses the charge with the ceiling's sentinel and // changes nothing. Use it for a cost not yet incurred: the refusal is // what stops it. OverrunReject OverrunPolicy = iota // OverrunRecord commits the charge past the ceiling and reports it in // ChargeReceipt.Overrun. Use it for a cost already incurred — reported // after the fact, out of the request path — where refusing would only // leave the ledger short of what was spent. The crossed ceiling then // refuses every later charge and reservation under OverrunReject. OverrunRecord )
func (OverrunPolicy) Validate ¶
func (p OverrunPolicy) Validate() error
Validate reports whether p is an OverrunPolicy this package defines. Implementations call it before touching state, so an unknown policy is never read as either one.
type Principal ¶
type Principal struct {
// Type discriminates the principal kind.
Type PrincipalType `json:"type"`
// TenantID is required for every principal — capability without
// tenant scope is malformed and the verifier rejects it.
TenantID uuid.UUID `json:"tenant_id"`
// Subject is the stable identity (user UUID, agent run ID, service
// account name). Format depends on Type.
Subject string `json:"subject"`
// Agent fields are populated only for Type == PrincipalAgent.
Agent *AgentPrincipal `json:"agent,omitzero"`
}
Principal identifies the subject the capability is issued to. Its json tags, and AgentPrincipal's, are the token's wire format (the limes_principal claim). Agent principals have a richer shape than service principals: they carry the run / tool-call lineage that audit and cost attribution need.
type PrincipalType ¶
type PrincipalType string
PrincipalType discriminates Principal.Type.
const ( PrincipalUser PrincipalType = "user" // a person PrincipalAgent PrincipalType = "agent" // an AI agent; Principal.Agent says which PrincipalService PrincipalType = "service" // a service account )
The principal kinds.
type Problem ¶ added in v0.3.0
type Problem struct {
// Type is a URI naming the kind of problem.
Type string `json:"type"`
// Title is a short, fixed summary of the kind.
Title string `json:"title"`
// Status repeats the HTTP status.
Status int `json:"status"`
// Detail explains this occurrence, in terms safe to show the client.
Detail string `json:"detail,omitempty"`
}
Problem is an RFC 9457 problem details object.
type Reason ¶
type Reason string
Reason is a stable, machine-readable name for why a presented capability was refused. The sentinels stay the way to branch in Go; a Reason is what a consumer puts on the wire, so that a client in any language tells "this capability is spent" from "this request was malformed" without reading a message. The values are never renamed; new ones may be added.
const ( ReasonInvalidSignature Reason = "invalid_signature" ReasonExpired Reason = "expired" ReasonNotYetValid Reason = "not_yet_valid" ReasonRevoked Reason = "revoked" ReasonAudienceMismatch Reason = "audience_mismatch" ReasonInvalidBiscuit Reason = "invalid_biscuit" ReasonProofRequired Reason = "proof_required" ReasonProofInvalid Reason = "proof_invalid" ReasonProofReplayed Reason = "proof_replayed" ReasonOpNotAllowed Reason = "op_not_allowed" ReasonResourceNotAllowed Reason = "resource_not_allowed" ReasonSourceNotAllowed Reason = "source_not_allowed" ReasonIdempotencyKeyRequired Reason = "idempotency_key_required" ReasonTaintedReadNotAllowed Reason = "tainted_read_not_allowed" ReasonRequestLimitExceeded Reason = "request_limit_exceeded" ReasonBudgetExceeded Reason = "budget_exceeded" ReasonTenantBudgetExceeded Reason = "tenant_budget_exceeded" )
The refusals a verifier, Caveats.Check or a Meter returns for a presented capability.
func ReasonOf ¶
ReasonOf returns the reason err refused a capability for, and false when err is no such refusal — nil, a store failure, or a programming error such as ErrInvalidRequest, which a consumer maps as it maps any other.
func Reasons ¶
func Reasons() []Reason
Reasons returns every reason this package defines, in a fixed order, so a consumer can prove its own mapping covers them all.
func (Reason) Permanent ¶
Permanent reports whether a refusal for r stands for as long as the capability does: the same request with the same capability will be refused again, whatever happens elsewhere, so a caller stops rather than retries.
A refusal that something else can lift is not permanent: a budget frees as holds lapse or charges are refunded, a tenant ceiling is raised by an operator, a taint flag is cleared, a not-yet-valid capability becomes valid, a proof is signed afresh, the verifier fetches its keys again, and an idempotency key can be added to the request.
type Record ¶
type Record struct {
Capability Capability
// IssuedBy is the principal Insert was given.
IssuedBy Principal
// Revocation is the capability's own revocation entry; nil when it has
// none. A capability stopped only through an ancestor has none — ask
// IsRevoked whether it verifies.
Revocation *Revocation
}
Record is a capability as the Store holds it.
type RefundReceipt ¶ added in v0.3.0
type RefundReceipt struct {
// ChargeID is the charge refunded from.
ChargeID uuid.UUID
// Refunded is the amount this call returned; zero when nothing was
// left to refund.
Refunded Nanos
// TenantID and UnitCode are the charge's.
TenantID uuid.UUID
UnitCode string
}
RefundReceipt is the result of Meter.Refund.
type RefundRequest ¶
type RefundRequest struct {
ChargeID uuid.UUID
// Amount to refund; 0 = everything not yet refunded from this charge.
Amount Nanos
}
RefundRequest is the input to Meter.Refund.
type RemoteJWKSConfig ¶
type RemoteJWKSConfig struct {
// URL of the issuer's JWKS document, e.g.
// https://issuer.example/.well-known/jwks.json.
URL string
// Client performs the fetch. Default: a client with a 10s timeout.
Client *http.Client
// RefreshInterval is how long a fetched key set is used before it is
// re-fetched, unless the response's Cache-Control says a shorter time
// (RFC 9111): its max-age, less its Age, never below
// MinRefreshInterval. Default 5m.
RefreshInterval time.Duration
// MinRefreshInterval rate-limits fetches. A token with an unknown kid
// triggers a refetch (that is how a newly published key is picked up),
// but at most once per this interval, so a stream of tokens with
// made-up kids cannot turn the verifier into a request amplifier
// against the issuer. Default 10s.
MinRefreshInterval time.Duration
// MaxStale is how long the last good key set keeps being served while
// fetches fail. Past it the resolver fails closed with
// ErrJWKSUnavailable. Default 1h — comfortably longer than the
// capability TTLs, shorter than a key's useful life after withdrawal.
MaxStale time.Duration
// MaxDocumentBytes caps the response body. Default 1 MiB.
MaxDocumentBytes int64
// Now is the clock; tests override.
Now func() time.Time
}
RemoteJWKSConfig configures a RemoteJWKSResolver. Only URL is required. The intervals must nest: MinRefreshInterval ≤ RefreshInterval ≤ MaxStale.
type RemoteJWKSResolver ¶
type RemoteJWKSResolver struct {
// contains filtered or unexported fields
}
RemoteJWKSResolver is a KeyResolver backed by an issuer's JWKS endpoint, for verifiers that run apart from the issuer. It caches the key set, revalidates with ETag, picks up a rotated-in key on first sight of its kid, and keeps serving the last good set through a bounded outage.
func NewRemoteJWKSResolver ¶
func NewRemoteJWKSResolver(cfg RemoteJWKSConfig) (*RemoteJWKSResolver, error)
NewRemoteJWKSResolver validates the config. It does not fetch: the first PublicKey call does, so constructing a verifier never blocks on the network.
type ReplayCache ¶
type ReplayCache interface {
// Seen records the proof id jti of the key with thumbprint jkt until
// expires, and reports whether it was already recorded. An
// implementation that cannot tell — its store is unreachable, or the
// key has used its share of the store — reports true: an unanswerable
// check refuses the proof.
Seen(ctx context.Context, jkt, jti string, expires time.Time) bool
}
ReplayCache remembers proof ids for as long as a proof could be accepted.
type ReplayCacheOption ¶ added in v0.5.0
type ReplayCacheOption func(*MemoryReplayCache)
ReplayCacheOption configures a MemoryReplayCache.
func WithEntriesPerKey ¶ added in v0.5.0
func WithEntriesPerKey(n int) ReplayCacheOption
WithEntriesPerKey bounds the live proof ids of one key; ≤ 0 keeps the default. It is never more than the whole cache.
type Reservation ¶
type Reservation struct {
ID uuid.UUID
CapabilityID uuid.UUID
TenantID uuid.UUID
// Amount is held in UnitCode.
Amount Nanos
UnitCode string
// Op and Actor are what Settle will stamp on the charge.
Op string
Actor string
// Copies are the Biscuit copies the hold is also on, innermost first,
// with the budget each is held against; a hold counts no requests, so
// their MaxRequests is zero.
Copies []CopyCeiling
ExpiresAt time.Time
}
Reservation is a committed hold, as Reserve made it and as GetReservation and ListReservations read it back.
type ReserveRequest ¶
type ReserveRequest struct {
CapabilityID uuid.UUID
// TenantID is the capability's tenant. Required.
TenantID uuid.UUID
// Amount to hold, in UnitCode. Within 0..MaxNanos (ValidateAmount).
Amount Nanos
// MaxBudget is the capability's own ceiling, from the verified token.
MaxBudget Nanos
UnitCode string
// TTL is how long the hold lasts if never settled or released.
// 0 = DefaultReservationTTL.
TTL time.Duration
// Op and Actor are carried onto the charge Settle records.
Op string
Actor string
// Copies are the presented Biscuit copy's own limits (Capability.Copies).
// The reservation keeps them, and Settle checks and charges them.
Copies []CopyCeiling
}
ReserveRequest is the input to Meter.Reserve.
type Revocation ¶
type Revocation struct {
RevokedAt time.Time
Reason string
Actor string
// Cascade reports that the entry was written by a cascading revoke —
// of this capability or of an ancestor.
Cascade bool
}
Revocation is one entry of the revocation list, as Revoke wrote it.
type RevocationLookup ¶
type RevocationLookup interface {
// IsRevoked reports whether id, or any ancestor of it, is revoked. An
// error is no answer: the verifier refuses rather than assume not.
IsRevoked(ctx context.Context, id uuid.UUID) (bool, error)
}
RevocationLookup is the seam the cache wraps. A persistent store implements it; tests substitute a stub. Like Store.IsRevoked, it answers for the capability's whole delegation chain.
type RevokeBiscuitRequest ¶
type RevokeBiscuitRequest struct {
// CapabilityID is the capability the Biscuit seals.
CapabilityID uuid.UUID
// RevocationID is the id to list: BiscuitCopy.RevocationID.
RevocationID []byte
// Reason and Actor are stored for audit, as on RevokeRequest.
Reason string
Actor string
}
RevokeBiscuitRequest is the input shape for BiscuitRevocationStore.RevokeBiscuit.
type RevokeRequest ¶
type RevokeRequest struct {
ID uuid.UUID
// Reason is an operator-supplied label ("compromise", "rotation",
// "policy-change"). Stored on the revocation row for audit.
Reason string
// Actor identifies who triggered the revocation (a user UUID or
// an automation principal). Stored on the row.
Actor string
// CascadeChildren records a revocation entry for every descendant as
// well. Descendants stop verifying regardless; see Store.Revoke.
CascadeChildren bool
}
RevokeRequest is the input shape for Store.Revoke.
type SetTenantBudgetRequest ¶
type SetTenantBudgetRequest struct {
TenantID uuid.UUID
MaxBudgetAmount Nanos
// UnitCode pins the currency. Empty = keep existing or default
// to DefaultUnitCode on first insert.
UnitCode string
// PeriodEnd, if non-nil, pins a closing time for the accounting
// window. Nil = open-ended (typical for "just set the cap, I'll
// roll later").
PeriodEnd *time.Time
// ResetSpend=true zeroes the spend and rolls PeriodStart to
// now. false leaves the counter alone — the cap changes mid-
// window.
ResetSpend bool
// ExpectedVersion is the optimistic-concurrency guard, checked
// atomically with the write. 0 asserts no ceiling has been set yet (the
// budget is missing, or holds only counted spend) and is itself a
// conflict if one has, so two operators editing one ceiling cannot
// overwrite each other unseen.
ExpectedVersion int64
}
SetTenantBudgetRequest is the input for UsageStore.SetTenantBudget.
type SettleRequest ¶
type SettleRequest struct {
ReservationID uuid.UUID
// Amount is the actual cost to charge. Within 0..MaxNanos (ValidateAmount).
Amount Nanos
// MaxBudget is the capability's own ceiling, from the verified token.
MaxBudget Nanos
// Overrun is what happens when Amount would cross a ceiling.
Overrun OverrunPolicy
}
SettleRequest is the input to Meter.Settle.
type Signer ¶
type Signer interface {
// KeyID is the JWKS key ID embedded in tokens this signer mints.
// Verifiers index their key set by it, and rotation works by
// running two signers in parallel until clients have caught up.
KeyID() string
// Sign emits a compact JWT for the supplied capability. The cap's
// IssuedAt / NotBefore / ExpiresAt must already be populated; Sign
// does not normalise times. ctx bounds the signing, which for a key
// held in a KMS or an HSM is a call over the network.
Sign(ctx context.Context, c Capability) (string, error)
}
Signer mints capability tokens with an Ed25519 key; verifiers pick up the public keys (statically or from a JWKS endpoint) and validate locally — no RPC per request. Implement it over a KMS to keep the private key out of process memory.
Signer is intentionally narrow: callers populate a Capability struct and ask Sign to encode + sign. The compact JWT format is the wire representation; Decode reads one back without the verifier path, for debugging tools, and VerifySignature checks its signature alone.
func NewEd25519Signer ¶
func NewEd25519Signer(keyID string, priv ed25519.PrivateKey) (Signer, error)
NewEd25519Signer constructs a signer from a key ID and Ed25519 private key. Caller is responsible for key generation / KMS interaction — this package is pure crypto + encoding.
type SigningKey ¶ added in v0.3.0
type SigningKey struct {
KeyID string
Public ed25519.PublicKey
Private ed25519.PrivateKey
// Generated reports that the key was made fresh rather than read from a
// file: it vanishes with the process, and every token signed with it
// stops verifying once it does.
Generated bool
}
SigningKey is an Ed25519 key and the kid it is published under.
func LoadOrGenerateKey ¶ added in v0.3.0
func LoadOrGenerateKey(path, kid string) (SigningKey, error)
LoadOrGenerateKey reads the key at path (LoadPrivateKey) or, when path is empty, generates an ephemeral one. kid names it; empty derives one (DeriveKeyID). A path that cannot be read is an error, never a reason to fall back to a fresh key. Log a generated key loudly: an operator who forgot to mount the file finds out on the first restart otherwise.
type StandardVerifier ¶
type StandardVerifier struct {
// contains filtered or unexported fields
}
StandardVerifier is the production verifier — signature first, then claims, then issuer / tenant / time / audience / revocation gates.
Verification is independent of the persistence layer apart from the revocation lookup, so the verifier scales horizontally without contention on the capability records.
func NewStandardVerifier ¶
func NewStandardVerifier(cfg VerifierConfig) (*StandardVerifier, error)
NewStandardVerifier validates wiring and builds the verifier.
func (*StandardVerifier) Authorize ¶ added in v0.3.0
func (v *StandardVerifier) Authorize(ctx context.Context, req AuthorizeRequest) (Capability, error)
Authorize implements Verifier.
Example ¶
package main
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/memstore"
)
// The issuer's name, and the audience every example's tokens are minted for.
const (
exampleIssuerName = "example-issuer"
exampleAudience = "my-service"
)
// exampleStack is an issuer and a verifier over one in-memory store — the
// setup ExampleIssuer_Issue shows in full.
type exampleStack struct {
issuer *limes.Issuer
verifier *limes.StandardVerifier
records *memstore.Store[struct{}]
usage *memstore.UsageStore[struct{}]
tenantID uuid.UUID
}
func newExampleStack() exampleStack {
kid, pub, priv, err := limes.GenerateEd25519Keypair()
if err != nil {
log.Fatal(err)
}
records := memstore.New[struct{}]()
signer, err := limes.NewEd25519Signer(kid, priv)
if err != nil {
log.Fatal(err)
}
issuer, err := limes.NewIssuer(limes.IssuerConfig{Signer: signer, Store: records, IssuerName: exampleIssuerName})
if err != nil {
log.Fatal(err)
}
verifier, err := limes.NewStandardVerifier(limes.VerifierConfig{
Keys: limes.NewStaticKeyResolver(map[string]ed25519.PublicKey{kid: pub}),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
AcceptBiscuit: true,
BiscuitRevocations: records,
DPoP: &limes.DPoPVerifier{Replay: limes.NewMemoryReplayCache(0)},
})
if err != nil {
log.Fatal(err)
}
return exampleStack{
issuer: issuer, verifier: verifier, records: records,
usage: memstore.NewUsage[struct{}](records), tenantID: uuid.New(),
}
}
// issue mints a capability for an agent: get and share under corpus/public/,
// 25.00 USD and 500 requests, for 15 minutes.
func (s exampleStack) issue() (limes.Capability, string) {
c, token, err := s.issuer.Issue(context.Background(), limes.IssueRequest{
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalAgent, TenantID: s.tenantID, Subject: "research-orchestrator",
},
Audience: []string{exampleAudience},
TTL: 15 * time.Minute,
Caveats: limes.Caveats{
Ops: []limes.Op{limes.OpGet, limes.OpShare},
ResourcePrefixes: []string{"corpus/public/"},
MaxRequests: 500,
MaxBudgetAmount: limes.MustParseAmount("25.00"),
UnitCode: "USD",
},
})
if err != nil {
log.Fatal(err)
}
return c, token
}
func main() {
ctx := context.Background()
s := newExampleStack()
_, token := s.issue()
// One call: the token, then every caveat that needs no state.
authorize := func(op limes.Op, resource string) error {
_, err := s.verifier.Authorize(ctx, limes.AuthorizeRequest{
VerifyRequest: limes.VerifyRequest{Token: token, Audience: exampleAudience},
CheckRequest: limes.CheckRequest{Op: op, Resource: resource},
})
return err
}
fmt.Println(authorize(limes.OpGet, "corpus/public/report.pdf"))
fmt.Println(errors.Is(authorize(limes.OpDelete, "corpus/public/report.pdf"), limes.ErrOpNotAllowed))
fmt.Println(errors.Is(authorize(limes.OpGet, "corpus/private/x"), limes.ErrResourceNotAllowed))
}
Output: <nil> true true
func (*StandardVerifier) BiscuitCopy ¶
func (v *StandardVerifier) BiscuitCopy(ctx context.Context, token string) (BiscuitCopy, error)
BiscuitCopy reads which copy token is, after checking it as Verify does — the sealed token's signature, issuer and tenant, then the Biscuit's signature chain — so that a revocation is only ever recorded against a capability the token genuinely carries. Expiry, audience and revocation are not checked: a copy can be revoked whatever its state. Nor is MeterCopies: a copy carrying limits is named, and can be revoked, on any verifier.
func (*StandardVerifier) DPoPNonce ¶ added in v0.3.0
func (v *StandardVerifier) DPoPNonce(ctx context.Context) (string, error)
DPoPNonce is the nonce to send in the DPoP-Nonce header — with a response refused by ErrDPoPNonceRequired, and with any other for the client's next proof. Empty when the verifier issues none.
func (*StandardVerifier) ErrorResponse ¶ added in v0.3.0
func (v *StandardVerifier) ErrorResponse(ctx context.Context, err error, scheme AuthScheme) ErrorResponse
ErrorResponse is how to answer a request refused with err, presented under scheme. A refusal about the token is challenged under the scheme the client used (Bearer when it named none); one about the proof, under DPoP. A verifier with DPoP configured also advertises its algorithms in a DPoP challenge, and hands out a fresh nonce with use_dpop_nonce.
func (*StandardVerifier) Verify ¶
func (v *StandardVerifier) Verify(ctx context.Context, req VerifyRequest) (Capability, error)
Verify implements Verifier. Returns the typed Capability on success. Errors return one of the package's typed sentinels so the caller can branch on the failure mode.
Order matters: nothing in the claims is read until the signature over them has verified, so a forged token costs a header decode and one signature check, and no claim-parsing path is reachable by an attacker. The checks that need no I/O come before the revocation lookup.
type StaticKeyResolver ¶
type StaticKeyResolver struct {
// contains filtered or unexported fields
}
StaticKeyResolver is the in-memory implementation used in tests and single-node deploys. Maps kid → public key; concurrency-safe via a sync.RWMutex so rotation can swap a kid without restarting callers.
func NewStaticKeyResolver ¶
func NewStaticKeyResolver(keys map[string]ed25519.PublicKey) *StaticKeyResolver
NewStaticKeyResolver builds a resolver from a kid → public key map. The map is copied to defend against caller mutation.
func (*StaticKeyResolver) Keys ¶
func (r *StaticKeyResolver) Keys() map[string]ed25519.PublicKey
Keys returns a snapshot of the current key set, e.g. for MarshalJWKS.
func (*StaticKeyResolver) RemoveKey ¶
func (r *StaticKeyResolver) RemoveKey(kid string)
RemoveKey withdraws a kid. Rotation step 3: call it only once the longest outstanding TTL signed under that key has elapsed.
type Store ¶
type Store interface {
// Insert records a capability at issuance. The persisted row holds
// the full claim set so admin tooling can show a capability without
// parsing the JWT, and so audit can correlate without
// keeping every issued token.
//
// issuedBy is the principal that ASKED for the capability, which is not
// c.Subject (the principal it authorises) and not c.Issuer (the service
// that minted it). A delegated capability is requested by its parent's
// holder; a root one by an operator. Passed as an argument rather than
// carried on Capability because it is issuance metadata, not a claim —
// putting it in the struct would change the token's frozen wire format.
//
// An id already on record returns ErrAlreadyExists and changes nothing.
// A store that knows tenants returns ErrUnknownTenant or
// ErrTenantDeleted for a tenant it cannot mint for.
Insert(ctx context.Context, c Capability, issuedBy Principal) error
// Get fetches a capability record by ID, for audit and admin reads and
// for a reporter that needs a capability's ceilings (see Meter).
// Returns ErrNotFound when no row exists. StandardVerifier never calls
// it: verification needs no record, only IsRevoked.
//
// ErrNotFound is defined in THIS package (below), not in any store
// implementation — a third party writing its own Store must have a
// sentinel to return without importing someone else's persistence.
Get(ctx context.Context, id uuid.UUID) (Capability, error)
// GetRecord reads back everything Insert and Revoke wrote about a
// capability: the capability, who issued it, and its own revocation
// entry if it has one. ErrNotFound as Get. For audit and admin reads.
GetRecord(ctx context.Context, id uuid.UUID) (Record, error)
// IsRevoked reports whether the capability, or any ancestor of it
// that is still on record, is in the revocation list. Answering for
// the chain is what makes revoking an orchestrator stop the workers it
// spawned. Verifiers gate every request on it, so implementations
// should make the walk a single round trip (a recursive query) and
// bound its depth. An id not on record is not revoked.
IsRevoked(ctx context.Context, id uuid.UUID) (bool, error)
// Revoke adds an entry to the revocation list. Idempotent: a
// duplicate revoke is a no-op. An id not on record — or not visible
// to the caller — returns ErrNotFound and writes nothing, so a caller
// is never told "revoked" about a capability nothing stopped.
//
// Every descendant stops verifying either way, because IsRevoked
// walks the chain. CascadeChildren=true additionally writes a
// revocation entry for each descendant, so the audit trail names
// every capability that was stopped, not only the one an operator
// picked.
Revoke(ctx context.Context, req RevokeRequest) error
// PurgeExpired drops the revocation entries — of capabilities and of
// Biscuit copies — of every capability expired for at least
// expiredFor, and returns how many it dropped. The capability records
// stay: an expired token fails verification on its expiry alone, so
// its revocation entry no longer stops anything. Run by the
// housekeeping worker so the revocation lists stay bounded.
PurgeExpired(ctx context.Context, expiredFor time.Duration) (int64, error)
// ListByPrincipal lists the capabilities issued to one principal, in
// ascending id order, a page at a time: it returns at most Limit of
// them and a cursor that is empty on the last page and otherwise
// fetches the next one. The cursor is opaque.
ListByPrincipal(ctx context.Context, req ListByPrincipalRequest) ([]Capability, string, error)
}
Store persists capability records and revocation entries. Consumers supply the implementation; memstore ships an in-memory one, and a store over a database is the consumer's own.
Records are append-mostly: capabilities aren't mutated after issuance except via Revoke. Revocations are checked by the verifier on every request; put a CachedRevocationChecker in front to keep the per-call cost flat.
type TenantBudget ¶
type TenantBudget struct {
TenantID uuid.UUID
MaxBudgetAmount Nanos
SpentAmount Nanos
// ReservedAmount is held by the tenant's open reservations.
ReservedAmount Nanos
UnitCode string
PeriodStart time.Time
PeriodEnd *time.Time
UpdatedAt time.Time
// ResourceVersion is the OCC token a caller passes back to Set. Zero
// means no ceiling has ever been set — the row, if there is one, holds
// only what Charge and Reserve counted.
ResourceVersion int64
}
TenantBudget is the snapshot view of the tenant aggregate cap. Amounts are Nanos, exact; a store keeps them exactly too (an integer or decimal column), so that sums do not drift.
type TenantBudgetCursor ¶
TenantBudgetCursor is a place in ListTenantBudgets' order: the last row's utilisation, in the decimal text its store computed it in, and tenant id.
func DecodeTenantBudgetCursor ¶
func DecodeTenantBudgetCursor(s string) (TenantBudgetCursor, error)
DecodeTenantBudgetCursor reads a cursor Encode wrote. Anything else is an ErrInvalidRequest.
func (TenantBudgetCursor) Encode ¶
func (c TenantBudgetCursor) Encode() string
Encode is the cursor as ListTenantBudgets hands it out: opaque, URL-safe.
type TenantBudgetLister ¶ added in v0.2.0
type TenantBudgetLister interface {
// ListTenantBudgets returns one page and the cursor of the next,
// empty on the last page (TenantBudgets.ListTenantBudgets).
ListTenantBudgets(ctx context.Context, req ListTenantBudgetsRequest) ([]TenantBudgetSummary, string, error)
}
TenantBudgetLister lists tenant budgets a page at a time. Every TenantBudgets is one.
type TenantBudgetSummary ¶
type TenantBudgetSummary struct {
TenantID uuid.UUID
Slug string
DisplayName string
Budget TenantBudget
// UtilisationPct = spent / max × 100, clamped to [0, 100]. 0
// when MaxBudgetAmount == 0 (unlimited/metering-only).
UtilisationPct float64
}
TenantBudgetSummary is a TenantBudget with the consumer's display fields (empty when the consumer keeps none). Returned by ListTenantBudgets.
type TenantBudgets ¶
type TenantBudgets interface {
// GetTenantBudget returns the tenant-aggregate snapshot. Returns
// ErrTenantBudgetNotFound when no row exists (tenant has no cap
// configured AND has never been charged).
//
// The aggregate counts from the tenant's first charge or reservation,
// whether or not a ceiling has been set: Charge and Reserve create the
// row, with no ceiling and ResourceVersion 0, when it is missing. A
// ceiling set afterwards is measured against that spend and those holds.
GetTenantBudget(ctx context.Context, tenantID uuid.UUID) (TenantBudget, error)
// SetTenantBudget upserts the tenant cap. ResetSpend=true rolls
// the accounting period and zeroes the spend — operators call
// this on each billing close. ResetSpend=false adjusts the cap
// mid-cycle without affecting accumulated spend. A ceiling outside
// 0..MaxNanos returns ErrInvalidAmount and changes nothing. A UnitCode
// other than the row's returns ErrUnitCodeMismatch while anything is
// held in the old unit, or spent in it and not reset.
SetTenantBudget(ctx context.Context, req SetTenantBudgetRequest) (TenantBudget, error)
// ListTenantBudgets returns tenant budget rows, with the consumer's
// display fields where it has them. Filters:
// - ExcludeInactive: skip tenants the consumer considers inactive.
// - ThresholdPct > 0: include only rows where
// spent / max * 100 >= ThresholdPct (and max > 0).
// - UnlimitedOnly: include only rows where max == 0.
// - Limit: the page size, as PageLimit reads it.
//
// Rows come most at risk first: by spent / max × 100 descending —
// unclamped, so of two tenants past their ceilings the further one
// leads — then by tenant id. A page ends with a cursor that fetches
// the next one, empty on the last page. Spend moves between pages, so
// a tenant may show on two of them, or on none; a cursor is a place in
// the order, not a snapshot.
ListTenantBudgets(ctx context.Context, req ListTenantBudgetsRequest) ([]TenantBudgetSummary, string, error)
}
TenantBudgets administers the tenant-aggregate ceilings Charge enforces.
type Usage ¶
type Usage struct {
CapabilityID uuid.UUID
RequestCount int64
SpentAmount Nanos
// ReservedAmount is held by open reservations (its subtree's, for a
// capability with delegated children).
ReservedAmount Nanos
UnitCode string
}
Usage is the snapshot view of a capability's runtime counters.
type UsageHousekeeping ¶
type UsageHousekeeping interface {
// Delete drops the row. Called when the capability is reaped. It
// releases the capability's open reservations first, returning their
// holds to its ancestors and its tenant: nothing could settle or release
// them afterwards.
Delete(ctx context.Context, capID uuid.UUID) error
// PurgeOrphans deletes usage rows whose capability record no longer
// exists, releasing their reservations as Delete does. Implementations may bound the work per call; the caller
// loops until it returns 0.
PurgeOrphans(ctx context.Context) (int64, error)
// ReleaseExpired releases every reservation past its expiry and
// returns how many it released. Run on a timer: until it runs, an
// expired hold keeps counting against the ceilings, which errs towards
// refusing spend, never towards allowing too much.
ReleaseExpired(ctx context.Context) (int64, error)
}
UsageHousekeeping reclaims usage rows of capabilities that are gone.
type UsageStore ¶
type UsageStore[TX any] interface { Meter[TX] TenantBudgets UsageHousekeeping }
UsageStore is everything a consumer implements for runtime accounting. It is the union of three narrower contracts so that code which only meters (an interceptor, a handler) can depend on Meter alone, and code which only administers tenant ceilings can depend on TenantBudgets alone.
func WithMetering ¶
func WithMetering[TX any](inner UsageStore[TX], opts ...MeteringOption) (UsageStore[TX], error)
WithMetering wraps a UsageStore in the metering decorator. Its instruments are created on the global MeterProvider unless WithMeterProvider names another, and carry no tenant unless WithTenantAttribute asks for it.
The result implements CopyUsageReader exactly when inner does, so wrapping a store never hides what it can read.
type Verifier ¶
type Verifier interface {
// Verify checks the token itself: its signature, issuer and tenant, its
// validity window, its audience, the proof of possession a key-bound
// capability needs, and revocation. It returns the capability the token
// grants.
Verify(ctx context.Context, req VerifyRequest) (Capability, error)
// Authorize is Verify followed by every caveat that can be checked
// without state: the operation, the resource, the idempotency key, the
// tainted read (Caveats.Check), and the client's address
// (Caveats.CheckSource). The request and budget limits are stateful:
// the Meter enforces them, through Bump and Charge, inside the
// consumer's own transaction.
Authorize(ctx context.Context, req AuthorizeRequest) (Capability, error)
}
Verifier validates tokens. A deployment typically resolves keys through a cached JWKS (RemoteJWKSResolver) and checks revocation through a short-TTL cache (CachedRevocationChecker), so revocation propagates quickly without hammering the store.
type VerifierConfig ¶
type VerifierConfig struct {
// Keys resolves JWKS kids to Ed25519 public keys.
Keys KeyResolver
// Revocations answers whether a capability — or any ancestor in its
// delegation chain — is revoked. Wrap the store in a
// CachedRevocationChecker to keep the per-call cost flat.
Revocations RevocationLookup
// TrustedIssuers is the set of issuer values the verifier accepts.
// Tokens whose `iss` claim is not in the set are rejected with
// ErrInvalidSignature.
TrustedIssuers []string
// KeyIssuers, when non-nil, binds each kid to the one issuer allowed
// to sign with it: a token whose kid is absent from the map, or whose
// `iss` differs from the kid's binding, is rejected. Without it any
// trusted key may sign for any trusted issuer, which is fine for one
// issuer and wrong for several that do not share a trust boundary.
KeyIssuers map[string]string
// Now is the time function the verifier uses for nbf/exp. Tests
// override it; production passes time.Now.
Now func() time.Time
// Leeway widens the nbf / exp window to absorb clock skew between
// issuer and verifier. Default 30s when zero.
Leeway time.Duration
// MaxTokenBytes rejects longer tokens before decoding them. Default
// DefaultMaxTokenBytes when zero.
MaxTokenBytes int
// AcceptBiscuit accepts the Biscuit form (Issuer.Biscuit, Attenuate)
// beside the JWT. Off, a Biscuit is refused like any malformed token.
AcceptBiscuit bool
// MeterCopies admits Biscuits whose attenuation blocks set a copy's own
// request or budget limits (Attenuation.MaxRequests, MaxBudget).
// Set it only when the Meter that enforces caveats counts
// BumpRequest.Copies and the like; off, such a token is refused with
// ErrCopyCountersNotMetered rather than accepted with its limits unkept.
MeterCopies bool
// BiscuitRevocations answers whether a copy of a Biscuit is revoked on
// its own. Required when AcceptBiscuit is set; wrap the store in a
// CachedBiscuitRevocationChecker.
BiscuitRevocations BiscuitRevocationLookup
// DPoP checks the proof of possession a key-bound capability (one with
// a ConfirmationJKT) must come with. Without it the verifier refuses
// every key-bound capability with ErrDPoPRequired, rather than accept
// one as a bearer token.
DPoP *DPoPVerifier
}
VerifierConfig wires the verifier. Keys, Revocations and TrustedIssuers are required.
type VerifyRequest ¶ added in v0.3.0
type VerifyRequest struct {
// Token is the capability token, a JWT or a Biscuit.
Token string
// Audience is the service the call is hitting: the token's audience
// must include it.
Audience string
// DPoP is the request's proof of possession, needed when the
// capability is key-bound and ignored when it is not.
DPoP DPoPProof
// Scheme is the Authorization scheme the token came under
// (ParseAuthorization). Set, it must match the binding: DPoP for a
// key-bound capability, Bearer for any other.
Scheme AuthScheme
}
VerifyRequest is a token as it arrived, and what it arrived for.
Source Files
¶
- authorization.go
- biscuit.go
- biscuit_revocation.go
- cache.go
- cachecontrol.go
- challenge.go
- check.go
- delegate.go
- doc.go
- dpop.go
- dpop_nonce.go
- effect.go
- issuer.go
- jwks.go
- jwks_remote.go
- keyloader.go
- metering_store.go
- metrics.go
- nanos.go
- pages.go
- reason.go
- resource.go
- signer.go
- store.go
- types.go
- uri.go
- usage.go
- validate.go
- verifier.go
Directories
¶
| Path | Synopsis |
|---|---|
|
biscuit
module
|
|
|
Command example is the quickstart walkthrough as a runnable program: issue → verify → delegate → charge → revoke, against the in-memory reference store.
|
Command example is the quickstart walkthrough as a runnable program: issue → verify → delegate → charge → revoke, against the in-memory reference store. |
|
exchange
module
|
|
|
Package memstore is an in-memory reference implementation of the limes storage contracts.
|
Package memstore is an in-memory reference implementation of the limes storage contracts. |
|
Package metertest checks a limes.UsageStore against the module's contract — its Meter, tenant budgets and housekeeping — the way testing/fstest checks an fs.FS.
|
Package metertest checks a limes.UsageStore against the module's contract — its Meter, tenant budgets and housekeeping — the way testing/fstest checks an fs.FS. |
|
otelmetric
module
|
|
|
pgstore
module
|
|
|
redisreplay
module
|
|
|
Package storetest checks a limes.Store — and, when it is one, a limes.BiscuitRevocationStore — against the module's contract, the way testing/fstest checks an fs.FS.
|
Package storetest checks a limes.Store — and, when it is one, a limes.BiscuitRevocationStore — against the module's contract, the way testing/fstest checks an fs.FS. |