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 (github.com/oleg-tkachuk/limes/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 an operator: an orchestrator narrows its own authority for each sub-agent it spawns, through the Issuer with no admin API in the path, or offline as a Biscuit block with no call at all.
Wire format is JWT compact serialization with Ed25519 signatures (`alg: Ed25519`, RFC 9864), or a Biscuit v3 sealing such a token; docs/standards.md lists every standard followed. Verification is local: the signature is checked against a resolved public key, and the store reads, whether the capability is revoked and, for a Biscuit, whether its copy is, go through one short-lived cache (RevocationCache), so consumers scale horizontally without contending on a shared store.
Packages ¶
Two standards the verifier relies on are packages of their own, usable without the rest: github.com/oleg-tkachuk/limes/jwks for key sets (RFC 7517) and github.com/oleg-tkachuk/limes/dpop for proof of possession (RFC 9449). github.com/oleg-tkachuk/limes/meter counts requests and spend against a capability's ceilings and keeps the charges ledger; a consumer that only verifies tokens never imports it. github.com/oleg-tkachuk/limes/inspect reads a token without verifying it, for tooling. memstore, storetest and metertest serve stores; dpop/dpoptest and jwks/jwkstest check a replay cache, a nonce source and a key resolver. Modules of their own carry what the root module does not resolve: github.com/oleg-tkachuk/limes/pgstore, every store contract over PostgreSQL; github.com/oleg-tkachuk/limes/redisreplay, a replay cache over Redis; github.com/oleg-tkachuk/limes/otelmetric, OpenTelemetry instruments; github.com/oleg-tkachuk/limes/exchange, OAuth 2.0 token exchange (RFC 8693); and github.com/oleg-tkachuk/limes/biscuit, the Biscuit form.
What you implement ¶
Three contracts are required; a consumer that implements them needs nothing else:
- Store — capability records and revocations; an Issuer needs only its IssuanceStore half. IsRevoked answers for the capability's whole delegation chain, at most MaxDelegationDepth ancestors deep. No transactions required; an in-memory map is a valid implementation. A store that scopes calls to a tenant refuses one whose request names another with ErrScopeMismatch. A store paired with a meter.TenantUsageEraser refuses Insert for a tenant whose usage was erased with ErrTenantErased.
- github.com/oleg-tkachuk/limes/meter.UsageStore — request and spend counters and the charges ledger, for a consumer that meters; see package meter.
- KeyResolver — public verification keys. jwks.Static ships as a working implementation and supports rotation via SetKey / RemoveKey; jwks.Remote fetches an issuer's JWKS for verifiers that run apart from it.
More are needed only with the feature they serve: RevocationLineageLookup for a RevocationCache to drop only what a revocation outdates, BiscuitRevocationLookup to accept Biscuit tokens and BiscuitRevocationStore to revoke one copy of one, RecordCensus to count capability records by disposition across tenants, RecordLister to list a tenant's records with their dispositions and RecordListingReader to read one with its disposition — RecordReporter is both, and the Census that memstore.NewCensus and pgstore.NewCensus return serves all three — BulkRevoker to revoke by user, client or identity-provider session, ExchangeStore to exchange each subject token for one capability, TenantRecordEraser to remove a tenant, dpop.ReplayCache to share DPoP replay state across replicas (dpop.MemoryReplayCache ships for one process), and dpop.Nonces to require server nonces (dpop.HMACNonces ships, and works across replicas holding the same keys).
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 (BudgetCeiling.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.GetRecord a capability, meter.MeterReader.GetUsage the counters — so one type may implement Store and meter.UsageStore together, or each apart as memstore does.
Guarantees ¶
Rejections are matchable sentinels, so a consumer can map each to its own transport status, or let Verifier.ErrorResponse map it to HTTP. Every error the package returns has a stable Code (CodeOf), and ProblemOf builds its RFC 9457 body for an API that is not a protected resource; never match an error's text. A charge rejected by any ceiling — a Biscuit copy's, the capability's, an ancestor's, the tenant's — leaves every counter and the ledger unmutated, so retrying after a rejection is safe. Delegation needs OpShare on the parent and 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 examples/quickstart for it as a runnable program.
Example (BackdatedCharges) ¶
A test of a report needs charges at chosen times. Build the stores on a clock the test sets, record a capability that outlives the period, and move the clock forward before each charge: a charge carries the store's time, so it lands where the clock says, and no test writes a store's tables.
package main
import (
"context"
"fmt"
"log"
"time"
"uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/memstore"
"github.com/oleg-tkachuk/limes/meter"
)
func main() {
ctx := context.Background()
start := time.Date(2026, time.March, 2, 9, 0, 0, 0, time.UTC) // a Monday
at := start
clock := func() time.Time { return at }
records, err := memstore.NewStore(memstore.StoreConfig{Now: clock})
if err != nil {
log.Fatal(err)
}
usage, err := memstore.NewUsageStore(memstore.UsageStoreConfig{Records: records, Now: clock})
if err != nil {
log.Fatal(err)
}
ledger := memstore.NewLedger(usage)
// The meter does not read expiry, but a capability that expired before
// a charge's time would make a report the test cannot reproduce live.
const period = 14 * 24 * time.Hour
c := limes.Capability{
ID: uuid.New(), IssuedAt: start, ExpiresAt: start.Add(period),
Subject: limes.Principal{Type: limes.PrincipalTypeService, TenantID: uuid.New(), Subject: "svc:reporting"},
Caveats: limes.Caveats{Requests: limes.RequestCeiling{Unlimited: true}, Budget: limes.BudgetCeiling{Unlimited: true, UnitCode: "USD"}},
}
if err := records.Insert(ctx, limes.InsertRequest{Capability: c}); err != nil {
log.Fatal(err)
}
subject, err := limes.StoredMeterSubject(ctx, records, c.ID)
if err != nil {
log.Fatal(err)
}
for _, day := range []int{0, 1, 8} { // Monday, Tuesday, and Tuesday a week on
at = start.AddDate(0, 0, day)
if _, err := usage.Charge(ctx, meter.ChargeRequest{Subject: subject, Amount: limes.MustParseAmount("1.5")}, nil); err != nil {
log.Fatal(err)
}
}
page, err := ledger.SumCharges(ctx, meter.SumChargesRequest{
TenantID: c.Subject.TenantID, From: start, To: start.Add(period), Bucket: meter.ChargeBucketWeek,
})
if err != nil {
log.Fatal(err)
}
for _, sum := range page.Items {
fmt.Println(sum.BucketStart.Format(time.DateOnly), sum.Charged, sum.Charges)
}
}
Output: 2026-03-02 3 2 2026-03-09 1.5 1
Example (Charge) ¶
package main
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"log"
"time"
"uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/dpop"
"github.com/oleg-tkachuk/limes/jwks"
"github.com/oleg-tkachuk/limes/memstore"
"github.com/oleg-tkachuk/limes/meter"
)
// 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.Verifier
records *memstore.Store
usage *memstore.UsageStore
tenantID uuid.UUID
}
// exampleClientID is the OAuth client the examples' capabilities are granted
// to (RFC 9068 client_id): the software the agent runs as.
const exampleClientID = "example-agent"
func newExampleStack() exampleStack {
key, err := limes.GenerateKey()
kid, pub, priv := key.KeyID, key.Public(), key.Private
if err != nil {
log.Fatal(err)
}
records, err := memstore.NewStore(memstore.StoreConfig{})
if err != nil {
log.Fatal(err)
}
usage, err := memstore.NewUsageStore(memstore.UsageStoreConfig{Records: records})
if err != nil {
log.Fatal(err)
}
signer, err := limes.NewSigner(limes.SignerConfig{KeyID: kid, Key: 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)
}
replay, err := dpop.NewMemoryReplayCache(dpop.MemoryReplayCacheConfig{})
if err != nil {
log.Fatal(err)
}
proofs, err := dpop.NewVerifier(dpop.VerifierConfig{Replay: replay})
if err != nil {
log.Fatal(err)
}
verifier, err := limes.NewVerifier(limes.VerifierConfig{
Keys: exampleKeys(kid, pub),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
DPoP: proofs,
})
if err != nil {
log.Fatal(err)
}
return exampleStack{
issuer: issuer, verifier: verifier, records: records,
usage: usage, tenantID: uuid.New(),
}
}
// verified is the capability token grants, as the verifier accepts it.
func (s exampleStack) verified(token string) limes.VerifiedCapability {
vc, err := s.verifier.Verify(context.Background(), limes.VerifyRequest{Token: token, Audience: exampleAudience})
if err != nil {
log.Fatal(err)
}
return vc
}
// 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) {
cResult, err := s.issuer.Issue(context.Background(), limes.IssueRequest{
ClientID: exampleClientID,
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalTypeAgent, 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/"},
Requests: limes.RequestCeiling{Max: 500},
Budget: limes.BudgetCeiling{Max: limes.MustParseAmount("25.00"), UnitCode: "USD"},
},
})
c, token := cResult.Capability, cResult.Token
if err != nil {
log.Fatal(err)
}
return c, token
}
func main() {
ctx := context.Background()
s := newExampleStack()
_, token := s.issue()
// The ceilings come from what the verifier accepted, never from the caller.
subject := s.verified(token).MeterSubject()
charge := func(amount string) (meter.ChargeReceipt, error) {
return s.usage.Charge(ctx, meter.ChargeRequest{
Subject: subject, Amount: limes.MustParseAmount(amount), Op: "search",
}, nil)
}
receipt, err := charge("0.35")
fmt.Println(err, receipt.SpentAmount)
// Over the ceiling: refused, and no counter moves.
_, err = charge("999")
fmt.Println(errors.Is(err, limes.ErrBudgetExceeded))
refund, err := s.usage.Refund(ctx, meter.RefundRequest{ChargeID: receipt.ChargeID, Remaining: true})
fmt.Println(err, refund.Amount)
}
// exampleKeys is a jwks.Static holding pub under kid.
func exampleKeys(kid string, pub ed25519.PublicKey) *jwks.Static {
keys, err := jwks.NewStatic(jwks.StaticConfig{Keys: map[string]ed25519.PublicKey{kid: pub}})
if err != nil {
log.Fatal(err)
}
return keys
}
Output: <nil> 0.35 true <nil> 0.35
Example (ProofOfPossession) ¶
package main
import (
"context"
"crypto/ed25519"
"crypto/rand"
"errors"
"fmt"
"log"
"time"
"uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/dpop"
"github.com/oleg-tkachuk/limes/jwks"
"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.Verifier
records *memstore.Store
usage *memstore.UsageStore
tenantID uuid.UUID
}
// exampleClientID is the OAuth client the examples' capabilities are granted
// to (RFC 9068 client_id): the software the agent runs as.
const exampleClientID = "example-agent"
func newExampleStack() exampleStack {
key, err := limes.GenerateKey()
kid, pub, priv := key.KeyID, key.Public(), key.Private
if err != nil {
log.Fatal(err)
}
records, err := memstore.NewStore(memstore.StoreConfig{})
if err != nil {
log.Fatal(err)
}
usage, err := memstore.NewUsageStore(memstore.UsageStoreConfig{Records: records})
if err != nil {
log.Fatal(err)
}
signer, err := limes.NewSigner(limes.SignerConfig{KeyID: kid, Key: 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)
}
replay, err := dpop.NewMemoryReplayCache(dpop.MemoryReplayCacheConfig{})
if err != nil {
log.Fatal(err)
}
proofs, err := dpop.NewVerifier(dpop.VerifierConfig{Replay: replay})
if err != nil {
log.Fatal(err)
}
verifier, err := limes.NewVerifier(limes.VerifierConfig{
Keys: exampleKeys(kid, pub),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
DPoP: proofs,
})
if err != nil {
log.Fatal(err)
}
return exampleStack{
issuer: issuer, verifier: verifier, records: records,
usage: usage, tenantID: uuid.New(),
}
}
// verified is the capability token grants, as the verifier accepts it.
func (s exampleStack) verified(token string) limes.VerifiedCapability {
vc, err := s.verifier.Verify(context.Background(), limes.VerifyRequest{Token: token, Audience: exampleAudience})
if err != nil {
log.Fatal(err)
}
return vc
}
// 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) {
cResult, err := s.issuer.Issue(context.Background(), limes.IssueRequest{
ClientID: exampleClientID,
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalTypeAgent, 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/"},
Requests: limes.RequestCeiling{Max: 500},
Budget: limes.BudgetCeiling{Max: limes.MustParseAmount("25.00"), UnitCode: "USD"},
},
})
c, token := cResult.Capability, cResult.Token
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, _ := dpop.Thumbprint(agentKey.Public())
tokenResult, err := s.issuer.Issue(ctx, limes.IssueRequest{
ClientID: exampleClientID,
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{Type: limes.PrincipalTypeAgent, TenantID: s.tenantID, Subject: "worker"},
Audience: []string{exampleAudience}, TTL: 15 * time.Minute,
Caveats: limes.Caveats{Requests: limes.RequestCeiling{Unlimited: true}, Ops: []limes.Op{limes.OpGet}},
ConfirmationJKT: jkt,
})
token := tokenResult.Token
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, dpop.ErrRequired))
// The client signs a fresh proof per request.
proof, _ := dpop.Sign(ctx, agentKey, dpop.SignRequest{Method: method, URL: url, Token: token, IssuedAt: time.Now()})
req := limes.VerifyRequest{
Token: token, Audience: exampleAudience,
DPoP: dpop.Proof{JWT: 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, dpop.ErrReplayed))
}
// exampleKeys is a jwks.Static holding pub under kid.
func exampleKeys(kid string, pub ed25519.PublicKey) *jwks.Static {
keys, err := jwks.NewStatic(jwks.StaticConfig{Keys: map[string]ed25519.PublicKey{kid: pub}})
if err != nil {
log.Fatal(err)
}
return keys
}
Output: true <nil> true
Example (Revoke) ¶
package main
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"log"
"time"
"uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/dpop"
"github.com/oleg-tkachuk/limes/jwks"
"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.Verifier
records *memstore.Store
usage *memstore.UsageStore
tenantID uuid.UUID
}
// exampleClientID is the OAuth client the examples' capabilities are granted
// to (RFC 9068 client_id): the software the agent runs as.
const exampleClientID = "example-agent"
func newExampleStack() exampleStack {
key, err := limes.GenerateKey()
kid, pub, priv := key.KeyID, key.Public(), key.Private
if err != nil {
log.Fatal(err)
}
records, err := memstore.NewStore(memstore.StoreConfig{})
if err != nil {
log.Fatal(err)
}
usage, err := memstore.NewUsageStore(memstore.UsageStoreConfig{Records: records})
if err != nil {
log.Fatal(err)
}
signer, err := limes.NewSigner(limes.SignerConfig{KeyID: kid, Key: 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)
}
replay, err := dpop.NewMemoryReplayCache(dpop.MemoryReplayCacheConfig{})
if err != nil {
log.Fatal(err)
}
proofs, err := dpop.NewVerifier(dpop.VerifierConfig{Replay: replay})
if err != nil {
log.Fatal(err)
}
verifier, err := limes.NewVerifier(limes.VerifierConfig{
Keys: exampleKeys(kid, pub),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
DPoP: proofs,
})
if err != nil {
log.Fatal(err)
}
return exampleStack{
issuer: issuer, verifier: verifier, records: records,
usage: usage, tenantID: uuid.New(),
}
}
// verified is the capability token grants, as the verifier accepts it.
func (s exampleStack) verified(token string) limes.VerifiedCapability {
vc, err := s.verifier.Verify(context.Background(), limes.VerifyRequest{Token: token, Audience: exampleAudience})
if err != nil {
log.Fatal(err)
}
return vc
}
// 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) {
cResult, err := s.issuer.Issue(context.Background(), limes.IssueRequest{
ClientID: exampleClientID,
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalTypeAgent, 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/"},
Requests: limes.RequestCeiling{Max: 500},
Budget: limes.BudgetCeiling{Max: limes.MustParseAmount("25.00"), UnitCode: "USD"},
},
})
c, token := cResult.Capability, cResult.Token
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{
CapabilityID: c.ID, Note: "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))
}
// exampleKeys is a jwks.Static holding pub under kid.
func exampleKeys(kid string, pub ed25519.PublicKey) *jwks.Static {
keys, err := jwks.NewStatic(jwks.StaticConfig{Keys: map[string]ed25519.PublicKey{kid: pub}})
if err != nil {
log.Fatal(err)
}
return keys
}
Output: <nil> true
Index ¶
- Constants
- Variables
- func AllByPrincipal(ctx context.Context, l CapabilityLister, req ListByPrincipalRequest) iter.Seq2[Capability, error]
- func AllPages[T any](ctx context.Context, cursor string, ...) iter.Seq2[T, error]
- func HasLimesKeyID(token string) bool
- func IsUnitCode(u string) bool
- func MarshalCaveats(c Caveats) ([]byte, error)
- func MatchResource(prefix, uri string) bool
- func MatchResourceName(prefix, uri string) bool
- func Narrows(parent, child Capability) error
- func PageLimit(requested int) int
- func ProblemTypeURI(kind string) string
- func WriteErrorResponse(w http.ResponseWriter, resp ErrorResponse)
- type AgentPrincipal
- type Attenuation
- type AuthScheme
- type AuthorizeRequest
- type BiscuitConfig
- type BiscuitCopy
- type BiscuitRequest
- type BiscuitResult
- type BiscuitRevocation
- type BiscuitRevocationLookup
- type BiscuitRevocationStore
- type BudgetCeiling
- type BulkRevoker
- type Capability
- type CapabilityLister
- type CapabilityRevocation
- type Caveats
- type CheckRequest
- type Code
- type ContextSigner
- type CopyCeiling
- type CopyQuota
- type CountRecordsRequest
- type DelegateRequest
- type Effect
- type EraseTenantRequest
- type EraseTenantResult
- type ErrorResponse
- type ExchangeKey
- type ExchangeStore
- type InsertRequest
- type Introspection
- type IssuanceStore
- type IssueRequest
- type IssueResult
- type Issuer
- func (i *Issuer) Biscuit(ctx context.Context, req BiscuitRequest) (BiscuitResult, error)
- func (i *Issuer) Delegate(ctx context.Context, req DelegateRequest) (IssueResult, error)
- func (i *Issuer) Issue(ctx context.Context, req IssueRequest) (IssueResult, error)
- func (i *Issuer) Reissue(ctx context.Context, id uuid.UUID) (IssueResult, error)
- type IssuerConfig
- type KeyResolver
- type ListByPrincipalRequest
- type ListOrder
- type ListRecordsRequest
- type MeterSubject
- func (s MeterSubject) Budget() BudgetCeiling
- func (s MeterSubject) CapabilityID() uuid.UUID
- func (s MeterSubject) Copies() []CopyCeiling
- func (s MeterSubject) CopyQuota() CopyQuota
- func (s MeterSubject) Requests() RequestCeiling
- func (s MeterSubject) TenantID() uuid.UUID
- func (s MeterSubject) UnitCode() string
- func (s MeterSubject) Validate() error
- func (s MeterSubject) ValidateAmounts() error
- type Nanos
- type Op
- type Page
- type Principal
- type PrincipalType
- type Problem
- type PurgeRequest
- type PurgeResult
- type Reason
- type Record
- type RecordCensus
- type RecordCount
- type RecordDisposition
- type RecordLister
- type RecordListing
- type RecordListingReader
- type RecordReader
- type RecordReporter
- type RequestCeiling
- type Revocation
- type RevocationAnswer
- type RevocationCache
- func (c *RevocationCache) Clear()
- func (c *RevocationCache) ClearRevoked(ids ...uuid.UUID)
- func (c *RevocationCache) IsBiscuitRevoked(ctx context.Context, revocationIDs [][]byte) (bool, error)
- func (c *RevocationCache) IsRevoked(ctx context.Context, id uuid.UUID) (bool, error)
- func (c *RevocationCache) NewSignal() *RevocationSignal
- func (c *RevocationCache) Stats() RevocationCacheStats
- func (c *RevocationCache) Sweep() int
- type RevocationCacheConfig
- type RevocationCacheCounts
- type RevocationCacheStats
- type RevocationLineageLookup
- type RevocationLookup
- type RevocationSignal
- type RevocationSignalTarget
- type RevokeBiscuitRequest
- type RevokeBiscuitResult
- type RevokeMatchingRequest
- type RevokeMatchingResult
- type RevokeRequest
- type RevokeResult
- type SealedKey
- func (SealedKey) Format(f fmt.State, _ rune)
- func (SealedKey) GoString() string
- func (k SealedKey) IsZero() bool
- func (SealedKey) MarshalText() ([]byte, error)
- func (k SealedKey) Public() crypto.PublicKey
- func (k SealedKey) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error)
- func (SealedKey) String() string
- type Signer
- type SignerConfig
- type SigningAlgorithm
- type SigningKey
- type Store
- type TenantRecordEraser
- type TokenForm
- type Unsealed
- type VerifiedCapability
- type Verifier
- func (v *Verifier) Authorize(ctx context.Context, req AuthorizeRequest) (VerifiedCapability, error)
- func (v *Verifier) BiscuitCopy(ctx context.Context, token string) (BiscuitCopy, error)
- func (v *Verifier) DPoPNonce(ctx context.Context) (string, error)
- func (v *Verifier) ErrorResponse(ctx context.Context, err error, scheme AuthScheme) ErrorResponse
- func (v *Verifier) Introspect(ctx context.Context, token string) (Introspection, error)
- func (v *Verifier) Verify(ctx context.Context, req VerifyRequest) (VerifiedCapability, error)
- type VerifierConfig
- type VerifyRequest
Examples ¶
- Package (BackdatedCharges)
- Package (Charge)
- Package (ProofOfPossession)
- Package (Revoke)
- AllByPrincipal
- AllPages
- AmountFromFloat
- BudgetCeiling
- Capability.Clone
- Caveats.AllowsResource
- Caveats.Check
- Caveats.Check (IdempotencyAndTaint)
- Caveats.CheckSource
- Caveats.Validate
- CheckRequest.Mutating
- CopyCeiling
- CopyQuota
- GenerateKey
- IsUnitCode
- Issuer.Delegate
- Issuer.Issue
- LoadKey
- MatchResource
- MustParseAmount
- Nanos.MarshalJSON
- Nanos.String
- Nanos.UnmarshalText
- Nanos.Validate
- Narrows
- NewIssuer
- NewRevocationCache
- NewSigner
- NewVerifier
- Op.Builtin
- Op.ResolveEffect
- Op.Validate
- PageLimit
- ParseAmount
- ParseAuthorization
- Principal.Validate
- ReasonOf
- Reasons
- RequestCeiling
- RevocationCache.Sweep
- SealKey
- SignerConfig.LogValue
- StoredMeterSubject
- VerifiedCapability.MeterSubject
- Verifier.Authorize
- Verifier.DPoPNonce
- Verifier.ErrorResponse
- Verifier.Verify
- WriteErrorResponse
Constants ¶
const ( CodeNoCredentials = Code(ReasonNoCredentials) CodeMalformedAuthorization = Code(ReasonMalformedAuthorization) CodeInvalidToken = Code(ReasonInvalidToken) CodeExpired = Code(ReasonExpired) CodeNotYetValid = Code(ReasonNotYetValid) CodeRevoked = Code(ReasonRevoked) CodeAudienceMismatch = Code(ReasonAudienceMismatch) CodeBiscuitAttenuation = Code(ReasonBiscuitAttenuation) CodeProofRequired = Code(ReasonProofRequired) CodeNonceRequired = Code(ReasonNonceRequired) CodeAuthSchemeMismatch = Code(ReasonAuthSchemeMismatch) CodeProofInvalid = Code(ReasonProofInvalid) CodeProofReplayed = Code(ReasonProofReplayed) CodeOpNotAllowed = Code(ReasonOpNotAllowed) CodeResourceNotAllowed = Code(ReasonResourceNotAllowed) CodeSourceIPNotAllowed = Code(ReasonSourceIPNotAllowed) CodeIdempotencyKeyRequired = Code(ReasonIdempotencyKeyRequired) CodeTaintedReadNotAllowed = Code(ReasonTaintedReadNotAllowed) CodeRequestLimitExceeded = Code(ReasonRequestLimitExceeded) CodeBudgetExceeded = Code(ReasonBudgetExceeded) CodeTenantBudgetExceeded = Code(ReasonTenantBudgetExceeded) )
The codes of the refusals, each its Reason.
const ( DefaultListLimit = 50 MaxListLimit = 500 )
Page sizes of a list: Limit 0 means DefaultListLimit, anything above MaxListLimit is clamped to it, and a negative Limit is the request's error (Validate).
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 DefaultHousekeepingLimit = 1_000
DefaultHousekeepingLimit is how many rows one housekeeping batch — Store.Purge, meter.UsageHousekeeping.ReleaseExpired or PurgeOrphans, meter.LedgerHousekeeping.PurgeCharges — handles when the request names no limit.
const DefaultIssueTTL = 15 * time.Minute
DefaultIssueTTL is IssuerConfig.DefaultTTL when it is zero.
const DefaultLeeway = 30 * time.Second
DefaultLeeway is VerifierConfig.Leeway when it is zero.
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 DefaultRealm = "limes"
DefaultRealm is the realm of a verifier whose config names none.
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 and never unbounded memory. It does evict other callers' answers of the same kind, each costing one lookup more; the other kind's are kept (RevocationCacheConfig.MaxEntries).
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 and hears no signal of it. It suits the requests of one exchange; a token verified less often — once per step of an agent — wants a longer bound while a revocation signal vouches for the cache: RevocationCacheConfig.SyncedTTL.
const MaxBiscuitBlocks = 32
MaxBiscuitBlocks bounds the attenuation blocks a Biscuit may carry. Each block is a revocation id the verifier looks up, and anyone holding a copy can append blocks offline, so an unbounded chain is a lookup per block on every request. Attenuate refuses to go past it, and the verifier refuses a token that has.
const MaxDelegationDepth = 64
MaxDelegationDepth is the most ancestors a capability may have: how far IsRevoked walks, and how deep Delegate mints.
const MaxIJSONInteger = jose.MaxIJSONInteger
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 MaxLeeway = 5 * time.Minute
MaxLeeway is the widest VerifierConfig.Leeway a verifier takes: a token is accepted for at most this long past its expiry, which is what lets a store purge it safely after MinPurgeRetention.
const MaxRevocationCacheSyncedTTL = 24 * time.Hour
MaxRevocationCacheSyncedTTL bounds RevocationCacheConfig.SyncedTTL. A signal that listens and still carries nothing — a notification its source never sends — leaves a revocation unseen for as long as the bound in force, so even a synced cache asks again within a day.
const MinPurgeRetention = MaxLeeway + PurgeClockSkew
MinPurgeRetention is the shortest retention Purge takes: no verifier accepts a token longer than MaxLeeway past its expiry, by a clock no more than PurgeClockSkew behind the store's, so a capability expired for longer can lose its revocation entry unseen.
const NanosPerUnit = 1_000_000_000
NanosPerUnit is how many nanos make one unit.
const PurgeClockSkew = time.Minute
PurgeClockSkew is how far the clock of the store that purges may run ahead of a verifier's: the store measures expiry by its own clock, the verifier by its own, and a revocation must outlive the token on both.
const TokenType = "at+jwt" //nolint:gosec // G101: a JWT type label, not a credential
TokenType is the JWT typ of every capability token: an OAuth 2.0 access token in the JWT profile of RFC 9068, so a resource server or gateway that validates that profile reads it, with the claims the profile requires, client_id among them. The type does not name the format: limes_v does, and a verifier reads the claims under the schema it names, strictly, so an at+jwt of another issuer's making is refused before any claim is trusted. What else tells a capability from another access token is its key, whose kid is in limes's namespace (jwks.KeyIDPrefix): never share it with an authorization server that mints at+jwt (docs/adr/0006-one-token-format.md).
Earlier formats, each with a type of its own, are not accepted: limes-cap-v6+jwt, v5, v4 and v3.
Variables ¶
var ( ErrOpNotAllowed error = &familyError{"limes: operation not allowed", ErrCaveatViolation} ErrResourceNotAllowed error = &familyError{"limes: resource not allowed", ErrCaveatViolation} ErrSourceIPNotAllowed error = &familyError{"limes: source IP not allowed", ErrCaveatViolation} ErrIdempotencyKeyRequired error = &familyError{"limes: idempotency key required", ErrCaveatViolation} ErrTaintedReadNotAllowed error = &familyError{"limes: tainted read not allowed", ErrCaveatViolation} )
Request-time caveat rejections. Each also matches ErrCaveatViolation.
var ( // ErrUnauthenticated is the family of every refusal of the presented // credentials, token or proof: the one errors.Is a consumer needs to // answer them all as unauthenticated. ErrUnauthenticated = errors.New("limes: unauthenticated") // ErrInvalidToken — the token is not genuine or not this verifier's // to accept: a bad signature, an unknown kid, an untrusted issuer, a // malformed header or claims. One sentinel for all, so a caller cannot // tell an attacker which check failed. ErrInvalidToken error = &familyError{"limes: invalid token", ErrUnauthenticated} // ErrProofOfPossession is the family of every proof-of-possession // refusal: the token may be genuine, the proof that its holder sent it // is what failed. The verifier wraps each dpop refusal in it. ErrProofOfPossession error = &familyError{"limes: proof of possession refused", ErrUnauthenticated} ErrExpired error = &familyError{"limes: expired", ErrUnauthenticated} ErrNotYetValid error = &familyError{"limes: not yet valid", ErrUnauthenticated} ErrRevoked error = &familyError{"limes: revoked", ErrUnauthenticated} // ErrTenantErased is a metering call, or a Store.Insert, for a tenant // whose usage EraseTenantUsage removed: nothing of it is recorded or // counted again. Its capabilities were revoked with it, so it matches // ErrRevoked. ErrTenantErased error = &familyError{"limes: tenant erased", ErrRevoked} // ErrDelegationTooDeep — a capability with more than MaxDelegationDepth // ancestors on record. Its root lies past the bounded IsRevoked walk, // so a revocation there would go unseen: it is refused as revoked, // never accepted, and Delegate refuses to mint one. A store answers it // as an error, which no revocation cache keeps. ErrDelegationTooDeep error = &familyError{"limes: delegation chain too deep", ErrRevoked} ErrAudienceMismatch error = &familyError{"limes: audience mismatch", ErrUnauthenticated} ErrCaveatViolation = errors.New("limes: caveat violation") ErrBudgetExceeded = errors.New("limes: budget exceeded") // ErrInvalidAmount — an amount that is negative or above MaxNanos, or a // decimal that is not one (ParseAmount). Refunds are explicit; a // negative charge is not one. A malformed request, so it matches // ErrInvalidRequest. ErrInvalidAmount error = &familyError{"limes: invalid amount", ErrInvalidRequest} // ErrCursorMismatch — a cursor RecordLister.ListRecords handed out for // another order, tenant or other filters, handed back with these: well formed, // but a place in another listing. A caller that tells it from a // malformed request starts again from the first page. It matches // ErrInvalidRequest, as every refused request does; a cursor that is // not one at all is ErrInvalidRequest alone. ErrCursorMismatch error = &familyError{"limes: cursor of another listing", ErrInvalidRequest} // ErrRequestLimitExceeded — the capability, a Biscuit copy or an // ancestor used every request its ceiling allows; the next was blocked. ErrRequestLimitExceeded = errors.New("limes: request limit exceeded") // 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") 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. The request is well formed and // disagrees with what is on record, so it matches ErrConflict. ErrUnitCodeMismatch error = &familyError{"limes: unit_code mismatch", ErrConflict} // ErrInvalidRequest is a request 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 or an amount that do not // validate. Nothing was done. Every such error matches it, so a caller // maps the whole family to its "bad request" without listing it. A // request that is well formed but asks for more than is allowed has its // own sentinel instead: ErrDelegationTooWide, ErrUnitCodeMismatch. ErrInvalidRequest = errs.ErrInvalidRequest // ErrInvalidConfig is a component wired wrong: a constructor given a // config it cannot work with (a required field missing, a negative // bound), a key of another kind, or a dependency missing. Nothing was // built or done. It is the deployment's to fix, not the caller's, and is // answered 500 (docs/errors.md). ErrInvalidConfig = errs.ErrInvalidConfig // ErrInternal is an invariant of the module broken: a signature its own // key does not verify, a store answering otherwise than its contract // says. It is answered 500, the error's text, for the server's log, // naming what broke. ErrInternal = errs.ErrInternal // 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") // ErrScopeMismatch is a store call whose context is scoped to a tenant // other than the one its request names — or to none — under a store // that keeps each tenant's rows apart. The rows are there; the call may // not see them. It is the caller's wiring that is wrong, not the // request, so it matches neither ErrInvalidRequest nor ErrNotFound, and // ErrorResponse answers it 500. ErrScopeMismatch = errors.New("limes: call scoped to another tenant than its request names") // ErrProofUnsupported is a key-bound capability presented to a verifier // with no DPoP configured: no proof the client sends could be checked. // It is the deployment's fault, not the request's, so it matches no // ErrUnauthenticated sentinel and ErrorResponse invites no retry. ErrProofUnsupported = errors.New("limes: key-bound capability, and this verifier checks no proof") )
Errors exposed to callers, which switch on them with errors.Is. A consumer maps them to its transport, or lets Verifier.ErrorResponse map them to HTTP.
Some are families that more specific sentinels match as well, so a caller branches on the one it needs:
ErrUnauthenticated the presented credentials were refused (401)
├── ErrNoCredentials none were presented
├── ErrInvalidToken the token is not genuine, or not this verifier's
│ └── ErrBiscuitAttenuation
├── ErrProofOfPossession the proof of possession is missing or wrong
│ └── ErrAuthSchemeMismatch
├── ErrRevoked
│ └── ErrDelegationTooDeep
└── ErrExpired, ErrNotYetValid, ErrAudienceMismatch
ErrCaveatViolation a caveat refuses the operation (403)
ErrBudgetExceeded, ErrTenantBudgetExceeded, ErrRequestLimitExceeded
a ceiling refuses the request (403)
ErrInvalidRequest the request itself is malformed
├── ErrInvalidCaveats, ErrInvalidAmount, ErrEffectConflict
└── ErrMalformedAuthorization the client's Authorization header (400)
ErrNotFound what the request names is not on record
└── meter.ErrChargeNotFound, meter.ErrUsageNotFound, …
ErrConflict a well-formed request disagrees with what is on record
└── ErrAlreadyExists, ErrUnitCodeMismatch, meter.ErrExternalRefConflict, …
var ErrAlreadyExists error = &familyError{"limes: already exists", ErrConflict}
ErrAlreadyExists is Store.Insert given an id already on record. It matches ErrConflict.
var ErrAuthSchemeMismatch error = &familyError{"limes: token presented under the wrong scheme", ErrProofOfPossession}
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 ErrProofOfPossession: the binding, not the token, is what the scheme fails.
var ErrBiscuitAttenuation error = &familyError{"limes: invalid Biscuit attenuation", ErrInvalidToken}
ErrBiscuitAttenuation — an attenuation block the verifier cannot honour: outside the vocabulary, malformed, or widening its parent. Matches ErrInvalidToken: the token is not one this verifier accepts.
var ErrConflict = errors.New("limes: conflict")
ErrConflict is the family of a request that is well formed but disagrees with what is already on record: an id taken, a name already given to another cost, a version someone else moved. Nothing was done; the caller reads again and decides. It matches no ErrInvalidRequest — the request would have succeeded against other state — and a caller maps the whole family to its "conflict" (HTTP 409) without listing it.
var ErrCopyCountersNotMetered = errors.New("limes: copy limits need a verifier with MeterCopies")
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. The token is not at fault — a verifier with MeterCopies accepts it — so this is the server's configuration, outside ErrInvalidToken: ErrorResponse answers it as an internal error, and it has no Reason.
var ErrCursorRepeated = errors.New("limes: a page returned a cursor already fetched")
ErrCursorRepeated is a page that hands back a cursor the walk has already fetched — its own, or one further back. Following it would fetch the same pages forever, so the walk stops there.
var ErrEffectConflict error = &familyError{"limes: declared effect conflicts with the operation", ErrInvalidRequest}
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 ErrInvalidCaveats error = &familyError{"limes: invalid caveats", ErrInvalidRequest}
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.
var ErrMalformedAuthorization error = &familyError{"limes: malformed Authorization header", ErrInvalidRequest}
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, so it matches ErrInvalidRequest; ErrorResponse still answers it 400, as the client's fault, where the rest of that family is the server's own call.
var ErrNoCredentials error = &familyError{"limes: no credentials", ErrUnauthenticated}
ErrNoCredentials — the request carries no credential this module reads: no Authorization header, or one under a scheme other than Bearer or DPoP. RFC 6750 §3.1 answers it with a challenge and no error code: the client has not tried, so nothing it sent was wrong.
var ErrNoRevocationLineage = errors.New("limes: the revocation upstream names no lineage")
ErrNoRevocationLineage is NewRevocationCache refusing, under RevocationCacheConfig.RequireLineage, a Capabilities upstream that is no RevocationLineageLookup.
var ErrNotFound = errors.New("limes: not found")
ErrNotFound is the family of every not-found error: Store.GetRecord returns it, and the meter's not-found sentinels (meter.ErrUsageNotFound and the others) match it under errors.Is. 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.
Functions ¶
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"
"uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/dpop"
"github.com/oleg-tkachuk/limes/jwks"
"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.Verifier
records *memstore.Store
usage *memstore.UsageStore
tenantID uuid.UUID
}
// exampleClientID is the OAuth client the examples' capabilities are granted
// to (RFC 9068 client_id): the software the agent runs as.
const exampleClientID = "example-agent"
func newExampleStack() exampleStack {
key, err := limes.GenerateKey()
kid, pub, priv := key.KeyID, key.Public(), key.Private
if err != nil {
log.Fatal(err)
}
records, err := memstore.NewStore(memstore.StoreConfig{})
if err != nil {
log.Fatal(err)
}
usage, err := memstore.NewUsageStore(memstore.UsageStoreConfig{Records: records})
if err != nil {
log.Fatal(err)
}
signer, err := limes.NewSigner(limes.SignerConfig{KeyID: kid, Key: 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)
}
replay, err := dpop.NewMemoryReplayCache(dpop.MemoryReplayCacheConfig{})
if err != nil {
log.Fatal(err)
}
proofs, err := dpop.NewVerifier(dpop.VerifierConfig{Replay: replay})
if err != nil {
log.Fatal(err)
}
verifier, err := limes.NewVerifier(limes.VerifierConfig{
Keys: exampleKeys(kid, pub),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
DPoP: proofs,
})
if err != nil {
log.Fatal(err)
}
return exampleStack{
issuer: issuer, verifier: verifier, records: records,
usage: usage, tenantID: uuid.New(),
}
}
// verified is the capability token grants, as the verifier accepts it.
func (s exampleStack) verified(token string) limes.VerifiedCapability {
vc, err := s.verifier.Verify(context.Background(), limes.VerifyRequest{Token: token, Audience: exampleAudience})
if err != nil {
log.Fatal(err)
}
return vc
}
// 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) {
cResult, err := s.issuer.Issue(context.Background(), limes.IssueRequest{
ClientID: exampleClientID,
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalTypeAgent, 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/"},
Requests: limes.RequestCeiling{Max: 500},
Budget: limes.BudgetCeiling{Max: limes.MustParseAmount("25.00"), UnitCode: "USD"},
},
})
c, token := cResult.Capability, cResult.Token
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.PrincipalTypeAgent, Subject: "research-orchestrator",
Limit: pageSize,
}) {
if err != nil {
log.Fatal(err)
}
n++
}
fmt.Println(n)
}
// exampleKeys is a jwks.Static holding pub under kid.
func exampleKeys(kid string, pub ed25519.PublicKey) *jwks.Static {
keys, err := jwks.NewStatic(jwks.StaticConfig{Keys: map[string]ed25519.PublicKey{kid: pub}})
if err != nil {
log.Fatal(err)
}
return keys
}
Output: 3
func AllPages ¶ added in v0.100.0
func AllPages[T any]( ctx context.Context, cursor string, page func(ctx context.Context, cursor string) (Page[T], error), ) iter.Seq2[T, error]
AllPages walks a paged list from cursor, fetching each page with page until one returns an empty cursor, and yields every item in its order, for a lister of a package other than this one; AllByPrincipal is one walk.
An error is yielded once, with a zero T, and ends the walk; so does a page handing back a cursor already fetched (ErrCursorRepeated). Breaking out of the loop fetches no further page.
Example ¶
package main
import (
"context"
"errors"
"fmt"
"log"
"github.com/oleg-tkachuk/limes"
)
func main() {
ctx := context.Background()
pages := map[string]limes.Page[string]{
"": {Items: []string{"a", "b"}, Next: "p2"},
"p2": {Items: []string{"c"}},
}
page := func(_ context.Context, cursor string) (limes.Page[string], error) {
return pages[cursor], nil
}
var items []string
for item, err := range limes.AllPages(ctx, "", page) {
if err != nil {
log.Fatal(err)
}
items = append(items, item)
}
fmt.Println(items)
// A page pointing back at one already fetched ends the walk.
pages["p2"] = limes.Page[string]{Items: []string{"c"}, Next: "p2"}
for _, err := range limes.AllPages(ctx, "", page) {
if err != nil {
fmt.Println(errors.Is(err, limes.ErrCursorRepeated))
}
}
}
Output: [a b c] true
func HasLimesKeyID ¶ added in v0.135.0
HasLimesKeyID reports whether token is a compact JWT whose header names a kid in limes's namespace (jwks.KeyIDPrefix): the one thing that tells a capability from an identity provider's access token, since both are typed at+jwt (RFC 9068). It reads the header alone, verifies nothing, and is never an authorization decision: a forged token can name any kid. Use it to route a bearer to the verifier that can decide — Verifier.Verify when it answers true, the identity provider's own verifier when it answers false — which then verifies everything. Verifier.Verify refuses a kid outside the namespace before it looks a key up, so an access token routed to it by mistake is refused, never read; a Biscuit, being no JWT, answers false.
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: it names no unit, and no unit is ever assumed for it.
Example ¶
package main
import (
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
fmt.Println(limes.IsUnitCode("USD"), limes.IsUnitCode(limes.AbstractUnitCode))
fmt.Println(limes.IsUnitCode("usd"), limes.IsUnitCode(""))
}
Output: true true false false
func MarshalCaveats ¶ added in v0.130.0
MarshalCaveats encodes c as a token's limes_caveats claim carries it, for a reader outside limes to be shown the same members a token holds, such as an RFC 7662 introspection answer.
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, between slashes that may be encoded or backslashes — 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. That covers a consumer that percent-decodes at most twice before it resolves the path, and no other notation: one that decodes more often, or whose backend resolves Unicode look-alikes (U+FF0E), overlong UTF-8 (%c0%ae), %u escapes or trailing dots and spaces as a dot segment, canonicalises the resource itself before it asks.
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 MatchResourceName ¶ added in v0.128.0
MatchResourceName reports whether uri starts with prefix, as strings and across segments: "drafts/billing-" covers "drafts/billing-q3" and "drafts/billing-q3/notes", but not "drafts/billing". Like MatchResource, an empty prefix matches nothing, and neither does a uri with a dot segment. It is the single definition of a name-prefix match, for requests and for delegation alike.
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.
Example ¶
start := time.Date(2026, time.March, 2, 9, 0, 0, 0, time.UTC)
tenant := uuid.New()
parent := limes.Capability{
Subject: limes.Principal{Type: limes.PrincipalTypeAgent, TenantID: tenant, Subject: "orchestrator"},
Audience: []string{exampleAudience},
IssuedAt: start,
ExpiresAt: start.Add(time.Hour),
Caveats: limes.Caveats{
Ops: []limes.Op{limes.OpGet, limes.OpShare},
ResourcePrefixes: []string{"corpus/"},
Requests: limes.RequestCeiling{Max: 100},
},
}
child := parent
child.Caveats = limes.Caveats{
Ops: []limes.Op{limes.OpGet},
ResourcePrefixes: []string{"corpus/public/"},
Requests: limes.RequestCeiling{Max: 10},
}
// A dry run of a delegation, with no issuer in the path.
fmt.Println(limes.Narrows(parent, child))
child.Caveats.Ops = []limes.Op{limes.OpDelete}
fmt.Println(errors.Is(limes.Narrows(parent, child), limes.ErrDelegationTooWide))
Output: <nil> true
func PageLimit ¶
PageLimit is the page size a store serves for a requested limit.
Example ¶
package main
import (
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
fmt.Println(limes.PageLimit(0), limes.PageLimit(20), limes.PageLimit(10_000))
}
Output: 50 20 500
func ProblemTypeURI ¶ added in v0.137.0
ProblemTypeURI is the RFC 9457 type URI of the problem a code, or a refusal's Reason, names.
func WriteErrorResponse ¶ added in v0.3.0
func WriteErrorResponse(w http.ResponseWriter, resp ErrorResponse)
WriteErrorResponse writes resp: its status, challenges, nonce and problem.
Example ¶
ctx := context.Background()
s := newExampleStack()
_, token := s.issue()
_, err := s.verifier.Authorize(ctx, limes.AuthorizeRequest{
Verify: limes.VerifyRequest{Token: token, Audience: exampleAudience},
Check: limes.CheckRequest{Op: limes.OpDelete, Resource: "corpus/public/a.txt"},
})
rec := httptest.NewRecorder()
limes.WriteErrorResponse(rec, s.verifier.ErrorResponse(ctx, err, limes.AuthSchemeBearer))
fmt.Println(rec.Code, rec.Header().Get("Content-Type"))
fmt.Print(rec.Body.String())
Output: 403 application/problem+json {"type":"https://github.com/oleg-tkachuk/limes/problems/op_not_allowed","title":"The token does not allow this request","status":403,"reason":"op_not_allowed","code":"op_not_allowed"}
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
// AgentVersion pins the build / git-sha of the agent runtime.
AgentVersion string
// RunID is a UUID minted by the agent host at session start.
RunID uuid.UUID
// ParentRunID is the RunID of the agent whose capability this one was
// delegated from. Issuer.Delegate sets it from the parent, and refuses
// a request naming another; zero for a root capability, or one
// delegated from a principal that is no agent or names no run.
ParentRunID uuid.UUID
// Model identifies the LLM behind the agent ("example-model-1",
// "gpt-5-pro"). Used for cost attribution.
Model string
// MCPClient is the agent host's user-agent ("example-host@1.4.0",
// "cursor@0.44"). Useful for compatibility tracking.
MCPClient string
// contains filtered or unexported fields
}
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
// Audience the token keeps.
Audience []string
// ExpiresAt, when earlier than the token's, becomes its expiry.
ExpiresAt time.Time
// ConfirmationJKT binds the token to a key (dpop.Thumbprint). 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
// Requests and Budget 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, which a block does not
// carry — so Budget names no UnitCode, and the verifier gives it the
// capability's. nil sets none; a ceiling of 0 freezes the copy. Each
// must be within every limit already in force, and an Unlimited one is
// refused: a copy only narrows.
Requests *RequestCeiling
Budget *BudgetCeiling
}
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.
func (Attenuation) Validate ¶ added in v0.133.0
func (a Attenuation) Validate() error
Validate refuses an attenuation no block can carry: a binding that is no thumbprint, or a copy limit that is not a bound. The verifier refuses such a block whatever form carried it.
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 — a gRPC metadata entry, a message field. // VerifyRequest checks no scheme for it; a key-bound token still needs // its DPoP proof. 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. With ErrMalformedAuthorization it still returns the scheme it recognised, so ErrorResponse challenges under the scheme the client used.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
scheme, token, err := limes.ParseAuthorization("dpop eyJhbGciOi")
fmt.Println(scheme, token, err)
// A scheme limes does not read is no credential at all.
_, _, err = limes.ParseAuthorization("Basic dXNlcjpwYXNz")
fmt.Println(errors.Is(err, limes.ErrNoCredentials))
// Bearer with two credentials is malformed; the scheme still comes back,
// so the challenge answers under it.
scheme, _, err = limes.ParseAuthorization("Bearer a b")
fmt.Println(scheme, errors.Is(err, limes.ErrMalformedAuthorization))
}
Output: DPoP eyJhbGciOi <nil> true Bearer true
func ParseAuthorizationValues ¶ added in v0.132.0
func ParseAuthorizationValues(values []string) (AuthScheme, string, error)
ParseAuthorizationValues reads every Authorization field a request carries — r.Header.Values(AuthorizationHeader), or a gRPC metadata entry's values — as ParseAuthorization reads one. More than one is ErrMalformedAuthorization, with no scheme: RFC 9449 §7.2 refuses a request that uses more than one authentication method, and RFC 6750 §2 one that presents a token more than once, so none of them is chosen. None is ErrNoCredentials.
func (AuthScheme) Validate ¶ added in v0.37.0
func (s AuthScheme) Validate() error
Validate refuses a scheme other than the ones ParseAuthorization returns, or AuthSchemeUnspecified, as ErrInvalidRequest.
type AuthorizeRequest ¶ added in v0.3.0
type AuthorizeRequest struct {
// Verify is the token and how it was presented.
Verify VerifyRequest
// Check is what the token is asked to allow.
Check 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 BiscuitConfig ¶ added in v0.87.0
type BiscuitConfig struct {
// Revocations answers whether a copy of a Biscuit is revoked on its
// own. Required; wrap the store in the RevocationCache that
// VerifierConfig.Revocations holds, with RevocationCacheConfig.Biscuits
// set. NewVerifier refuses a *RevocationCache without it, or a nil one;
// a type of yours that wraps such a cache is not seen through, and
// refuses every Biscuit at Verify instead.
Revocations BiscuitRevocationLookup
// Form reads the Biscuit form: github.com/oleg-tkachuk/limes/biscuit's
// Form. Required.
Form TokenForm
// MeterCopies admits Biscuits whose attenuation blocks set a copy's own
// request or budget limits (Attenuation.Requests, Attenuation.Budget).
// Set it only when the Meter that enforces caveats counts
// the copies a MeterSubject carries; off, such a token is refused with
// ErrCopyCountersNotMetered rather than accepted with its limits unkept
// — once it has passed every other check, so a token refused for what
// it is gets that refusal instead.
MeterCopies bool
}
BiscuitConfig is how a verifier reads the Biscuit form (VerifierConfig.Biscuit).
type BiscuitCopy ¶
type BiscuitCopy struct {
// contains filtered or unexported fields
}
BiscuitCopy identifies one copy of a capability's Biscuit. Only Verifier.BiscuitCopy makes one, from a token whose signature chain it checked, so the revocation id and the capability it is revoked under always belong together: a caller cannot name another tenant's copy under its own capability. The zero BiscuitCopy names nothing.
func (BiscuitCopy) CapabilityID ¶
func (c BiscuitCopy) CapabilityID() uuid.UUID
CapabilityID is the capability the Biscuit seals.
func (BiscuitCopy) Copies ¶ added in v0.50.0
func (c BiscuitCopy) Copies() []CopyCeiling
Copies are the request and budget limits in force on the copy — its own and those of the copies it was attenuated from — innermost first; meter.CopyUsageReader reads their counters.
func (BiscuitCopy) RevocationID ¶
func (c BiscuitCopy) RevocationID() []byte
RevocationID is the id of the copy's last block: listing it revokes this copy and every copy attenuated from it.
func (BiscuitCopy) Validate ¶ added in v0.93.0
func (c BiscuitCopy) Validate() error
Validate refuses the zero BiscuitCopy, which names no copy, as ErrInvalidRequest.
type BiscuitRequest ¶ added in v0.46.0
type BiscuitRequest struct {
// Capability is the capability to give a Biscuit form, as the verifier
// accepted it.
Capability VerifiedCapability
}
BiscuitRequest is the input shape for Issuer.Biscuit.
type BiscuitResult ¶ added in v0.46.0
type BiscuitResult struct {
// Token is the Biscuit, base64url.
Token string
// contains filtered or unexported fields
}
BiscuitResult is what Issuer.Biscuit mints.
type BiscuitRevocation ¶
type BiscuitRevocation struct {
CapabilityID uuid.UUID
RevocationID []byte
RevokedAt time.Time
Note 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 the revocation id of one Biscuit copy, under the
// capability the copy belongs to — the pair Verifier.BiscuitCopy read
// from the token, which no caller can assemble — and says whether the
// copy had an entry already. Idempotent: a repeat writes nothing, keeps
// the first entry and reports it with AlreadyRevoked. 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 ErrInvalidRequest for a request naming no copy.
RevokeBiscuit(ctx context.Context, req RevokeBiscuitRequest) (RevokeBiscuitResult, error)
// GetBiscuitRevocation reads back what RevokeBiscuit wrote for one copy,
// under the capability it was revoked under. ErrNotFound when the copy is
// not revoked under that capability, or the capability is one the caller
// cannot see; ErrInvalidRequest for the zero BiscuitCopy.
GetBiscuitRevocation(ctx context.Context, copy BiscuitCopy) (BiscuitRevocation, error)
}
BiscuitRevocationStore persists revoked Biscuit copies. It is separate from Store so that a consumer without Biscuits implements nothing more.
type BudgetCeiling ¶ added in v0.27.0
type BudgetCeiling struct {
// Max is the most that may be spent; 0 allows nothing. Within
// 0..MaxNanos, and 0 when Unlimited.
Max Nanos
// Unlimited lifts the ceiling.
Unlimited bool
// UnitCode is the unit Max counts in: an ISO 4217 code (IsUnitCode),
// AbstractUnitCode for a budget that is not money. Required when the
// ceiling lets anything be spent; may be empty on the zero ceiling.
UnitCode string
}
BudgetCeiling bounds an amount spent: at most Max of UnitCode, or any amount of it when Unlimited. The unit is part of the bound, as a currency is part of an amount: a ceiling that lets anything be spent names what, and the zero ceiling, which lets nothing be, needs none.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
ceiling := limes.BudgetCeiling{Max: limes.MustParseAmount("25"), UnitCode: "USD"}
fmt.Println(ceiling, ceiling.Validate())
fmt.Println(ceiling.Admits(limes.MustParseAmount("25")), ceiling.Admits(limes.MustParseAmount("25.01")))
unlimited := limes.BudgetCeiling{Unlimited: true, UnitCode: "USD"}
fmt.Println(unlimited, ceiling.Within(unlimited), unlimited.Within(ceiling))
// A ceiling that lets something be spent names its unit.
err := limes.BudgetCeiling{Max: limes.MustParseAmount("1")}.Validate()
fmt.Println(errors.Is(err, limes.ErrInvalidRequest))
}
Output: 25 USD <nil> true false unlimited USD true false true
func (BudgetCeiling) Admits ¶ added in v0.27.0
func (c BudgetCeiling) Admits(amount Nanos) bool
Admits reports whether amount spent in all stays under the ceiling.
func (BudgetCeiling) String ¶ added in v0.27.0
func (c BudgetCeiling) String() string
String is the ceiling for logs and errors: its Max, or "unlimited", and its unit when it names one.
func (BudgetCeiling) Validate ¶ added in v0.27.0
func (c BudgetCeiling) Validate() error
Validate refuses a Max beside Unlimited, a unit that is not an ISO 4217 code, and a ceiling that lets something be spent without naming its unit, as ErrInvalidRequest; a Max outside 0..MaxNanos is ErrInvalidAmount (Nanos.Validate), which matches ErrInvalidRequest too.
func (BudgetCeiling) Within ¶ added in v0.27.0
func (c BudgetCeiling) Within(outer BudgetCeiling) bool
Within reports whether c allows no more than outer, by amount alone: whether the two count in one unit is the caller's to check, and to refuse as ErrUnitCodeMismatch rather than as a wider ceiling.
type BulkRevoker ¶ added in v0.136.0
type BulkRevoker interface {
// RevokeMatching writes a revocation entry, not cascaded, for at most
// req.BatchLimit() capabilities of req.TenantID that match every
// selector req names and have no entry of their own, in id order, and
// reports how many and whether the batch was full. It refuses what
// RevokeMatchingRequest.Validate refuses, and a call scoped to another
// tenant as ErrScopeMismatch.
RevokeMatching(ctx context.Context, req RevokeMatchingRequest) (RevokeMatchingResult, error)
}
BulkRevoker revokes every capability of a tenant that matches a subject, an on-behalf-of, a client or a session, in batches, as Revoke revokes one: an entry for each that has none, and every descendant stopped through its lineage. A store that records sessions (InsertRequest.Session) implements it; memstore and pgstore do.
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
// 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 (package dpop). 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
// ClientID is the OAuth client the capability was granted to: the
// client of the access token it was exchanged for, or the software its
// subject runs as. Carried as RFC 9068's client_id, which the profile
// makes required. It describes the grant and restricts nothing.
ClientID string
// OnBehalfOf is the principal on whose behalf Subject acts, when it acts
// for another: the user an agent works for, as the identity provider
// names them. A delegated child keeps its parent's or, when the parent
// has none, acts on behalf of the parent's holder. In the token it is
// sub, with Subject as act (RFC 8693 §4.1). It describes the grant and
// restricts nothing: Subject is who the capability authorises.
OnBehalfOf *Principal
// PriorActors are the subjects of the holders this capability was
// delegated through on behalf of OnBehalfOf, nearest first: the token's
// nested act. Empty for a capability issued rather
// than delegated, and for a child of a capability with no OnBehalfOf.
PriorActors []string
}
Capability is the in-memory representation of a token. The wire form is a JWT; this struct is what the Issuer fills before signing, and what Verifier.Verify returns inside a VerifiedCapability.
func (Capability) Clone ¶ added in v0.24.0
func (c Capability) Clone() Capability
Clone is c with slices and pointers of its own: a store keeps, and hands out, a capability no caller can alter through a slice it kept — the record is append-only.
Example ¶
package main
import (
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
c := limes.Capability{Caveats: limes.Caveats{Ops: []limes.Op{limes.OpGet}}}
clone := c.Clone()
clone.Caveats.Ops[0] = limes.OpDelete
// The clone's slices are its own.
fmt.Println(c.Caveats.Ops, clone.Caveats.Ops)
}
Output: [get] [delete]
type CapabilityLister ¶ added in v0.2.0
type CapabilityLister interface {
// ListByPrincipal returns one page (Store.ListByPrincipal).
ListByPrincipal(ctx context.Context, req ListByPrincipalRequest) (Page[Capability], error)
}
CapabilityLister lists a principal's capabilities a page at a time. Every Store is one.
type CapabilityRevocation ¶ added in v0.114.0
type CapabilityRevocation struct {
// CapabilityID is the record's own id, or the ancestor's whose entry
// it is.
CapabilityID uuid.UUID
Revocation Revocation
}
CapabilityRevocation is a revocation entry and the capability it is of.
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
// 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
// ResourceURIs pins the capability to specific URIs. Used by
// hand-off flows where the parent passes exactly the artifacts the
// child needs.
ResourceURIs []string
// ResourceNamePrefixes restricts the capability to URIs that start with
// any entry, as strings (MatchResourceName): "drafts/billing-" covers
// "drafts/billing-q3" and "drafts/billing-q3/notes", where a
// ResourcePrefixes entry covers whole segments alone. An empty entry is
// invalid.
ResourceNamePrefixes []string
// Requests caps the total number of times the capability may be
// used within its TTL; the zero value allows none, and
// RequestCeiling{Unlimited: true} lifts the cap.
Requests RequestCeiling
// Budget is the cost budget the capability authorises, exact to the
// nano, in Budget.UnitCode — the unit every amount metered against the
// capability counts in. The zero value allows no spend and needs no
// unit: the capability counts requests (Meter.Bump) but no amount, and
// a Charge, Reserve or Settle on it with no unit is ErrInvalidRequest.
// BudgetCeiling{Unlimited: true, UnitCode: u} lifts the bound. Once it
// is exhausted, a charge returns ErrBudgetExceeded. A delegated child
// whose budget names no unit takes its parent's.
Budget BudgetCeiling
// Copies is how many Biscuit copies of the capability a meter counts —
// the copies whose blocks set request or budget limits of their own,
// each a counter the store keeps; the zero value counts none, and
// CopyQuota{Unlimited: true} as many as the store allows. A block that
// sets no limit makes no copy a meter counts, so it needs none.
Copies CopyQuota
// 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
// IdempotencyKeyRequired forces the bearer to send an explicit
// idempotency key on mutating operations (CheckRequest.Mutating).
// Enforced by Caveats.Check from CheckRequest.HasIdempotencyKey.
IdempotencyKeyRequired bool
// 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
}
Caveats is a typed bag of restrictions, AND-combined. Ops is required; an empty resource or source-network list places no restriction on its axis. The ceilings are explicit: a zero RequestCeiling or BudgetCeiling allows nothing, and only Unlimited lifts one.
It is written into a token as the limes_caveats claim (wire.go), 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 "." or ".." path segment, is never allowed by restricted caveats — an operation that cannot name what it touches cannot be shown to stay inside the scope.
Example ¶
package main
import (
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
caveats := limes.Caveats{
Ops: []limes.Op{limes.OpGet},
ResourcePrefixes: []string{"corpus/public/"},
ResourceURIs: []string{"reports/q3.pdf"},
}
fmt.Println(caveats.RestrictsResources())
fmt.Println(caveats.AllowsResource("corpus/public/a.txt"), caveats.AllowsResource("reports/q3.pdf"))
// A dot segment proves nothing about where it leads, so it matches nothing.
fmt.Println(caveats.AllowsResource("corpus/public/../private/a.txt"), caveats.AllowsResource(""))
// No resource caveat: any resource within the tenant.
open := limes.Caveats{Ops: []limes.Op{limes.OpGet}}
fmt.Println(open.RestrictsResources(), open.AllowsResource("anything"))
}
Output: true true true false false false true
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 meter (package meter).
Example ¶
package main
import (
"errors"
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
caveats := limes.Caveats{
Requests: limes.RequestCeiling{Unlimited: true},
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
Example (IdempotencyAndTaint) ¶
package main
import (
"errors"
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
caveats := limes.Caveats{
Ops: []limes.Op{limes.OpGet, limes.OpPut},
IdempotencyKeyRequired: true,
}
// A write without an idempotency key is refused; a read needs none.
err := caveats.Check(limes.CheckRequest{Op: limes.OpPut, Resource: "notes/1"})
fmt.Println(errors.Is(err, limes.ErrIdempotencyKeyRequired))
fmt.Println(caveats.Check(limes.CheckRequest{Op: limes.OpPut, Resource: "notes/1", HasIdempotencyKey: true}))
// A read of a resource the consumer flagged is refused unless allowed.
err = caveats.Check(limes.CheckRequest{Op: limes.OpGet, Resource: "inbox/7", ResourceTainted: true})
fmt.Println(errors.Is(err, limes.ErrTaintedReadNotAllowed))
}
Output: true <nil> 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".
Example ¶
package main
import (
"errors"
"fmt"
"net/netip"
"github.com/oleg-tkachuk/limes"
)
func main() {
caveats := limes.Caveats{Ops: []limes.Op{limes.OpGet}, SourceIPCIDR: []string{"10.0.0.0/8"}}
fmt.Println(caveats.CheckSource(netip.MustParseAddr("10.1.2.3")))
fmt.Println(errors.Is(caveats.CheckSource(netip.MustParseAddr("192.0.2.1")), limes.ErrSourceIPNotAllowed))
// An address the consumer could not observe fails closed.
fmt.Println(errors.Is(caveats.CheckSource(netip.Addr{}), limes.ErrSourceIPNotAllowed))
}
Output: <nil> true true
func (Caveats) RestrictsResources ¶
RestrictsResources reports whether the caveats confine the capability to a set of resources. False means "any resource within the tenant".
func (Caveats) Validate ¶
Validate reports whether the caveat bag is well formed. The issuer runs it before minting anything, root or delegated, so every signed capability carries caveats with one meaning.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
fmt.Println(limes.Caveats{Ops: []limes.Op{limes.OpGet, "tool:search"}}.Validate())
err := limes.Caveats{Ops: []limes.Op{limes.OpGet}, SourceIPCIDR: []string{"not-a-cidr"}}.Validate()
fmt.Println(errors.Is(err, limes.ErrInvalidCaveats), errors.Is(err, limes.ErrInvalidRequest))
}
Output: <nil> true true
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.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
write, _ := limes.CheckRequest{Op: limes.OpPut}.Mutating()
read, _ := limes.CheckRequest{Op: limes.OpList}.Mutating()
// A consumer-defined operation is a write unless the server declares it.
custom, _ := limes.CheckRequest{Op: "index:search"}.Mutating()
declared, _ := limes.CheckRequest{Op: "index:search", Effect: limes.EffectRead}.Mutating()
fmt.Println(write, read, custom, declared)
// A declaration may not change a built-in operation's effect.
_, err := limes.CheckRequest{Op: limes.OpPut, Effect: limes.EffectRead}.Mutating()
fmt.Println(errors.Is(err, limes.ErrEffectConflict))
}
Output: true false true false true
type Code ¶ added in v0.137.0
type Code string
Code is a stable, machine-readable name for any error limes returns (docs/adr/0012-error-codes.md, docs/errors.md): what a consumer puts on its wire, so a client branches on it rather than on a message, whose text is never a contract. A refusal of a presented capability has its Reason as its code; an error that Reason folds into a wider one keeps a code of its own (tenant_erased, reported as revoked). Codes are never renamed; new ones may be added.
const ( // Refusals Reason reports under a wider one, or not at all. CodeTenantErased Code = "tenant_erased" CodeDelegationTooDeep Code = "delegation_too_deep" CodeUnknownKID Code = "unknown_kid" CodeUnauthenticated Code = "unauthenticated" CodeProofOfPossession Code = "proof_of_possession" CodeCaveatViolation Code = "caveat_violation" CodeConflict Code = "conflict" CodeAlreadyExists Code = "already_exists" CodeUnitCodeMismatch Code = "unit_code_mismatch" CodeInvalidRequest Code = "invalid_request" CodeInvalidCaveats Code = "invalid_caveats" CodeInvalidAmount Code = "invalid_amount" CodeEffectConflict Code = "effect_conflict" CodeCursorMismatch Code = "cursor_mismatch" CodeDelegationTooWide Code = "delegation_too_wide" CodeNotFound Code = "not_found" CodeUnknownTenant Code = "unknown_tenant" CodeTenantDeleted Code = "tenant_deleted" CodeScopeMismatch Code = "scope_mismatch" CodeProofUnsupported Code = "proof_unsupported" CodeNoRevocationLineage Code = "no_revocation_lineage" CodeCopyCountersNotMetered Code = "copy_counters_not_metered" CodeCursorRepeated Code = "cursor_repeated" CodeInvalidConfig Code = "invalid_config" CodeInternal Code = "internal" )
The codes of the other errors.
type ContextSigner ¶ added in v0.14.0
type ContextSigner interface {
crypto.Signer
SignContext(ctx context.Context, rand io.Reader, message []byte, opts crypto.SignerOpts) ([]byte, error)
}
ContextSigner is a crypto.Signer that signs under a context, as a key held in a KMS or an HSM does for its call over the network. Signer uses SignContext when its key has it, so a slow or cancelled call is bounded by the request that asked for the token.
type CopyCeiling ¶
type CopyCeiling struct {
// RevocationID is the block's revocation id.
RevocationID []byte
// Requests bounds the requests made with the copy; nil when the block
// set no request limit of its own. Never Unlimited: a copy only narrows.
Requests *RequestCeiling
// Budget bounds the copy's spend, in the capability's unit, which its
// UnitCode carries; nil when the block set no budget of its own. Never
// Unlimited.
Budget *BudgetCeiling
}
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.
Example ¶
package main
import (
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
requests := limes.RequestCeiling{Max: 10}
copyLimit := limes.CopyCeiling{RevocationID: []byte{1}, Requests: &requests}
fmt.Println(copyLimit.AdmitsRequests(10), copyLimit.AdmitsRequests(11))
// With no budget of its own, the copy is bounded by the capability's alone.
fmt.Println(copyLimit.AdmitsBudget(limes.MaxNanos))
}
Output: true false true
func (CopyCeiling) AdmitsBudget ¶ added in v0.52.0
func (c CopyCeiling) AdmitsBudget(amount Nanos) bool
AdmitsBudget reports whether amount spent with the copy in all stays under its own budget; with none of its own, any amount does.
func (CopyCeiling) AdmitsRequests ¶ added in v0.52.0
func (c CopyCeiling) AdmitsRequests(n int64) bool
AdmitsRequests reports whether n requests made with the copy in all stay under its own request limit; with none of its own, any number does.
type CopyQuota ¶ added in v0.104.0
type CopyQuota struct {
// Max is the most copies counted; 0 counts none, so a copy with limits
// of its own is refused. Within 0..MaxIJSONInteger, and 0 when
// Unlimited.
Max int64
// Unlimited lifts the quota to the store's bound.
Unlimited bool
}
CopyQuota bounds how many Biscuit copies of a capability a meter counts — the copies that carry limits of their own, each a counter it keeps: at most Max, or as many as the store allows when Unlimited. It counts the copies of the capability and of every capability delegated from it, as its request and budget ceilings count theirs, so a delegated child does not multiply them, and goes on counting them once a child's usage is dropped. A store counts no more than its own bound per capability either (meter.DefaultMaxCopiesPerCapability), whatever the quota.
Example ¶
package main
import (
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
quota := limes.CopyQuota{Max: 3}
fmt.Println(quota, quota.Validate(), quota.Admits(3), quota.Admits(4))
// A store that holds at most two copies per capability applies two.
const storeBound = 2
fmt.Println(quota.Bounded(storeBound), limes.CopyQuota{Unlimited: true}.Bounded(storeBound))
fmt.Println(quota.Within(limes.CopyQuota{Max: 5}), quota.Within(limes.CopyQuota{}))
}
Output: 3 <nil> true false 2 2 true false
func (CopyQuota) Admits ¶ added in v0.106.0
Admits reports whether n copies in all stay within the quota.
func (CopyQuota) Bounded ¶ added in v0.104.0
Bounded is the quota a store holding at most bound copies applies: q, or bound when q allows more.
func (CopyQuota) String ¶ added in v0.104.0
String is the quota for logs and errors: its Max, or "unlimited".
type CountRecordsRequest ¶ added in v0.64.0
type CountRecordsRequest struct {
// TenantID is the tenant whose records are counted; AllTenants counts
// every tenant's instead. Exactly one of them is required.
TenantID uuid.UUID
AllTenants bool
// ExpiringWithin is the window ExpiringSoon counts in: an active record
// expiring within it of now. Zero counts none; negative is refused.
ExpiringWithin time.Duration
// ByTenant and ByPrincipalType group the counts by the record's tenant
// and by its subject's principal type as well.
ByTenant, ByPrincipalType bool
// Limit is the page size; see PageLimit. Negative is refused.
Limit int
// Cursor is the one the previous page returned; empty for the first.
Cursor string
}
CountRecordsRequest asks for one page of record counts.
func (CountRecordsRequest) Validate ¶ added in v0.64.0
func (r CountRecordsRequest) Validate() error
Validate refuses a request that names no tenant and not every tenant, or both, a negative window, or a negative page size.
type DelegateRequest ¶
type DelegateRequest struct {
// Parent is the capability the child narrows, as the verifier accepted
// it: only Verifier.Verify and Authorize make one.
Parent VerifiedCapability
// 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: Caveats with no Ops is refused, never
// read as "inherit", which would replace every other field the caller
// set — a budget of 2 would become 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
// ClientID is the OAuth client the child is granted to. Empty: the
// parent's.
ClientID 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. It errs away from the one // that tightens reads: a tainted resource is refused only to a read, so // a consumer-defined operation that reads and is left undeclared is // never held to AllowTaintedRead. Declare EffectRead for one. EffectUnspecified Effect = iota // EffectRead leaves state unchanged. EffectRead // EffectWrite may change state. EffectWrite )
type EraseTenantRequest ¶ added in v0.60.0
type EraseTenantRequest struct {
TenantID uuid.UUID
// Note and Actor are written on every revocation entry, as
// RevokeRequest's are.
Note string
Actor string
}
EraseTenantRequest names the tenant EraseTenantRecords erases and who asked, recorded on each revocation it writes.
func (EraseTenantRequest) Validate ¶ added in v0.60.0
func (r EraseTenantRequest) Validate() error
Validate refuses a request naming no tenant.
type EraseTenantResult ¶ added in v0.60.0
type EraseTenantResult struct {
// Revoked counts the revocation entries it wrote: one for each of the
// tenant's capabilities that had none of its own — one revoked only
// through an ancestor included — whether or not its record was then
// removed.
Revoked int64
// Removed counts the records it removed; Kept, those it left on record
// for Purge.
Removed int64
Kept int64
// contains filtered or unexported fields
}
EraseTenantResult is what EraseTenantRecords did.
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. A use_dpop_nonce challenge always comes with it:
// when the nonce cannot be given, the refusal is answered as the
// server's failure instead (RFC 9449 §9).
DPoPNonce string
// Problem is the body.
Problem Problem
}
ErrorResponse is how to answer a refused request.
type ExchangeKey ¶ added in v0.134.0
type ExchangeKey struct {
TenantID uuid.UUID
// Issuer is the subject token's iss, and TokenID its jti.
Issuer, TokenID string
}
ExchangeKey names one subject token an exchange issues one capability for (docs/adr/0009-one-capability-per-subject-token.md): its issuer and jti, within the tenant the capability is issued in.
func (ExchangeKey) Validate ¶ added in v0.134.0
func (k ExchangeKey) Validate() error
Validate refuses a key without a tenant, an issuer or a token id.
type ExchangeStore ¶ added in v0.134.0
type ExchangeStore interface {
// ExchangedFor is the capability recorded for key, or ErrNotFound when
// there is none. It refuses what ExchangeKey.Validate refuses.
ExchangedFor(ctx context.Context, key ExchangeKey) (uuid.UUID, error)
// ClaimExchange records id as the capability issued for key, and
// returns it with claimed true. When key holds a capability already it
// records nothing and returns that one with claimed false, so concurrent
// claims of one key converge on one id. id must be on record in key's
// tenant: ErrNotFound for one that is not, or is another tenant's. The
// mapping goes with its capability when Purge removes it. It refuses
// what ExchangeKey.Validate refuses.
ClaimExchange(ctx context.Context, key ExchangeKey, id uuid.UUID) (recorded uuid.UUID, claimed bool, err error)
}
ExchangeStore records which capability exchanging a subject token issued, so that one subject token yields one capability across every replica. It holds the capability's id and nothing else: no token is stored, and the token is signed again from the record (Issuer.Reissue).
type InsertRequest ¶ added in v0.15.0
type InsertRequest struct {
Capability Capability
IssuedBy Principal
// Session is the identity provider's session the capability was issued
// under, such as an OIDC sid: issuance metadata, beside IssuedBy, never
// a claim, which BulkRevoker revokes by. Empty for none. A child has
// none of its own: the store records its parent's.
Session string
}
InsertRequest is a capability to record, and who asked for it.
func (InsertRequest) Validate ¶ added in v0.53.0
func (r InsertRequest) Validate() error
Validate refuses, as ErrInvalidRequest, a capability with no id — nothing could read it back, and the zero id is what a root's ParentID holds — and one with no expiry: no verifier accepts it, Purge never removes it, and a child under it would outlive nothing.
type Introspection ¶ added in v0.3.0
type Introspection struct {
// Capability is the capability the token carries.
Capability Capability
// FromBiscuit reports that the token is a Biscuit.
FromBiscuit bool
// Copies are the limits the token's Biscuit blocks set on this copy of
// the capability, as VerifiedCapability.Copies has them: a copy is held
// to these as well as to the capability's caveats. Empty for a JWT and a
// Biscuit whose blocks set none.
Copies []CopyCeiling
// contains filtered or unexported fields
}
Introspection is what Introspect says of an active token.
type IssuanceStore ¶ added in v0.15.0
type IssuanceStore 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.
//
// req.IssuedBy is the principal that ASKED for the capability, which
// is not its Subject (the principal it authorises) and not its Issuer
// (the service that minted it). A delegated capability is requested by
// its parent's holder; a root one by an operator. It is issuance
// metadata, not a claim, so it travels beside the Capability rather
// than in it, which would change the token's frozen wire format.
//
// An id already on record returns ErrAlreadyExists and changes nothing.
// It refuses what InsertRequest.Validate refuses, a parent not on
// record as ErrInvalidRequest, a child expiring after its parent or in
// another tenant as
// ErrDelegationTooWide, a child whose budget names another unit than its
// parent's as ErrUnitCodeMismatch — metering a child spends from every
// ancestor, so a lineage counts in one unit — and a child that would have
// more than MaxDelegationDepth ancestors still on record — counted as
// IsRevoked counts them — as ErrDelegationTooDeep: Purge and the
// IsRevoked walk rely on a lineage that is whole, no deeper than the
// walk reaches, expires from the leaves up and stays in one tenant, so a
// store that scopes rows by tenant sees all of it. A refused record is
// not written. A
// store that knows tenants returns ErrUnknownTenant or ErrTenantDeleted
// for a tenant it cannot mint for. A store beside a meter.TenantUsageEraser
// refuses a tenant whose usage it erased as ErrTenantErased, until
// PurgeOrphans forgets the mark.
Insert(ctx context.Context, req InsertRequest) 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),
// bounded at MaxDelegationDepth ancestors: a capability with more
// still on record returns ErrDelegationTooDeep, never false. An id not
// on record is not revoked.
IsRevoked(ctx context.Context, id uuid.UUID) (bool, error)
}
IssuanceStore is what an Issuer needs of storage: to record what it mints, and to know whether a parent it delegates from has been revoked.
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. Its Subject is
// required — a capability nobody requested cannot be audited — and the
// rest is recorded as given: an operator need not be a tenant's
// principal.
IssuedBy Principal
Audience []string
Caveats Caveats
TTL time.Duration // 0 → DefaultTTL; must reach the next whole second
NotBefore time.Time // zero → valid from issuance, no nbf claim
// ConfirmationJKT binds the capability to the key with this RFC 7638
// thumbprint (dpop.Thumbprint); empty issues a bearer capability.
ConfirmationJKT string
// ClientID is the OAuth client the capability is granted to
// (Capability.ClientID): the client of the access token it was exchanged
// for, or the software the subject runs as. Required.
ClientID string
// OnBehalfOf is the principal Subject acts for (Capability.OnBehalfOf),
// of Subject's tenant; nil when Subject acts for itself.
OnBehalfOf *Principal
// Session is the identity provider's session the capability is issued
// under, such as the OIDC sid of the token it was exchanged for:
// recorded beside the capability, never a claim, so that every
// capability of a session can be revoked together (BulkRevoker). Its
// delegations take it. Empty for none.
Session string
}
IssueRequest is the input shape for Issuer.Issue. Subject (with its TenantID), IssuedBy, Audience and Caveats are required; TTL, NotBefore and ConfirmationJKT are optional, and the issuer fills in ID, IssuedAt, ExpiresAt and Issuer. Caveats are validated (Caveats.Validate) before anything is persisted.
type IssueResult ¶ added in v0.46.0
type IssueResult struct {
Capability Capability
Token string
// contains filtered or unexported fields
}
IssueResult is what Issue and Delegate mint: the capability as recorded, to log or display, and its compact-form token, to ship to the agent.
type Issuer ¶
type Issuer struct {
// contains filtered or unexported fields
}
Issuer mints capabilities, in this order:
- Validate the request (caveats sane, parent narrowing if delegated).
- Sign the token, then persist the record, and only then return the token.
Signing first means a capability whose token cannot be minted — a signer that fails, or a token past MaxTokenBytes — is never recorded. The token is returned only once its record is written: if the store write fails, the signed token is dropped, so no token leaves the issuer that audit could not find or Revoke, which refuses an id not on record, could not stop.
func NewIssuer ¶
func NewIssuer(cfg IssuerConfig) (*Issuer, error)
NewIssuer validates wiring and returns a ready Issuer.
Example ¶
// Wiring is checked once, at construction, rather than on the first request.
_, err := limes.NewIssuer(limes.IssuerConfig{IssuerName: exampleIssuerName})
fmt.Println(err != nil)
Output: true
func (*Issuer) Biscuit ¶
func (i *Issuer) Biscuit(ctx context.Context, req BiscuitRequest) (BiscuitResult, error)
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.
Like Delegate it takes a VerifiedCapability, so what it signs is what this issuer signed and recorded before, never a value a caller assembled or widened. It refuses one another issuer named, one read from a Biscuit — wrapping a copy again would shed its attenuation and its revocation id; its holder attenuates it instead — and one that has expired or that the store reports revoked.
It needs no OpShare: a copy attenuated from the form allows no more than the capability, which its bearer could hand on as it is, and a copy that sets limits of its own is counted against Caveats.Copies. Delegate, which has the issuer mint a new principal, still requires OpShare.
func (*Issuer) Delegate ¶
func (i *Issuer) Delegate(ctx context.Context, req DelegateRequest) (IssueResult, error)
Delegate mints a child capability narrower than the supplied parent, which the verifier accepted (VerifiedCapability), so its caveats are the ones its token carries. 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 (VerifiedCapability.FromBiscuit), which is ErrInvalidRequest, as Issuer.Biscuit answers it — its holder attenuates it instead. A parent another issuer named is ErrInvalidRequest: this issuer's store holds no record of it, so revoking it there would never reach the child. A child that would have more than MaxDelegationDepth ancestors is ErrDelegationTooDeep, refused by Store.Insert before it is on record. A parent whose caveats do not allow OpShare — the right to delegate — is ErrOpNotAllowed: delegating is the share operation, and a capability granted without it may not have the issuer mint a new principal any part of it.
Example ¶
package main
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"log"
"time"
"uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/dpop"
"github.com/oleg-tkachuk/limes/jwks"
"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.Verifier
records *memstore.Store
usage *memstore.UsageStore
tenantID uuid.UUID
}
// exampleClientID is the OAuth client the examples' capabilities are granted
// to (RFC 9068 client_id): the software the agent runs as.
const exampleClientID = "example-agent"
func newExampleStack() exampleStack {
key, err := limes.GenerateKey()
kid, pub, priv := key.KeyID, key.Public(), key.Private
if err != nil {
log.Fatal(err)
}
records, err := memstore.NewStore(memstore.StoreConfig{})
if err != nil {
log.Fatal(err)
}
usage, err := memstore.NewUsageStore(memstore.UsageStoreConfig{Records: records})
if err != nil {
log.Fatal(err)
}
signer, err := limes.NewSigner(limes.SignerConfig{KeyID: kid, Key: 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)
}
replay, err := dpop.NewMemoryReplayCache(dpop.MemoryReplayCacheConfig{})
if err != nil {
log.Fatal(err)
}
proofs, err := dpop.NewVerifier(dpop.VerifierConfig{Replay: replay})
if err != nil {
log.Fatal(err)
}
verifier, err := limes.NewVerifier(limes.VerifierConfig{
Keys: exampleKeys(kid, pub),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
DPoP: proofs,
})
if err != nil {
log.Fatal(err)
}
return exampleStack{
issuer: issuer, verifier: verifier, records: records,
usage: usage, tenantID: uuid.New(),
}
}
// verified is the capability token grants, as the verifier accepts it.
func (s exampleStack) verified(token string) limes.VerifiedCapability {
vc, err := s.verifier.Verify(context.Background(), limes.VerifyRequest{Token: token, Audience: exampleAudience})
if err != nil {
log.Fatal(err)
}
return vc
}
// 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) {
cResult, err := s.issuer.Issue(context.Background(), limes.IssueRequest{
ClientID: exampleClientID,
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalTypeAgent, 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/"},
Requests: limes.RequestCeiling{Max: 500},
Budget: limes.BudgetCeiling{Max: limes.MustParseAmount("25.00"), UnitCode: "USD"},
},
})
c, token := cResult.Capability, cResult.Token
if err != nil {
log.Fatal(err)
}
return c, token
}
func main() {
ctx := context.Background()
s := newExampleStack()
_, token := s.issue()
// The orchestrator presents its token; the parent is what the verifier
// accepted.
parent := s.verified(token)
worker := limes.Principal{Type: limes.PrincipalTypeAgent, TenantID: s.tenantID, Subject: "sub-worker"}
childResult, 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/"},
Requests: limes.RequestCeiling{Max: 50},
Budget: limes.BudgetCeiling{Max: limes.MustParseAmount("2.00"), UnitCode: "USD"},
},
})
child := childResult.Capability
fmt.Println(err, child.ParentID == parent.Capability().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{
Requests: limes.RequestCeiling{Unlimited: true},
Ops: []limes.Op{limes.OpGet},
Budget: limes.BudgetCeiling{Max: limes.MustParseAmount("999"), UnitCode: "USD"},
},
})
fmt.Println(errors.Is(err, limes.ErrDelegationTooWide))
}
// exampleKeys is a jwks.Static holding pub under kid.
func exampleKeys(kid string, pub ed25519.PublicKey) *jwks.Static {
keys, err := jwks.NewStatic(jwks.StaticConfig{Keys: map[string]ed25519.PublicKey{kid: pub}})
if err != nil {
log.Fatal(err)
}
return keys
}
Output: <nil> true true
func (*Issuer) Issue ¶
func (i *Issuer) Issue(ctx context.Context, req IssueRequest) (IssueResult, error)
Issue mints a top-level capability.
Example ¶
package main
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"log"
"time"
"uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/jwks"
"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"
)
// exampleClientID is the OAuth client the examples' capabilities are granted
// to (RFC 9068 client_id): the software the agent runs as.
const exampleClientID = "example-agent"
func main() {
ctx := context.Background()
key, _ := limes.GenerateKey()
kid, pub, priv := key.KeyID, key.Public(), key.Private
records, _ := memstore.NewStore(memstore.StoreConfig{}) // your Store
signer, _ := limes.NewSigner(limes.SignerConfig{KeyID: kid, Key: priv})
issuer, _ := limes.NewIssuer(limes.IssuerConfig{
Signer: signer, Store: records, IssuerName: exampleIssuerName,
})
verifier, _ := limes.NewVerifier(limes.VerifierConfig{
Keys: exampleKeys(kid, pub),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
})
cResult, err := issuer.Issue(ctx, limes.IssueRequest{
ClientID: exampleClientID,
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalTypeAgent, TenantID: uuid.New(), Subject: "research-orchestrator",
},
Audience: []string{exampleAudience},
TTL: 15 * time.Minute,
Caveats: limes.Caveats{
Requests: limes.RequestCeiling{Unlimited: true},
Ops: []limes.Op{limes.OpGet},
ResourcePrefixes: []string{"corpus/public/"},
Budget: limes.BudgetCeiling{Max: limes.MustParseAmount("25.00"), UnitCode: "USD"},
},
})
c, token := cResult.Capability, cResult.Token
if err != nil {
log.Fatal(err)
}
got, err := verifier.Verify(ctx, limes.VerifyRequest{Token: token, Audience: exampleAudience})
fmt.Println(err == nil, got.Capability().ID == c.ID)
_, err = verifier.Verify(ctx, limes.VerifyRequest{Token: token, Audience: "another-service"})
fmt.Println(errors.Is(err, limes.ErrAudienceMismatch))
}
// exampleKeys is a jwks.Static holding pub under kid.
func exampleKeys(kid string, pub ed25519.PublicKey) *jwks.Static {
keys, err := jwks.NewStatic(jwks.StaticConfig{Keys: map[string]ed25519.PublicKey{kid: pub}})
if err != nil {
log.Fatal(err)
}
return keys
}
Output: true true true
func (*Issuer) Reissue ¶ added in v0.134.0
Reissue signs the capability on record under id again, and records nothing: an exchange that issues one capability per subject token hands it out again without keeping its token (ExchangeStore). The claims are the record's, so under the same key the token is the one Issue returned. It refuses an id not on record (ErrNotFound), a capability expired (ErrExpired) or revoked (ErrRevoked), and an issuer whose store reads no record (a RecordReader, as every Store is) with ErrInvalidRequest.
type IssuerConfig ¶
type IssuerConfig struct {
// Signer mints tokens (NewSigner), over a key in memory or one held in
// a KMS or an HSM.
Signer *Signer
// Store records what the issuer mints and answers whether a parent is
// revoked. Any Store is one.
Store IssuanceStore
// 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.
// Zero: DefaultIssueTTL, short enough that revocation propagation
// latency rarely matters. Negative is refused.
DefaultTTL time.Duration
// MaxTokenBytes refuses to mint a token longer than this, which a
// verifier with the same limit (VerifierConfig.MaxTokenBytes) would
// refuse unread. Zero: DefaultMaxTokenBytes; negative is refused.
MaxTokenBytes int
// Now is the clock; tests override.
Now func() time.Time
// Biscuit is the form Issuer.Biscuit seals a capability in:
// github.com/oleg-tkachuk/limes/biscuit's Form. Nil: Issuer.Biscuit
// refuses, and this issuer mints JWTs alone.
Biscuit TokenForm
}
IssuerConfig is the wiring for NewIssuer.
type KeyResolver ¶
type KeyResolver interface {
// PublicKey returns the Ed25519 public key for the given kid.
// Returns jwks.ErrUnknownKID if the key set does not contain the kid,
// which the verifier reads as the token's fault (ErrInvalidToken).
// Any other error is the resolver's: the verifier refuses the token
// with it as it is, the server's fault rather than the token's.
PublicKey(ctx context.Context, kid string) (ed25519.PublicKey, error)
}
KeyResolver resolves a JWKS `kid` to the public key the verifier should use. jwks.Static holds keys in memory; jwks.Remote fetches them from an issuer's JWKS endpoint with a cache.
Keys are Ed25519 for all of 1.x: the token format, not the key, names the algorithm, so another algorithm would be another format (limes_v) rather than a key this interface returns.
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 — at their ExpiresAt, by the store's clock — 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 int
}
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 ListOrder ¶ added in v0.111.0
type ListOrder uint8
ListOrder is the id order a list pages in.
type ListRecordsRequest ¶ added in v0.111.0
type ListRecordsRequest struct {
// TenantID is the tenant whose records are listed; required.
TenantID uuid.UUID
// PrincipalType lists the records issued to principals of that type;
// unspecified, every type's.
PrincipalType PrincipalType
// Subject lists the records issued to that subject exactly, and
// SubjectPrefix those whose subject starts with it, bytewise; at most
// one of them. Empty, every subject's.
Subject, SubjectPrefix string
// Dispositions lists the records of any of these dispositions; empty,
// every record.
Dispositions []RecordDisposition
// Order is the id order of the pages; the zero value is ascending.
Order ListOrder
// Cursor is the one the previous page returned; empty for the first.
Cursor string
// Limit is the page size; see PageLimit. Negative is refused.
Limit int
}
ListRecordsRequest asks for one page of a tenant's records. Every filter but TenantID is optional, and the ones set must all hold.
func (ListRecordsRequest) Admits ¶ added in v0.111.0
func (r ListRecordsRequest) Admits(d RecordDisposition) bool
Admits reports whether a record of disposition d is one r lists: r names no disposition, or names d.
func (ListRecordsRequest) Validate ¶ added in v0.111.0
func (r ListRecordsRequest) Validate() error
Validate refuses a request that names no tenant, an unknown principal type, both a subject and a subject prefix, an unknown disposition or order, or a negative page size. Every error matches ErrInvalidRequest.
type MeterSubject ¶ added in v0.17.0
type MeterSubject struct {
// contains filtered or unexported fields
}
MeterSubject is what a Meter counts a request against: a verified capability's id and tenant, its request and budget ceilings, its unit and the limits of the Biscuit copy presented. Only VerifiedCapability.MeterSubject and StoredMeterSubject make one, so a ceiling is the one a verified token carries or a RecordReader reads back — never a value a caller sets on the subject itself. A store reads it through its methods. The zero MeterSubject names no capability and no tenant; a Meter refuses it with ErrInvalidRequest rather than count it anywhere.
func StoredMeterSubject ¶ added in v0.33.0
func StoredMeterSubject(ctx context.Context, records RecordReader, capabilityID uuid.UUID) (MeterSubject, error)
StoredMeterSubject is what the Meter counts a capability against for a cost reported with no token in hand, read from the capability's record — records is the Store, or anything reading its records. It reads the record itself rather than take a Record, whose fields any caller can set, so the ceilings are the ones on record. A stored record names no Biscuit copy, so the subject carries none. A revoked capability still gives one: a cost already incurred belongs in the ledger. ErrNotFound when the capability is not on record.
Example ¶
ctx := context.Background() s := newExampleStack() c, _ := s.issue() // A cost reported with no token in hand counts against the ceilings on record. subject, err := limes.StoredMeterSubject(ctx, s.records, c.ID) fmt.Println(err, subject.Budget()) _, err = limes.StoredMeterSubject(ctx, s.records, uuid.New()) fmt.Println(errors.Is(err, limes.ErrNotFound))
Output: <nil> 25 USD true
func (MeterSubject) Budget ¶ added in v0.27.0
func (s MeterSubject) Budget() BudgetCeiling
Budget is the capability's own budget ceiling.
func (MeterSubject) CapabilityID ¶ added in v0.17.0
func (s MeterSubject) CapabilityID() uuid.UUID
CapabilityID is the capability the request counts against.
func (MeterSubject) Copies ¶ added in v0.17.0
func (s MeterSubject) Copies() []CopyCeiling
Copies are the limits of the Biscuit copy presented, a copy of them.
func (MeterSubject) CopyQuota ¶ added in v0.104.0
func (s MeterSubject) CopyQuota() CopyQuota
CopyQuota is how many of the capability's Biscuit copies a meter counts (Caveats.Copies).
func (MeterSubject) Requests ¶ added in v0.27.0
func (s MeterSubject) Requests() RequestCeiling
Requests is the capability's own request ceiling.
func (MeterSubject) TenantID ¶ added in v0.17.0
func (s MeterSubject) TenantID() uuid.UUID
TenantID is the capability's tenant: its aggregate ceiling applies, and the ledger files the charge under it.
func (MeterSubject) UnitCode ¶ added in v0.17.0
func (s MeterSubject) UnitCode() string
UnitCode is the unit the capability counts in, its budget's; empty when that names none, and then it counts requests alone.
func (MeterSubject) Validate ¶ added in v0.100.0
func (s MeterSubject) Validate() error
Validate refuses a subject that names no capability or no tenant — the zero MeterSubject, a request that set none — or an unknown unit. A metering request's Validate calls it.
func (MeterSubject) ValidateAmounts ¶ added in v0.100.0
func (s MeterSubject) ValidateAmounts() error
ValidateAmounts is Validate for a request that moves an amount, which needs a unit to count it in: no unit is ever assumed.
type Nanos ¶
type Nanos int64
Nanos is an amount of a currency or metering unit (see BudgetCeiling.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 Nanos.Validate refuses, and NaN and the infinities.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
// A float price written with at most nine decimals converts exactly.
price, err := limes.AmountFromFloat(0.35)
fmt.Println(price, int64(price), err)
// A float sum, 0.30000000000000004, rounds to the nearest nano.
a, b := 0.1, 0.2
sum, _ := limes.AmountFromFloat(a + b)
fmt.Println(sum)
_, err = limes.AmountFromFloat(-1)
fmt.Println(errors.Is(err, limes.ErrInvalidAmount))
}
Output: 0.35 350000000 <nil> 0.3 true
func MustParseAmount ¶
MustParseAmount is ParseAmount for an amount written into the program, a price or a test value: it panics on one ParseAmount refuses.
Example ¶
package main
import (
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
// For an amount written into the program: it panics on a malformed one.
price := limes.MustParseAmount("0.002")
fmt.Println(price, price*3, int64(price))
}
Output: 0.002 0.006 2000000
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.
Example ¶
package main
import (
"encoding/json"
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
type price struct {
Amount limes.Nanos `json:"amount"`
}
b, _ := json.Marshal(price{Amount: limes.MustParseAmount("0.1")})
fmt.Println(string(b))
// A JSON number is refused: it may already have passed through a float.
var p price
fmt.Println(json.Unmarshal([]byte(`{"amount":"12.5"}`), &p), p.Amount)
fmt.Println(json.Unmarshal([]byte(`{"amount":12.5}`), &p) != nil)
}
Output: {"amount":"0.1"} <nil> 12.5 true
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".
Example ¶
package main
import (
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
fmt.Println(limes.Nanos(1_500_000_000), limes.Nanos(1), limes.Nanos(25*limes.NanosPerUnit))
// Float64 is for display and metrics only.
fmt.Println(limes.MustParseAmount("1.25").Float64())
}
Output: 1.5 0.000000001 25 1.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.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
// Configuration — a YAML scalar, an environment variable — reads exactly.
var budget limes.Nanos
fmt.Println(budget.UnmarshalText([]byte("25.00")), budget)
fmt.Println(errors.Is(budget.UnmarshalText([]byte("-1")), limes.ErrInvalidAmount))
}
Output: <nil> 25 true
func (Nanos) Validate ¶ added in v0.12.0
Validate reports whether n is usable as a charge or refund amount: not negative and at most MaxNanos. Implementations call it before touching state.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
fmt.Println(limes.MaxNanos.Validate())
fmt.Println(errors.Is(limes.Nanos(-1).Validate(), limes.ErrInvalidAmount))
}
Output: <nil> true
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 OpManage Op = "manage" // administer: tenants, policy )
The built-in operations: the ones every resource server shares. Get and List read; the rest may change state (Op.ResolveEffect). A domain's own operations — searching an index, computing embeddings — are the consumer's, namespaced: "index:search", "model:embed".
func (Op) Builtin ¶
Builtin reports whether op is one of the operations this package defines.
Example ¶
package main
import (
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
fmt.Println(limes.OpShare.Builtin(), limes.Op("tool:search").Builtin())
}
Output: true false
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.
Example ¶
package main
import (
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
fmt.Println(limes.OpGet.ResolveEffect(limes.EffectUnspecified))
fmt.Println(limes.Op("model:embed").ResolveEffect(limes.EffectUnspecified))
fmt.Println(limes.Op("model:embed").ResolveEffect(limes.EffectRead))
}
Output: read <nil> write <nil> read <nil>
func (Op) Validate ¶
Validate reports whether op is well formed: a built-in operation, or a namespaced one of the form "<namespace>:<name>" made of printable, non-space ASCII.
Example ¶
package main
import (
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
fmt.Println(limes.OpGet.Validate(), limes.Op("mcp:github/create_issue").Validate())
fmt.Println(limes.Op("search").Validate())
}
Output: <nil> <nil> limes: invalid caveats: unknown op "search": the built-in ops are get, put, list, delete, share, manage; a custom op is namespaced, e.g. "tool:search"
type Page ¶ added in v0.15.0
Page is one page of a list: its items, and the cursor that fetches the next page — empty on the last one. The cursor is the store's own, opaque to the caller.
type Principal ¶
type Principal struct {
// Type discriminates the principal kind.
Type PrincipalType
// TenantID is required for every principal — capability without
// tenant scope is malformed and the verifier rejects it.
TenantID uuid.UUID
// Subject is the stable identity (user UUID, agent run ID, service
// account name). Format depends on Type.
Subject string
// Agent fields are populated only for Type == PrincipalTypeAgent.
Agent *AgentPrincipal
// contains filtered or unexported fields
}
Principal identifies the subject the capability is issued to. It is written into a token as the limes_principal claim (wire.go). Agent principals have a richer shape than service principals: they carry the run / tool-call lineage that audit and cost attribution need.
func (Principal) Clone ¶ added in v0.24.0
Clone is p with agent details of its own: a store keeps, and hands out, a principal no caller can alter through a pointer it kept.
func (Principal) Validate ¶ added in v0.8.0
Validate reports whether p names a principal a capability can be issued to: one of the principal kinds, spelled as the constants spell it, and agent details only on an agent. The kind is part of the token, so a kind outside the set would be signed and then read by every verifier as it pleased.
Example ¶
package main
import (
"errors"
"fmt"
"uuid"
"github.com/oleg-tkachuk/limes"
)
func main() {
agent := limes.Principal{
Type: limes.PrincipalTypeAgent, TenantID: uuid.New(), Subject: "research-orchestrator",
Agent: &limes.AgentPrincipal{AgentType: "researcher", Model: "example-model-1"},
}
fmt.Println(agent.Validate())
// Agent details belong to an agent only.
service := agent
service.Type = limes.PrincipalTypeService
fmt.Println(errors.Is(service.Validate(), limes.ErrInvalidRequest))
fmt.Println(errors.Is(limes.PrincipalType("robot").Validate(), limes.ErrInvalidRequest))
}
Output: <nil> true true
type PrincipalType ¶
type PrincipalType string
PrincipalType discriminates Principal.Type.
const ( PrincipalTypeUnspecified PrincipalType = "" PrincipalTypeUser PrincipalType = "user" // a person PrincipalTypeAgent PrincipalType = "agent" // an AI agent; Principal.Agent says which PrincipalTypeService PrincipalType = "service" // a service account )
The principal kinds. The zero value, PrincipalTypeUnspecified, names none and is refused wherever a kind is required.
func (PrincipalType) Validate ¶ added in v0.21.0
func (t PrincipalType) Validate() error
Validate reports whether t is one of the principal kinds, spelled as the constants spell it. Every error matches ErrInvalidRequest.
type Problem ¶ added in v0.3.0
type Problem struct {
// Type is a URI naming the kind of problem: the refusal's Reason after
// a fixed base, or for a refusal with none the family it belongs to.
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"`
// Reason is the refusal's Reason, an extension member (RFC 9457 §3.2):
// the stable name a client branches on, which the Type URI ends with.
// Empty for a refusal with none.
Reason Reason `json:"reason,omitempty"`
// Code is the error's Code, an extension member: its Reason, or the
// narrower code Reason folds into it (docs/errors.md).
Code Code `json:"code,omitempty"`
// contains filtered or unexported fields
}
Problem is an RFC 9457 problem details object.
func ProblemFor ¶ added in v0.138.0
ProblemFor is ProblemOf for any error: one that is not limes's — a store's own failure, a consumer's — is answered as internal, 500, so every error a consumer passes has a code and a body.
func ProblemOf ¶ added in v0.137.0
ProblemOf is the RFC 9457 problem details of err, an error limes returned, for a consumer's own API, whatever its transport: its type URI, a fixed title, its HTTP status, its code and, for a refusal, its Reason. A refusal's is the body Verifier.ErrorResponse writes. It carries no detail: an error's text can name kids, issuers or stores, which is for the server's log. False for an error that is not limes's: answer that as the server's failure.
type PurgeRequest ¶ added in v0.15.0
type PurgeRequest struct {
// ExpiredFor is how long a capability must have been expired to go.
// Zero: MinPurgeRetention; anything shorter, negative included, is
// refused.
ExpiredFor time.Duration
// Limit bounds the records removed. Zero: DefaultHousekeepingLimit; negative
// is refused.
Limit int
}
PurgeRequest is one batch of Store.Purge.
func (PurgeRequest) Retention ¶ added in v0.23.0
func (r PurgeRequest) Retention() time.Duration
Retention is how long a capability must have been expired to go: ExpiredFor, or MinPurgeRetention when it is zero.
func (PurgeRequest) Validate ¶ added in v0.15.0
func (r PurgeRequest) Validate() error
Validate refuses a retention shorter than MinPurgeRetention, other than zero, and a negative limit.
type PurgeResult ¶ added in v0.15.0
type PurgeResult struct {
// Purged is the number of capability records removed.
Purged int64
// contains filtered or unexported fields
}
PurgeResult is what one Purge removed.
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 ( ReasonNoCredentials Reason = "no_credentials" ReasonMalformedAuthorization Reason = "malformed_authorization" ReasonInvalidToken Reason = "invalid_token" ReasonExpired Reason = "expired" ReasonNotYetValid Reason = "not_yet_valid" ReasonRevoked Reason = "revoked" ReasonAudienceMismatch Reason = "audience_mismatch" ReasonBiscuitAttenuation Reason = "biscuit_attenuation" ReasonProofRequired Reason = "proof_required" ReasonNonceRequired Reason = "nonce_required" ReasonAuthSchemeMismatch Reason = "auth_scheme_mismatch" ReasonProofInvalid Reason = "proof_invalid" ReasonProofReplayed Reason = "proof_replayed" ReasonOpNotAllowed Reason = "op_not_allowed" ReasonResourceNotAllowed Reason = "resource_not_allowed" ReasonSourceIPNotAllowed Reason = "source_ip_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. The one member of that family that is a refusal, ErrMalformedAuthorization, has its reason.
Example ¶
package main
import (
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
reason, ok := limes.ReasonOf(fmt.Errorf("charge: %w", limes.ErrBudgetExceeded))
fmt.Println(reason, ok, reason.Permanent())
reason, ok = limes.ReasonOf(limes.ErrRevoked)
fmt.Println(reason, ok, reason.Permanent())
// A programming error is not a refusal of the capability.
_, ok = limes.ReasonOf(limes.ErrInvalidRequest)
fmt.Println(ok)
}
Output: budget_exceeded true false revoked true true false
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.
Example ¶
package main
import (
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
// A consumer proves its own wire mapping covers every reason.
mapped := map[limes.Reason]int{limes.ReasonRevoked: 401}
missing := 0
for _, r := range limes.Reasons() {
if _, ok := mapped[r]; !ok {
missing++
}
}
fmt.Println(len(limes.Reasons()), missing)
}
Output: 22 21
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
// Session is the identity provider's session on record: the one Insert
// was given, or for a child its parent's. Empty for none, and for a
// store that records none.
Session string
// 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 RecordCensus ¶ added in v0.64.0
type RecordCensus interface {
// CountRecords returns one page of counts, each one group's: the
// records of req.TenantID, or of every tenant with req.AllTenants,
// grouped by tenant and by principal type when req asks, in that order,
// each ascending. A group with no record has no row, so the census of
// no record is an empty page.
CountRecords(ctx context.Context, req CountRecordsRequest) (Page[RecordCount], error)
}
RecordCensus counts capability records by what they are now — active, expired or revoked — for a platform operator's report: the records of one tenant or of every tenant, whole or by tenant and by principal type, with how many expire soon. It reads the store a consumer issues into, so a report counts through it rather than through a store's tables, whose columns are the store's own; a view over those tables could freeze its columns, but not take the store's clock or share these rules with every store. It is a type of its own, beside Store: a reporter holds it, an issuer does not. memstore and pgstore each ship one; storetest checks it when its Env sets one. A RecordLister lists the records it counts, each under the disposition it counts it under.
Each record counts under exactly one disposition, by the store's clock, the one expiry and purging are measured by:
- Revoked: the record, or an ancestor of it still on record, is revoked — what Store.IsRevoked answers, so a child of a revoked parent is revoked here, unlike ListByPrincipalRequest.IncludeRevoked, which reads a record's own entry. Revoked wins over expired.
- Expired: not revoked, and its ExpiresAt is not after now, or zero (RecordDispositionExpired). A child expires no later than its parent (Store.Insert), so no ancestor need be read for it.
- Active: neither.
A Biscuit copy's revocation is not a record's and counts nowhere.
type RecordCount ¶ added in v0.64.0
type RecordCount struct {
TenantID uuid.UUID
PrincipalType PrincipalType
// Total is every record of the group: Active, Expired and Revoked
// together, each record in one of them.
Total, Active, Expired, Revoked int64
// Delegated counts the group's records with a parent, whatever their
// disposition.
Delegated int64
// ExpiringSoon counts the active records expiring within the
// request's ExpiringWithin of now: a part of Active.
ExpiringSoon int64
// contains filtered or unexported fields
}
RecordCount is one group's counts. The fields a request did not group by are zero.
type RecordDisposition ¶ added in v0.111.0
type RecordDisposition string
RecordDisposition is what a record is now: RecordLister's and RecordCensus's three, under which each record falls exactly once.
const ( RecordDispositionUnspecified RecordDisposition = "" RecordDispositionActive RecordDisposition = "active" RecordDispositionExpired RecordDisposition = "expired" RecordDispositionRevoked RecordDisposition = "revoked" )
The dispositions. The zero value, RecordDispositionUnspecified, names none.
RecordDispositionExpired takes a record whose ExpiresAt is zero too: no expiry is not "never expires". Store.Insert refuses a capability without one, so such a record was written around it — a pgstore row from before that check, with a NULL expires_at, or one inserted by hand.
A disposition is the record's, never its Biscuit copies': a capability whose every copy is revoked stays active while the record is not revoked, and its JWT still verifies. Active is no promise that every credential minted from the record works.
func (RecordDisposition) Validate ¶ added in v0.111.0
func (d RecordDisposition) Validate() error
Validate refuses a disposition other than the three, as ErrInvalidRequest.
type RecordLister ¶ added in v0.111.0
type RecordLister interface {
// ListRecords returns one page of req.TenantID's records, in id order —
// ascending, or descending with ListOrderDescending: a capability's id
// is a UUIDv7, so that is newest first — at most Limit of them, and the
// cursor of the next page, empty on the last. The cursor is the store's
// own and opaque, and holds the order and the filters it was handed out
// for: a cursor of the other order, or of another tenant, principal
// type, subject, subject prefix or set of dispositions, is refused with
// ErrCursorMismatch.
ListRecords(ctx context.Context, req ListRecordsRequest) (Page[RecordListing], error)
}
RecordLister lists one tenant's capability records a page at a time, each with its disposition, for an operator's view of them: every record of the tenant, or those of a principal type, a subject or a subject prefix, and of the dispositions it asks for, oldest or newest first. It is a type of its own beside Store, as RecordCensus is: a reporter holds it, an issuer does not. memstore's and pgstore's Census are each one; storetest checks it when its Env sets one.
A record's disposition is the one RecordCensus counts it under, by the store's clock: revoked — itself or an ancestor still on record, unlike ListByPrincipalRequest.IncludeRevoked — over expired, over active. So, at one instant, the records of each disposition a full listing yields number what CountRecords counts for the tenant. Each page is read on its own, so a walk of many pages is no snapshot: a record can expire, or be revoked, between two of them. A Biscuit copy's revocation changes no record's disposition (RecordDisposition).
type RecordListing ¶ added in v0.111.0
type RecordListing struct {
Record Record
Disposition RecordDisposition
// StoppedBy is the entry that makes a revoked record revoked: its own,
// Record.Revocation, or for a record stopped only through an ancestor
// the entry of the nearest ancestor still on record that has one. Nil
// unless Disposition is RecordDispositionRevoked.
StoppedBy *CapabilityRevocation
}
RecordListing is one record of a listing: what GetRecord reads back about it — its own revocation entry among it, none when it is stopped only through an ancestor — its disposition now, and what revoked it.
type RecordListingReader ¶ added in v0.112.0
type RecordListingReader interface {
// GetRecordListing reads the record of id with its disposition; one
// not on record, or not visible to the caller, is ErrNotFound, as
// GetRecord answers it.
GetRecordListing(ctx context.Context, id uuid.UUID) (RecordListing, error)
}
RecordListingReader reads one record with its disposition, as a listing gives it: what Store.GetRecord reads back, and the disposition by the rules RecordLister and RecordCensus share, by the store's clock. A consumer showing one record asks it rather than working the disposition out from IsRevoked and the expiry, which would be a second rule beside the store's. The disposition is the record's, whatever its Biscuit copies' (RecordDisposition). A type of its own beside RecordLister, as the lister is beside Store: memstore's and pgstore's Census are each one, and storetest checks it when its Env.Lister is one.
type RecordReader ¶ added in v0.42.0
type RecordReader interface {
// 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 when there is no record. For audit
// and admin reads, and for a reporter that needs a capability's
// ceilings; Verifier never calls it.
GetRecord(ctx context.Context, id uuid.UUID) (Record, error)
}
RecordReader reads one capability's record. Store implements it; a caller that only reads records — StoredMeterSubject — asks for no more.
type RecordReporter ¶ added in v0.113.0
type RecordReporter interface {
RecordLister
RecordListingReader
}
RecordReporter is RecordLister and RecordListingReader together: what a reporter that lists a tenant's records and opens one of them holds. memstore's and pgstore's Census are each one, as NewCensus returns it.
type RequestCeiling ¶ added in v0.27.0
type RequestCeiling struct {
// Max is the most requests allowed; 0 allows none, which Issue and
// Delegate refuse. Within 0..MaxIJSONInteger, and 0 when Unlimited.
Max int64
// Unlimited lifts the ceiling.
Unlimited bool
}
RequestCeiling bounds a count of requests: at most Max, or none at all when Unlimited.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/oleg-tkachuk/limes"
)
func main() {
ceiling := limes.RequestCeiling{Max: 500}
fmt.Println(ceiling, ceiling.Validate(), ceiling.Admits(500), ceiling.Admits(501))
// The zero ceiling allows nothing; only Unlimited lifts it.
fmt.Println(limes.RequestCeiling{}.Admits(1), limes.RequestCeiling{Unlimited: true}.Admits(1))
fmt.Println(ceiling.Within(limes.RequestCeiling{Unlimited: true}), limes.RequestCeiling{Unlimited: true}.Within(ceiling))
fmt.Println(errors.Is(limes.RequestCeiling{Max: 1, Unlimited: true}.Validate(), limes.ErrInvalidRequest))
}
Output: 500 <nil> true false false true true false true
func (RequestCeiling) Admits ¶ added in v0.27.0
func (c RequestCeiling) Admits(n int64) bool
Admits reports whether n requests in all stay under the ceiling.
func (RequestCeiling) String ¶ added in v0.27.0
func (c RequestCeiling) String() string
String is the ceiling for logs and errors: its Max, or "unlimited".
func (RequestCeiling) Validate ¶ added in v0.27.0
func (c RequestCeiling) Validate() error
Validate refuses a negative or out-of-range Max, and a Max beside Unlimited, as ErrInvalidRequest.
func (RequestCeiling) Within ¶ added in v0.27.0
func (c RequestCeiling) Within(outer RequestCeiling) bool
Within reports whether c allows no more than outer: anything fits under an unlimited ceiling, and under a bounded one only a bounded ceiling no higher.
type Revocation ¶
type Revocation struct {
RevokedAt time.Time
Note string
Actor string
// Cascaded reports that the entry was written by a cascading revoke —
// of this capability or of an ancestor.
Cascaded bool
// contains filtered or unexported fields
}
Revocation is one entry of the revocation list, as Revoke wrote it.
type RevocationAnswer ¶ added in v0.119.0
type RevocationAnswer struct {
// Revoked is IsRevoked's answer: the capability, or an ancestor still
// on record, is revoked.
Revoked bool
// Ancestors are the ancestors still on record the answer read, nearest
// first: every one, when the capability is not revoked. A revoked
// answer needs none, as no revocation is undone.
Ancestors []uuid.UUID
// contains filtered or unexported fields
}
RevocationAnswer is RevocationLineageLookup's answer about a capability.
type RevocationCache ¶ added in v0.86.0
type RevocationCache struct {
// contains filtered or unexported fields
}
RevocationCache is a small in-memory cache for revocation answers: of a capability, IsRevoked, and of a Biscuit copy, IsBiscuitRevoked. One cache serves both, so one Clear, wired to a revocation notification, reaches every answer a verifier holds.
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. ClearRevoked drops only the answers a revocation of some ids outdates, when the capabilities' upstream is a RevocationLineageLookup and the cache knows each answer's ancestors; over any other upstream it is Clear.
Concurrent misses for the same question share one upstream call.
func NewRevocationCache ¶ added in v0.86.0
func NewRevocationCache(cfg RevocationCacheConfig) (*RevocationCache, error)
NewRevocationCache wires a cache over cfg's upstreams. A negative TTL or bound is refused; a deployment that wants no cache passes its store to the verifier directly.
Example ¶
ctx := context.Background()
s := newExampleStack()
// One cache answers for capabilities and Biscuit copies; memstore's
// Store is both upstreams.
cache, err := limes.NewRevocationCache(limes.RevocationCacheConfig{
Capabilities: s.records,
Biscuits: s.records,
TTL: time.Second,
})
if err != nil {
log.Fatal(err)
}
c, _ := s.issue()
id := c.ID
for range 3 {
if _, err := cache.IsRevoked(ctx, id); err != nil {
log.Fatal(err)
}
}
stats := cache.Stats().Capabilities
fmt.Println(stats.Misses, stats.Hits, stats.Lookups, stats.Entries)
// A revocation of id drops what it outdates: id's answer and those of
// the capabilities delegated from it, the others' kept. A
// pgstore.RevocationWatcher does it on each revocation it hears.
cache.ClearRevoked(id)
fmt.Println(cache.Stats().NarrowClears, cache.Stats().Capabilities.Entries)
// Clear drops every answer, for a revocation that names no capability.
cache.Clear()
fmt.Println(cache.Stats().Clears)
Output: 1 2 1 1 1 0 1
func (*RevocationCache) Clear ¶ added in v0.86.0
func (c *RevocationCache) 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 (*RevocationCache) ClearRevoked ¶ added in v0.114.0
func (c *RevocationCache) ClearRevoked(ids ...uuid.UUID)
ClearRevoked drops what a revocation of ids outdates: each one's cached "not revoked" answer and that of every descendant the cache holds one for, which it knows by the ancestors its upstream named. Every other answer stays, Biscuit copies' among them — revoking a capability revokes no copy's own id — so a consumer that revokes often keeps the answers it did not touch. A lookup in flight when it runs caches nothing, as with Clear. Over an upstream that is no RevocationLineageLookup it is Clear: the cache cannot tell a descendant from any other id. A Biscuit copy's revocation still takes Clear. Named no id, it drops nothing.
func (*RevocationCache) IsBiscuitRevoked ¶ added in v0.109.0
func (c *RevocationCache) IsBiscuitRevoked(ctx context.Context, revocationIDs [][]byte) (bool, error)
IsBiscuitRevoked answers from the cache for each id that has a fresh answer, and asks upstream about all the others in one lookup, shared by every concurrent caller asking about the same ones. A token is revoked when any of its ids is: the ids a token shares with the copies it was attenuated from stay cached, so a fresh attenuation — which anyone holding a copy can make offline — costs one lookup for its new block, and a token of blocks the cache has never seen one lookup in all, not one per block. A "live" answer is then cached for each of them; a "revoked" one for that set alone, as upstream does not say which id it is. An upstream error is returned and never cached.
func (*RevocationCache) IsRevoked ¶ added in v0.86.0
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 (*RevocationCache) NewSignal ¶ added in v0.123.0
func (c *RevocationCache) NewSignal() *RevocationSignal
NewSignal opens a revocation signal on the cache. Not listening yet, it keeps the cache on TTL until it listens or closes.
func (*RevocationCache) Stats ¶ added in v0.110.0
func (c *RevocationCache) Stats() RevocationCacheStats
Stats reads the cache's counts. Each kind is read shard by shard, without stopping the cache, so the counts of a cache in use are each a moment apart.
func (*RevocationCache) Sweep ¶ added in v0.86.0
func (c *RevocationCache) Sweep() int
Sweep drops every expired entry, of either kind, and returns how many it dropped. Optional housekeeping — lookups skip stale entries, and every new answer drops the expired ones before it.
Example ¶
ctx := context.Background()
s := newExampleStack()
now := time.Date(2026, time.March, 2, 9, 0, 0, 0, time.UTC)
cache, err := limes.NewRevocationCache(limes.RevocationCacheConfig{
Capabilities: s.records, TTL: time.Second, Now: func() time.Time { return now },
})
if err != nil {
log.Fatal(err)
}
for range 2 {
c, _ := s.issue()
if _, err := cache.IsRevoked(ctx, c.ID); err != nil {
log.Fatal(err)
}
}
now = now.Add(2 * time.Second)
fmt.Println(cache.Sweep(), cache.Stats().Capabilities.Entries)
Output: 2 0
type RevocationCacheConfig ¶ added in v0.13.0
type RevocationCacheConfig struct {
// Capabilities answers IsRevoked for what the cache does not hold.
// Required. When it is a RevocationLineageLookup the cache asks
// RevocationLineage in place of IsRevoked, and ClearRevoked drops only
// what a revocation outdates. A decorator of a store keeps that only by
// having the method itself: a struct embedding limes.Store has no
// RevocationLineage to promote, so it forwards one —
//
// func (c counting) RevocationLineage(ctx context.Context, id uuid.UUID) (limes.RevocationAnswer, error) {
// return c.lineage.RevocationLineage(ctx, id)
// }
//
// — and counts there what it counts in IsRevoked.
Capabilities RevocationLookup
// RequireLineage refuses a Capabilities that is no
// RevocationLineageLookup, with ErrNoRevocationLineage, where the cache
// would otherwise clear whole on every ClearRevoked.
RequireLineage bool
// Biscuits answers IsBiscuitRevoked for what the cache does not hold.
// Optional: without it the cache refuses every Biscuit question, and
// NewVerifier refuses it as BiscuitConfig.Revocations. A store that
// implements both, as pgstore's does, is passed as both. A nil pointer
// held here is refused.
Biscuits BiscuitRevocationLookup
// TTL is how long an answer is served. Zero: DefaultRevocationCacheTTL.
// It bounds how long a revocation may go unseen where no revocation
// signal reaches the cache.
TTL time.Duration
// SyncedTTL is how long an answer is served while revocation signals
// vouch for the cache: while at least one RevocationSignal is open and
// every open one listens, as pgstore.RevocationWatcher's does while
// its connection listens. A revocation then reaches the cache at once,
// so the TTL can be long. Otherwise TTL bounds every answer, those
// cached under SyncedTTL among them. Zero: TTL; shorter than TTL, or
// longer than MaxRevocationCacheSyncedTTL, is refused.
SyncedTTL time.Duration
// MaxEntries bounds each kind of answer on its own, so a flood of one
// never evicts the other. Zero: DefaultRevocationCacheEntries. A Biscuit
// answer is one entry per revocation id, and one per set of ids asked
// about together, whose key holds every id of the set — up to
// MaxBiscuitBlocks+1 block signatures: size memory for MaxEntries times
// the largest such key, not one id. Over a RevocationLineageLookup a
// capability's "not revoked" answer keeps its ancestors' ids beside it,
// up to MaxDelegationDepth of them: about 16 bytes per generation, so a
// full cache of chains 64 deep holds some 100 MB of them at the default
// bound, against 32 to 80 bytes for chains two to five deep.
MaxEntries int
// Now is the clock. Nil: time.Now.
Now func() time.Time
}
RevocationCacheConfig configures a RevocationCache.
type RevocationCacheCounts ¶ added in v0.110.0
type RevocationCacheCounts struct {
// Hits counts the questions answered from the cache, Misses the ones
// that waited on an upstream lookup: each question is one or the
// other. A Biscuit question is a hit only when every id of the token
// has a fresh answer.
Hits, Misses uint64
// Lookups counts the upstream lookups run for the misses: fewer than
// Misses when concurrent misses share one.
Lookups uint64
// Evictions counts the live answers dropped to stay within
// RevocationCacheConfig.MaxEntries: a count that keeps growing says
// the bound is too small for the ids in use.
Evictions uint64
// Entries is how many answers the cache holds, fresh or not yet
// swept.
Entries int
// contains filtered or unexported fields
}
RevocationCacheCounts is one kind of answer's share of RevocationCacheStats.
type RevocationCacheStats ¶ added in v0.110.0
type RevocationCacheStats struct {
// Capabilities counts the IsRevoked questions.
Capabilities RevocationCacheCounts
// Biscuits counts the IsBiscuitRevoked questions.
Biscuits RevocationCacheCounts
// Clears counts the calls to Clear, and NarrowClears those to
// ClearRevoked that dropped only what a revocation outdates; one over
// an upstream that names no ancestors is a Clear.
Clears, NarrowClears uint64
// ClearsWithoutLineage counts the calls to ClearRevoked that cleared
// whole, as the upstream names no lineage: a part of Clears, apart from
// a Clear asked for. Growing, narrow invalidation is off (Lineage).
ClearsWithoutLineage uint64
// Synced reports that revocation signals vouch for the cache now, and
// SyncedTTL bounds its answers; false, TTL does. Syncs counts the
// signals' Listening, and the Close that left every open signal
// listening, each a Clear, among Clears: the watchers' connections,
// their first and every reconnect.
Synced bool
Syncs uint64
// SyncsLost counts the signals' NotListening, and LastSyncLoss is the
// time of the last: zero, none yet. A count growing as fast as Syncs
// says the watcher keeps losing its connection.
SyncsLost uint64
LastSyncLoss time.Time
// Signals counts the notifications revocation signals passed on
// (Revoked, RevokedAll), and SignalsWhole those that named no id and
// cleared whole (RevokedAll), a part of Clears. Clears less Syncs, SignalsWhole and
// ClearsWithoutLineage is the Clears the consumer asked for itself.
Signals, SignalsWhole uint64
// Lineage reports that the cache's upstream names each answer's
// ancestors, so ClearRevoked drops only what a revocation outdates;
// false, every ClearRevoked is a Clear.
Lineage bool
// contains filtered or unexported fields
}
RevocationCacheStats is what a RevocationCache has counted since it was made, and what it holds now: of each kind of answer, and its clears. The counts only grow, so a metrics exporter reads them as counters (otelmetric.InstrumentRevocationCache).
type RevocationLineageLookup ¶ added in v0.114.0
type RevocationLineageLookup interface {
RevocationLookup
// RevocationLineage answers as IsRevoked does, and names the ancestors
// of id still on record that the answer read: every one, when id is
// not revoked. An id not on record is ErrNotFound: the cache answers
// it not revoked, as IsRevoked does, but keeps no answer, which no
// ancestor's revocation could reach. One that answers such an id not
// revoked instead is cached as any other.
RevocationLineage(ctx context.Context, id uuid.UUID) (RevocationAnswer, error)
}
RevocationLineageLookup is a RevocationLookup that also names the ancestors an answer covers. A RevocationCache over one keeps them beside each "not revoked" answer, so ClearRevoked drops only the answers a revocation outdates rather than every one. memstore's Store and pgstore's are each one.
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 RevocationSignal ¶ added in v0.123.0
type RevocationSignal struct {
// contains filtered or unexported fields
}
RevocationSignal is one revocation signal's hold on a RevocationCache — a watcher's: it says through it when it listens and what it hears, and closes it when it stops for good. The cache serves SyncedTTL while at least one signal is open and every open one listens, since a revocation the one not listening would have carried goes unheard. A signal opened later is not waited for: start the watchers that feed one cache together. After Close, every call does nothing.
func (*RevocationSignal) Close ¶ added in v0.123.0
func (s *RevocationSignal) Close()
Close ends the signal: it no longer counts toward synced, and the cache is synced again only while another open signal listens. Closing the one signal that kept the cache on TTL — lost, or never listening — clears it before SyncedTTL holds again, as Listening does: what was cached while it was down may miss a revocation it would have carried. Once is enough; again, it does nothing.
func (*RevocationSignal) Listening ¶ added in v0.123.0
func (s *RevocationSignal) Listening()
Listening says the signal reaches the cache from now on, at each connection a watcher opens: it clears the cache — a revocation made while nothing listened sent a signal nobody heard — and counts toward synced. Again while listening, it is a reconnect: it clears, and counts once.
func (*RevocationSignal) NotListening ¶ added in v0.123.0
func (s *RevocationSignal) NotListening()
NotListening says the signal stopped: a revocation may now go unheard, so every answer, those cached already among them, is served for TTL alone until it listens again. Not listening, it does nothing.
func (*RevocationSignal) Revoked ¶ added in v0.123.0
func (s *RevocationSignal) Revoked(ids ...uuid.UUID)
Revoked is the signal's word that ids were revoked: ClearRevoked of them. Named no id, it does nothing; a revocation it cannot name is RevokedAll.
func (*RevocationSignal) RevokedAll ¶ added in v0.123.0
func (s *RevocationSignal) RevokedAll()
RevokedAll is the signal's word of a revocation it cannot name — a Biscuit copy's, or too many ids to name: Clear.
type RevocationSignalTarget ¶ added in v0.123.0
type RevocationSignalTarget interface {
NewSignal() *RevocationSignal
}
RevocationSignalTarget is a cache a revocation signal keeps in step through a RevocationSignal of its own: pgstore.RevocationWatcher takes one from each such cache it serves. RevocationCache is one.
type RevokeBiscuitRequest ¶
type RevokeBiscuitRequest struct {
// Copy is the copy to revoke, as Verifier.BiscuitCopy read it.
Copy BiscuitCopy
// Note and Actor are stored for audit, as on RevokeRequest.
Note string
Actor string
}
RevokeBiscuitRequest is the input shape for BiscuitRevocationStore.RevokeBiscuit.
func (RevokeBiscuitRequest) Validate ¶ added in v0.43.0
func (r RevokeBiscuitRequest) Validate() error
Validate refuses a request naming no copy as ErrInvalidRequest.
type RevokeBiscuitResult ¶ added in v0.121.0
type RevokeBiscuitResult struct {
// AlreadyRevoked reports that the copy had an entry before the call, so
// the call wrote none.
AlreadyRevoked bool
// Revocation is the copy's entry as it stands after the call — the one
// the call wrote, or the earlier one it kept — as GetBiscuitRevocation
// reads it back, at the store's precision.
Revocation BiscuitRevocation
}
RevokeBiscuitResult is what BiscuitRevocationStore.RevokeBiscuit did to the copy it names, as RevokeResult is Store.Revoke's.
type RevokeMatchingRequest ¶ added in v0.136.0
type RevokeMatchingRequest struct {
// TenantID is the tenant whose capabilities are revoked. Required.
TenantID uuid.UUID
// Subject matches a capability's Subject.Subject, the principal it
// authorises: a user who leaves, an agent retired.
Subject string
// OnBehalfOf matches the principal a capability acts for
// (Capability.OnBehalfOf.Subject): every agent acting for a user.
OnBehalfOf string
// ClientID matches a capability's ClientID: a client compromised.
ClientID string
// Session matches the identity provider's session a capability was
// issued under (InsertRequest.Session): one the provider ended, by OIDC
// back-channel logout say.
Session string
// Note and Actor are stored for audit, as on RevokeRequest.
Note, Actor string
// Limit bounds the capabilities one call revokes. Zero:
// DefaultHousekeepingLimit; negative is refused.
Limit int
}
RevokeMatchingRequest names the capabilities of one tenant to revoke together (docs/adr/0013-bulk-revocation.md): every one on record that matches each selector given — at least one — and has no revocation entry of its own yet.
func (RevokeMatchingRequest) BatchLimit ¶ added in v0.136.0
func (r RevokeMatchingRequest) BatchLimit() int
BatchLimit is Limit, or DefaultHousekeepingLimit when it is zero.
func (RevokeMatchingRequest) Validate ¶ added in v0.136.0
func (r RevokeMatchingRequest) Validate() error
Validate refuses a request with no tenant, no selector, or a negative limit.
type RevokeMatchingResult ¶ added in v0.136.0
type RevokeMatchingResult struct {
// Revoked counts the revocation entries the call wrote.
Revoked int64
// More reports a full batch: more may match, so call again until it is
// false. A capability issued meanwhile, by a client still holding a
// token that can mint one, is caught by the next call.
More bool
// contains filtered or unexported fields
}
RevokeMatchingResult is what one RevokeMatching call did.
type RevokeRequest ¶
type RevokeRequest struct {
CapabilityID uuid.UUID
// Note is an operator-supplied label ("compromise", "rotation",
// "policy-change"). Stored on the revocation row for audit.
Note 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.
func (RevokeRequest) Validate ¶ added in v0.90.0
func (r RevokeRequest) Validate() error
Validate refuses a request naming no capability, as ErrInvalidRequest: nothing could be revoked under the zero id, and answering ErrNotFound would hide the caller's bug.
type RevokeResult ¶ added in v0.113.0
type RevokeResult struct {
// AlreadyRevoked reports that the capability had a revocation entry of
// its own before the call, so the call wrote none for it. A capability
// stopped only through an ancestor has none: its first revoke writes
// one.
AlreadyRevoked bool
// Revocation is the capability's entry as it stands after the call:
// the one the call wrote, or the earlier one it kept, as GetRecord
// reads it back — at the store's precision.
Revocation Revocation
// PriorDisposition is the capability's disposition just before the
// call, by the rules and the clock RecordCensus and RecordListingReader
// use: revoked — by its own entry or through an ancestor — then
// expired, then active. Only a revoke of an active capability stopped something new.
// AlreadyRevoked implies RecordDispositionRevoked. Unspecified when the
// store could not tell — a chain past MaxDelegationDepth — though it
// wrote the entry all the same.
PriorDisposition RecordDisposition
}
RevokeResult is what Store.Revoke did to the capability it names.
type SealedKey ¶ added in v0.105.0
type SealedKey struct {
// contains filtered or unexported fields
}
SealedKey is a private key where no formatting reaches it: under any verb, %p included, in JSON and in slog, and wherever it sits — a field of a struct of yours, exported or not — it reads redactedKeyText. A plain key field cannot promise that: fmt walks into a field it cannot call a method on, and under a verb a value does not take. SealKey makes one; the zero SealedKey holds no key. It is a crypto.Signer over the key it holds.
func SealKey ¶ added in v0.105.0
SealKey seals key: an ed25519.PrivateKey, or a pointer to one, of which it keeps a copy, so clearing or reusing the caller's key after leaves the sealed key whole, or a crypto.Signer over a key held elsewhere. A nil key, or a nil pointer to an ed25519.PrivateKey, gives the zero SealedKey.
Example ¶
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"fmt"
"log"
"log/slog"
"github.com/oleg-tkachuk/limes"
)
func main() {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
log.Fatal(err)
}
sealed := limes.SealKey(priv)
// No verb, no encoder and no slog handler prints the key.
asJSON, _ := json.Marshal(struct{ Key limes.SealedKey }{sealed})
fmt.Printf("%v %x %#v %s\n", sealed, sealed, sealed, asJSON)
fmt.Println(slog.AnyValue(sealed))
// It still signs, as a crypto.Signer.
sig, err := sealed.Sign(rand.Reader, []byte("message"), &ed25519.Options{})
fmt.Println(err, ed25519.Verify(priv.Public().(ed25519.PublicKey), []byte("message"), sig))
fmt.Println(sealed.IsZero(), limes.SealedKey{}.IsZero())
}
Output: [redacted] [redacted] [redacted] {"Key":"[redacted]"} [redacted] <nil> true false true
func (SealedKey) Format ¶ added in v0.105.0
Format prints the key as redactedKeyText under every verb.
func (SealedKey) MarshalText ¶ added in v0.105.0
MarshalText makes encoding/json and slog's handlers write the key as redactedKeyText too.
func (SealedKey) Public ¶ added in v0.105.0
Public is the key's public half; nil when k holds no usable key.
type Signer ¶
type Signer struct {
// contains filtered or unexported fields
}
Signer holds the key an Issuer mints capability tokens with: it encodes a Capability as the token format and signs it with an Ed25519 key, which may be in memory or held in a KMS or an HSM behind crypto.Signer. It signs only for an Issuer (IssuerConfig.Signer). Verifiers pick up the public keys (statically or from a JWKS endpoint) and validate locally — no RPC per request.
func NewSigner ¶ added in v0.14.0
func NewSigner(cfg SignerConfig) (*Signer, error)
NewSigner builds a Signer, refusing a kid outside limes's namespace or a key that is not Ed25519.
Example ¶
package main
import (
"fmt"
"log"
"github.com/oleg-tkachuk/limes"
)
func main() {
key, err := limes.GenerateKey()
if err != nil {
log.Fatal(err)
}
signer, err := limes.NewSigner(limes.SignerConfig{KeyID: key.KeyID, Key: key.Private})
fmt.Println(err, signer != nil)
// A signer needs a kid for verifiers to find its key by, in limes's
// namespace: GenerateKey's is.
_, err = limes.NewSigner(limes.SignerConfig{KeyID: "k1", Key: key.Private})
fmt.Println(err)
}
Output: <nil> true limes: invalid configuration: SignerConfig.KeyID "k1" is not a limes kid ("limes:" and a name)
type SignerConfig ¶ added in v0.14.0
type SignerConfig struct {
// KeyID is the kid tokens carry, by which verifiers find the key.
// Required, in limes's namespace: jwks.KeyIDPrefix, as jwks.KeyID and
// GenerateKey write it.
KeyID string
// Key signs, sealed (SealKey): an ed25519.PrivateKey, or a
// crypto.Signer over a key held elsewhere — one that also implements
// ContextSigner is given the request's context. Its public key must be
// Ed25519. Required.
Key SealedKey
// Algorithm is the alg the tokens name. Empty: SigningAlgorithmEd25519,
// the name RFC 9864 gives the algorithm. SigningAlgorithmEdDSA names it
// as JOSE libraries that predate RFC 9864 do; the signature is the same,
// and every verifier of this package reads both.
Algorithm SigningAlgorithm
}
SignerConfig configures a Signer.
func (SignerConfig) Format ¶ added in v0.65.0
func (cfg SignerConfig) Format(f fmt.State, verb rune)
Format prints cfg with its key redacted, whether the key is an ed25519.PrivateKey or a crypto.Signer that holds one.
func (SignerConfig) LogValue ¶ added in v0.77.3
func (cfg SignerConfig) LogValue() slog.Value
LogValue logs cfg with its key redacted, under any slog handler.
Example ¶
package main
import (
"encoding/json"
"fmt"
"log"
"log/slog"
"os"
"github.com/oleg-tkachuk/limes"
)
func main() {
key, err := limes.GenerateKey()
if err != nil {
log.Fatal(err)
}
cfg := limes.SignerConfig{KeyID: "limes:k1", Key: key.Private}
asJSON, _ := json.Marshal(cfg)
fmt.Println(string(asJSON))
// The time is dropped only to keep the output stable.
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
}))
logger.Info("signer configured", "signer", cfg)
}
Output: {"KeyID":"limes:k1","Key":"[redacted]"} level=INFO msg="signer configured" signer="{KeyID:limes:k1 Key:[redacted]}"
func (SignerConfig) MarshalJSON ¶ added in v0.77.3
func (cfg SignerConfig) MarshalJSON() ([]byte, error)
MarshalJSON encodes cfg with its key redacted.
type SigningAlgorithm ¶ added in v0.127.0
type SigningAlgorithm string
SigningAlgorithm is the JWS alg a token names: Ed25519, by either of its names.
const ( // SigningAlgorithmEd25519 is the fully-specified name of RFC 9864. SigningAlgorithmEd25519 SigningAlgorithm = jose.AlgEd25519 // SigningAlgorithmEdDSA is the polymorphic name RFC 9864 deprecates, the // only one many JOSE libraries, gateways and identity providers know. SigningAlgorithmEdDSA SigningAlgorithm = jose.AlgEdDSA )
The names of Ed25519 a token may carry.
type SigningKey ¶ added in v0.3.0
type SigningKey struct {
KeyID string
// Private is the private key, sealed: SignerConfig.Key takes it as it
// is, and no formatting of a SigningKey reaches it.
Private SealedKey
}
SigningKey is an Ed25519 key and the kid it is published under. It holds the private key alone, and Public derives the public half from it, so the two cannot disagree.
func GenerateKey ¶ added in v0.12.0
func GenerateKey() (SigningKey, error)
GenerateKey makes a fresh Ed25519 signing key under the kid jwks.KeyID derives for it. It lives only as long as the process unless saved.
Example ¶
package main
import (
"fmt"
"log"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/jwks"
)
func main() {
key, err := limes.GenerateKey()
if err != nil {
log.Fatal(err)
}
// The kid is derived from the public half, as a JWKS publishes it.
fmt.Println(key.KeyID == jwks.KeyID(key.Public()))
// The private half never reaches a log line.
fmt.Println(key.Private)
}
Output: true [redacted]
func LoadKey ¶ added in v0.13.0
func LoadKey(path string) (SigningKey, error)
LoadKey reads the PKCS#8 PEM signing key at path, under the kid jwks.KeyID derives from it; a deployment that names its kids sets SigningKey.KeyID on the result. For a key that lives only as long as the process, GenerateKey; a deployment that may have no key file chooses between the two itself, so a missing mount is never silently a fresh key.
Example ¶
package main
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"os"
"path/filepath"
"github.com/oleg-tkachuk/limes"
)
// The PEM block type a PKCS#8 private key is written under.
const pkcs8BlockType = "PRIVATE KEY"
func main() {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
log.Fatal(err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
log.Fatal(err)
}
dir, err := os.MkdirTemp("", "limes-key")
if err != nil {
log.Fatal(err)
}
err = func() error {
defer os.RemoveAll(dir)
path := filepath.Join(dir, "signing.pem")
if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: pkcs8BlockType, Bytes: der}), 0o600); err != nil {
return err
}
key, err := limes.LoadKey(path)
if err != nil {
return err
}
fmt.Println(key.Public().Equal(priv.Public()))
// A missing file is an error, never a fresh key.
_, err = limes.LoadKey(filepath.Join(dir, "missing.pem"))
fmt.Println(err != nil)
return nil
}()
if err != nil {
log.Fatal(err)
}
}
Output: true true
func (SigningKey) Format ¶ added in v0.65.0
func (k SigningKey) Format(f fmt.State, verb rune)
Format prints k with its private key redacted.
func (SigningKey) LogValue ¶ added in v0.77.3
func (k SigningKey) LogValue() slog.Value
LogValue logs k with its private key redacted, under any slog handler.
func (SigningKey) MarshalJSON ¶ added in v0.77.3
func (k SigningKey) MarshalJSON() ([]byte, error)
MarshalJSON encodes k with its private key redacted: a SigningKey written as JSON, to a log or anywhere else, is not a way to carry the key.
func (SigningKey) Public ¶ added in v0.3.0
func (k SigningKey) Public() ed25519.PublicKey
Public is the public half of Private; nil when Private holds no Ed25519 key.
type Store ¶
type Store interface {
IssuanceStore
RecordReader
// Revoke adds an entry to the revocation list, and says whether the
// capability had one already. Idempotent: a duplicate revoke writes
// nothing for the capability, keeps the first entry, and reports it
// with RevokeResult.AlreadyRevoked. 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.
// It refuses what RevokeRequest.Validate refuses.
//
// 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. A repeated cascading revoke writes the entries a descendant
// lacks, whatever the capability's own entry says. A capability already
// stopped through an ancestor, or expired, still gets its entry: the
// audit names who revoked it and why, and the entry stands if the
// ancestor's goes with a purge. RevokeResult.PriorDisposition says it
// stopped nothing new. A store may keep work per descendant beside the
// entry — pgstore marks each one revoked, holding its row until the
// revoke commits — so a revoke's cost can grow with the subtree below
// the capability, flag or not.
Revoke(ctx context.Context, req RevokeRequest) (RevokeResult, error)
// Purge removes the capabilities expired for longer than the request's
// retention (PurgeRequest.Retention) — their records, their revocation
// entries and those of their Biscuit copies — at most req.Limit of
// them, and reports how many records it removed. A housekeeping worker
// calls it until a batch comes back short of the limit. The retention
// is how long an expired capability stays on record for audit, and
// never less than MinPurgeRetention: a verifier still accepts a token
// for up to its Leeway past expiry, and a revocation purged before
// that would let a revoked token through again.
//
// Purge never removes a capability while a child of it stays on record:
// a child expires no later than its parent (Insert refuses one that
// would not), and among records expiring together Purge takes the
// deepest first. The IsRevoked walk and the Meter's lineage, which stop
// at the first ancestor no longer on record, therefore miss no
// revocation and no ceiling. Once records go, meter.UsageHousekeeping.PurgeOrphans
// releases what the meter still holds for them.
Purge(ctx context.Context, req PurgeRequest) (PurgeResult, error)
// ListByPrincipal lists the capabilities issued to one principal, in
// ascending id order, a page at a time: at most Limit of them, and the
// cursor of the next page, empty on the last. The cursor is the store's
// own and opaque.
ListByPrincipal(ctx context.Context, req ListByPrincipalRequest) (Page[Capability], error)
}
Store persists capability records and revocation entries. Consumers supply the implementation: memstore ships an in-memory one, and the module github.com/oleg-tkachuk/limes/pgstore one over PostgreSQL.
Records are append-mostly: capabilities aren't mutated after issuance except via Revoke. Revocations are checked by the verifier on every request; put a RevocationCache in front to keep the per-call cost flat.
type TenantRecordEraser ¶ added in v0.60.0
type TenantRecordEraser interface {
// EraseTenantRecords revokes every capability of the tenant not revoked
// yet, so no token of the tenant verifies again, and removes those of
// its records Purge would remove at MinPurgeRetention — each with its
// revocation entry and its Biscuit copies' — and no other: an id not on
// record is not revoked, so a record removed while its token may still
// verify would let that token through again. Purge takes the rest as
// it expires. It
// changes no other tenant's records, and a second call revokes and
// removes nothing the first left.
EraseTenantRecords(ctx context.Context, req EraseTenantRequest) (EraseTenantResult, error)
}
TenantRecordEraser is a Store that erases one tenant's capability records, for a consumer removing the tenant. It is optional; storetest checks a Store that implements it.
type TokenForm ¶ added in v0.133.0
type TokenForm interface {
// Seal builds a token around the JWT sign returns for the root key the
// form chose, whose private half signs the token's first block and is
// then dropped.
Seal(ctx context.Context, sign func(ctx context.Context, root ed25519.PublicKey) (string, error)) (string, error)
// Open splits token into the JWT it seals and unseal, which, once the
// caller has verified that JWT and hands it the root key the JWT names,
// verifies the rest of token under it and returns what it read. Nothing
// of token is to be trusted before unseal returns.
Open(token string) (sealed string, unseal func(root ed25519.PublicKey) (Unsealed, error), err error)
}
TokenForm is a form of capability token that seals a JWT this issuer signed inside a token its holder can narrow offline: the Biscuit form (IssuerConfig.Biscuit, BiscuitConfig.Form). The form reads and writes its encoding and verifies its signatures; the core keeps what a narrowing may do. Every block a form hands back is checked as the holder's input (Attenuation, Narrows), whatever form decoded it.
A form's error refuses the token as ErrBiscuitAttenuation when it wraps that sentinel, and as ErrInvalidToken otherwise. The form need not wrap either: an error that does keeps its text, one that does not gets the family's prefixed, and the family is named once either way.
type Unsealed ¶ added in v0.133.0
type Unsealed struct {
// Blocks are the token's attenuations, in the order they were appended.
Blocks []Attenuation
// RevocationIDs are the token's revocation ids: its first block's, then
// one for each of Blocks.
RevocationIDs [][]byte
}
Unsealed is what a TokenForm read of a token past its sealed JWT, its signature chain verified.
type VerifiedCapability ¶ added in v0.14.0
type VerifiedCapability struct {
// contains filtered or unexported fields
}
VerifiedCapability is a capability a Verifier accepted. Only Verify and Authorize make one, so a function that takes it — Issuer.Delegate — knows the token it came from was checked, rather than trusting a caller to have checked it.
func (VerifiedCapability) Capability ¶ added in v0.14.0
func (v VerifiedCapability) Capability() Capability
Capability is what the token grants: a copy, the caller's to change.
func (VerifiedCapability) Copies ¶ added in v0.83.0
func (v VerifiedCapability) Copies() []CopyCeiling
Copies are the request and budget limits the attenuation blocks of a verified Biscuit set, innermost block first; none for a JWT, or a Biscuit whose blocks set none. Each is counted on its own, beside the capability's counters, by every Meter call its MeterSubject is handed.
func (VerifiedCapability) FromBiscuit ¶ added in v0.25.0
func (v VerifiedCapability) FromBiscuit() bool
FromBiscuit reports whether the capability was read from a copy of a Biscuit rather than from a JWT. Issuer.Delegate and Issuer.Biscuit take only the latter.
func (VerifiedCapability) MeterSubject ¶ added in v0.17.0
func (v VerifiedCapability) MeterSubject() MeterSubject
MeterSubject is what the Meter counts this capability's requests against.
Example ¶
s := newExampleStack()
c, token := s.issue()
subject := s.verified(token).MeterSubject()
fmt.Println(subject.CapabilityID() == c.ID, subject.TenantID() == s.tenantID)
fmt.Println(subject.Requests(), subject.Budget(), subject.UnitCode(), subject.CopyQuota(), len(subject.Copies()))
fmt.Println(subject.Validate(), subject.ValidateAmounts())
// The zero subject names nothing, and a meter refuses it.
fmt.Println(errors.Is(limes.MeterSubject{}.Validate(), limes.ErrInvalidRequest))
Output: true true 500 25 USD USD 0 0 <nil> <nil> true
type Verifier ¶
type Verifier struct {
// contains filtered or unexported fields
}
Verifier is the production verifier — signature first, then claims, then issuer / tenant / time / audience / revocation gates. A deployment typically resolves keys through a cached JWKS (jwks.Remote) and checks revocation through a short-TTL cache (RevocationCache), so revocation propagates quickly without hammering the store. A consumer that mocks it declares the narrow interface it needs.
Verification is independent of the persistence layer apart from the revocation lookup, so the verifier scales horizontally without contention on the capability records.
func NewVerifier ¶ added in v0.36.0
func NewVerifier(cfg VerifierConfig) (*Verifier, error)
NewVerifier validates wiring and builds the verifier.
Example ¶
records, err := memstore.NewStore(memstore.StoreConfig{})
if err != nil {
log.Fatal(err)
}
key, err := limes.GenerateKey()
if err != nil {
log.Fatal(err)
}
verifier, err := limes.NewVerifier(limes.VerifierConfig{
Keys: exampleKeys(key.KeyID, key.Public()),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
})
fmt.Println(verifier != nil, err)
// Keys, Revocations and TrustedIssuers are required.
_, err = limes.NewVerifier(limes.VerifierConfig{Revocations: records, TrustedIssuers: []string{exampleIssuerName}})
fmt.Println(err != nil)
Output: true <nil> true
func (*Verifier) Authorize ¶ added in v0.3.0
func (v *Verifier) Authorize(ctx context.Context, req AuthorizeRequest) (VerifiedCapability, 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. The caveats are checked before the proof's id is recorded, so a presenter they refuse — one outside the allowed networks, say — spends none of the replay cache's room, which every holder of the capability shares.
Example ¶
package main
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"log"
"time"
"uuid"
"github.com/oleg-tkachuk/limes"
"github.com/oleg-tkachuk/limes/dpop"
"github.com/oleg-tkachuk/limes/jwks"
"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.Verifier
records *memstore.Store
usage *memstore.UsageStore
tenantID uuid.UUID
}
// exampleClientID is the OAuth client the examples' capabilities are granted
// to (RFC 9068 client_id): the software the agent runs as.
const exampleClientID = "example-agent"
func newExampleStack() exampleStack {
key, err := limes.GenerateKey()
kid, pub, priv := key.KeyID, key.Public(), key.Private
if err != nil {
log.Fatal(err)
}
records, err := memstore.NewStore(memstore.StoreConfig{})
if err != nil {
log.Fatal(err)
}
usage, err := memstore.NewUsageStore(memstore.UsageStoreConfig{Records: records})
if err != nil {
log.Fatal(err)
}
signer, err := limes.NewSigner(limes.SignerConfig{KeyID: kid, Key: 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)
}
replay, err := dpop.NewMemoryReplayCache(dpop.MemoryReplayCacheConfig{})
if err != nil {
log.Fatal(err)
}
proofs, err := dpop.NewVerifier(dpop.VerifierConfig{Replay: replay})
if err != nil {
log.Fatal(err)
}
verifier, err := limes.NewVerifier(limes.VerifierConfig{
Keys: exampleKeys(kid, pub),
Revocations: records,
TrustedIssuers: []string{exampleIssuerName},
DPoP: proofs,
})
if err != nil {
log.Fatal(err)
}
return exampleStack{
issuer: issuer, verifier: verifier, records: records,
usage: usage, tenantID: uuid.New(),
}
}
// verified is the capability token grants, as the verifier accepts it.
func (s exampleStack) verified(token string) limes.VerifiedCapability {
vc, err := s.verifier.Verify(context.Background(), limes.VerifyRequest{Token: token, Audience: exampleAudience})
if err != nil {
log.Fatal(err)
}
return vc
}
// 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) {
cResult, err := s.issuer.Issue(context.Background(), limes.IssueRequest{
ClientID: exampleClientID,
IssuedBy: limes.Principal{Subject: "operator@example.com"},
Subject: limes.Principal{
Type: limes.PrincipalTypeAgent, 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/"},
Requests: limes.RequestCeiling{Max: 500},
Budget: limes.BudgetCeiling{Max: limes.MustParseAmount("25.00"), UnitCode: "USD"},
},
})
c, token := cResult.Capability, cResult.Token
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{
Verify: limes.VerifyRequest{Token: token, Audience: exampleAudience},
Check: 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))
}
// exampleKeys is a jwks.Static holding pub under kid.
func exampleKeys(kid string, pub ed25519.PublicKey) *jwks.Static {
keys, err := jwks.NewStatic(jwks.StaticConfig{Keys: map[string]ed25519.PublicKey{kid: pub}})
if err != nil {
log.Fatal(err)
}
return keys
}
Output: <nil> true true
func (*Verifier) BiscuitCopy ¶ added in v0.36.0
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 (*Verifier) DPoPNonce ¶ added in v0.36.0
DPoPNonce is the nonce to send in the DPoP-Nonce header — with a response refused by dpop.ErrNonceRequired, and with any other for the client's next proof. Empty when the verifier issues none.
Example ¶
// A verifier configured with no dpop.Nonces hands out none.
s := newExampleStack()
nonce, err := s.verifier.DPoPNonce(context.Background())
fmt.Printf("%q %v\n", nonce, err)
Output: "" <nil>
func (*Verifier) ErrorResponse ¶ added in v0.36.0
func (v *Verifier) 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; when it cannot give one, it logs why (VerifierConfig.Logger) and answers the server's failure, 500, for a challenge without a nonce is one the client cannot satisfy.
Example ¶
ctx := context.Background()
s := newExampleStack()
_, err := s.verifier.Verify(ctx, limes.VerifyRequest{Token: "not-a-token", Audience: exampleAudience})
resp := s.verifier.ErrorResponse(ctx, err, limes.AuthSchemeBearer)
fmt.Println(resp.Status, resp.Problem.Reason)
fmt.Println(resp.Challenges[0])
Output: 401 invalid_token Bearer error="invalid_token", error_description="The access token is invalid", realm="limes"
func (*Verifier) Introspect ¶ added in v0.129.0
Introspect answers an introspection endpoint (RFC 7662) about a token a resource server or gateway holds a copy of: nil error if it is active — genuine, from a trusted issuer, within its time window and not revoked, itself, through an ancestor or, for a Biscuit, through a copy — with the capability it carries. It checks neither an audience nor proof of possession: a key-bound token is active though no key was proved, and the answer says the token is bound, not that its presenter holds the key. A token lifted out of the Biscuit it was sealed in is refused, as Verify refuses it. So is a Biscuit copy that sets request or budget limits of its own, on a verifier without BiscuitConfig.MeterCopies: ErrCopyCountersNotMetered, as Verify answers it; with MeterCopies, the answer carries those limits in Introspection.Copies. A refusal of the token is ErrUnauthenticated's family, which the endpoint answers as inactive; any other error is the server's, such as a revocation lookup that failed, and never reads as inactive.
Introspect is no authorization: it yields no VerifiedCapability, so its answer can be neither delegated from nor checked against a request.
func (*Verifier) Verify ¶
func (v *Verifier) Verify(ctx context.Context, req VerifyRequest) (VerifiedCapability, error)
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; a refusal is one of the package's 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 JWT costs a header decode and one signature check, and no claim-parsing path is reachable by an attacker. A Biscuit is read further before its signatures are checked: its container and every block are decoded and their facts read as data, which MaxTokenBytes and MaxBiscuitBlocks bound; none of it is trusted until the signatures have verified. The checks that need no I/O come before the revocation lookup — the proof of possession among them, so a holder of a key-bound token without its key learns nothing of the token's revocation and causes no lookup — and the proof's id is recorded in the replay cache last, so neither a revoked token nor a copy this verifier cannot meter records one.
Example ¶
ctx := context.Background()
s := newExampleStack()
_, token := s.issue()
got, err := s.verifier.Verify(ctx, limes.VerifyRequest{
Token: token, Audience: exampleAudience, Scheme: limes.AuthSchemeBearer,
})
fmt.Println(err, got.Capability().Subject.Subject, got.FromBiscuit())
// An unbound token presented under DPoP is a downgrade the other way.
_, err = s.verifier.Verify(ctx, limes.VerifyRequest{
Token: token, Audience: exampleAudience, Scheme: limes.AuthSchemeDPoP,
})
fmt.Println(errors.Is(err, limes.ErrAuthSchemeMismatch), errors.Is(err, limes.ErrUnauthenticated))
Output: <nil> research-orchestrator false true true
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
// RevocationCache 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
// ErrInvalidToken.
TrustedIssuers []string
// KeyIssuers 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. Empty binds no kid: enough with one
// trusted issuer. With several it is required, unless SharedKeys says otherwise:
// any trusted key signing for any trusted issuer is wrong for issuers
// that do not share a trust boundary, so it is never the default.
// One key signs for two issuers — a family of capabilities marked by
// an iss of its own — under two kids, each bound here to its issuer:
// two Issuers with the same key, each with its kid and IssuerName.
// SharedKeys would let any trusted key sign either iss, which defeats
// the mark.
KeyIssuers map[string]string
// several issuers that do share one trust boundary. It and KeyIssuers
// exclude each other.
SharedKeys bool
// 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. Zero: DefaultLeeway; negative or past MaxLeeway
// is refused.
Leeway time.Duration
// NoLeeway holds nbf and exp to the verifier's clock exactly, for an
// issuer that shares it. It excludes a Leeway.
NoLeeway bool
// MaxTokenBytes rejects longer tokens before decoding them. Default
// DefaultMaxTokenBytes when zero; negative is refused.
MaxTokenBytes int
// Biscuit accepts the Biscuit form (Issuer.Biscuit, Attenuate) beside
// the JWT, as it says. Nil: a Biscuit is refused like any malformed
// token.
Biscuit *BiscuitConfig
// 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 ErrProofUnsupported, rather than accept
// one as a bearer token. Built by dpop.NewVerifier, with its own clock.
DPoP *dpop.Verifier
// Realm names the protection space in every WWW-Authenticate challenge
// ErrorResponse gives (RFC 9110 §11.5). Empty: DefaultRealm. Only the
// characters RFC 6750 §3 allows in an auth-param value.
Realm string
// Logger logs what ErrorResponse cannot answer the way the refusal
// asks: a nonce the DPoP verifier cannot give. Nil: slog.Default().
Logger *slog.Logger
}
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 dpop.Proof
// 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_revocation.go
- biscuit_revocation_cache.go
- bulk_revoke.go
- cache.go
- ceiling.go
- census.go
- challenge.go
- check.go
- code.go
- delegate.go
- doc.go
- effect.go
- erase_tenant.go
- errors.go
- exchange_store.go
- issuer.go
- key_namespace.go
- keyloader.go
- listing.go
- meter_subject.go
- nanos.go
- pages.go
- principal.go
- reason.go
- redact.go
- resource.go
- sealed_form.go
- signer.go
- store.go
- types.go
- unit.go
- validate.go
- verifier.go
- wire.go
Directories
¶
| Path | Synopsis |
|---|---|
|
biscuit
module
|
|
|
Package dpop implements proof of possession after RFC 9449 (DPoP): a client signs a short JWT over each request with a key it holds, and a server checks the proof against the key a token is bound to.
|
Package dpop implements proof of possession after RFC 9449 (DPoP): a client signs a short JWT over each request with a key it holds, and a server checks the proof against the key a token is bound to. |
|
dpoptest
Package dpoptest checks a dpop.ReplayCache and a dpop.Nonces against the package's contract, the way storetest checks a limes.Store.
|
Package dpoptest checks a dpop.ReplayCache and a dpop.Nonces against the package's contract, the way storetest checks a limes.Store. |
|
exchange
module
|
|
|
Package inspect reads a capability token for tooling — a CLI that shows a token, a debugger, a log viewer — without verifying it.
|
Package inspect reads a capability token for tooling — a CLI that shows a token, a debugger, a log viewer — without verifying it. |
|
internal
|
|
|
errguard
Package errguard finds the errors a module constructs without naming one of its sentinels (docs/adr/0012-error-codes.md): an errors.New, or an fmt.Errorf whose format wraps nothing (%w).
|
Package errguard finds the errors a module constructs without naming one of its sentinels (docs/adr/0012-error-codes.md): an errors.New, or an fmt.Errorf whose format wraps nothing (%w). |
|
errs
Package errs holds the root error families every package of the limes module wraps, so that dpop and jwks, which limes imports and so cannot import it, refuse with the same sentinels limes exports (limes.ErrInvalidRequest, limes.ErrInvalidConfig, limes.ErrInternal): one value under each name, matched by errors.Is from any of them.
|
Package errs holds the root error families every package of the limes module wraps, so that dpop and jwks, which limes imports and so cannot import it, refuse with the same sentinels limes exports (limes.ErrInvalidRequest, limes.ErrInvalidConfig, limes.ErrInternal): one value under each name, matched by errors.Is from any of them. |
|
expect
Package expect reports a failed expectation of the contract suites, storetest and metertest, as one call rather than an if around an Errorf or a Fatalf: a suite's checks then read as the list of what a store must do, and a check costs no branch of its own in the suite's complexity.
|
Package expect reports a failed expectation of the contract suites, storetest and metertest, as one call rather than an if around an Errorf or a Fatalf: a suite's checks then read as the list of what a store must do, and a check costs no branch of its own in the suite's complexity. |
|
fields
Package fields lets the contract suites, storetest and metertest, prove that what they write and read back covers every field of the module's types.
|
Package fields lets the contract suites, storetest and metertest, prove that what they write and read back covers every field of the module's types. |
|
httpx
Package httpx names the HTTP header fields and Cache-Control directives the module's packages write and read, so each is spelt in one place.
|
Package httpx names the HTTP header fields and Cache-Control directives the module's packages write and read, so each is spelt in one place. |
|
jose
Package jose holds the JOSE pieces the module's packages share: the algorithm and key identifiers, base64url, the compact serialisation, and the public JWK with its RFC 7638 thumbprint.
|
Package jose holds the JOSE pieces the module's packages share: the algorithm and key identifiers, base64url, the compact serialisation, and the public JWK with its RFC 7638 thumbprint. |
|
link
Package link lets the module's own packages reach what the root does not export: limes/inspect the token reader, and metertest, storetest, memstore and the tests of memstore and pgstore the constructors of a MeterSubject and a BiscuitCopy.
|
Package link lets the module's own packages reach what the root does not export: limes/inspect the token reader, and metertest, storetest, memstore and the tests of memstore and pgstore the constructors of a MeterSubject and a BiscuitCopy. |
|
rfc3986
Package rfc3986 normalises a URI the way RFC 3986 §6.2.2 and §6.2.3 say, for comparing two spellings of one request URI.
|
Package rfc3986 normalises a URI the way RFC 3986 §6.2.2 and §6.2.3 say, for comparing two spellings of one request URI. |
|
ttlcache
Package ttlcache caches a yes/no answer per key for a TTL, bounded in size, with concurrent misses for one key sharing one lookup.
|
Package ttlcache caches a yes/no answer per key for a TTL, bounded in size, with concurrent misses for one key sharing one lookup. |
|
watchdog
Package watchdog bounds one check of a contract suite.
|
Package watchdog bounds one check of a contract suite. |
|
Package jwks publishes and resolves the Ed25519 keys capability tokens are signed with, as an RFC 7517 JSON Web Key Set.
|
Package jwks publishes and resolves the Ed25519 keys capability tokens are signed with, as an RFC 7517 JSON Web Key Set. |
|
jwkstest
Package jwkstest checks a limes.KeyResolver against the module's contract, the way storetest checks a limes.Store.
|
Package jwkstest checks a limes.KeyResolver against the module's contract, the way storetest checks a limes.Store. |
|
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 meter counts what a verified capability uses — requests and spend, against the capability's ceilings, its ancestors', its Biscuit copies' and its tenant's — and keeps the charges ledger, for billing and reports.
|
Package meter counts what a verified capability uses — requests and spend, against the capability's ceilings, its ancestors', its Biscuit copies' and its tenant's — and keeps the charges ledger, for billing and reports. |
|
Package metertest checks a meter.UsageStore against the module's contract — its Meter, tenant budgets and housekeeping — the way testing/fstest checks an fs.FS.
|
Package metertest checks a meter.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. |