Skip to content

feat(notary): add per-principal machine evaluation quota#276

Closed
jeremi wants to merge 3 commits into
mainfrom
feat/notary-machine-batch-quota
Closed

feat(notary): add per-principal machine evaluation quota#276
jeremi wants to merge 3 commits into
mainfrom
feat/notary-machine-batch-quota

Conversation

@jeremi

@jeremi jeremi commented Jul 6, 2026

Copy link
Copy Markdown
Member

Summary

Adds evidence.machine_quota: a per-principal budget for machine
evaluate/batch_evaluate traffic that accounts for request rate and
batch size together, instead of controlling them independently.

  • Budget is counted in subjects over a fixed one-minute window per
    principal_id: a single /v1/evaluations call costs 1, a
    /v1/batch-evaluations call costs items.len().
  • A request whose cost would exceed the remaining budget is rejected whole
    (no partial consumption), so retrying with a smaller batch or waiting out
    the window is safe.
  • Exhaustion returns 429 with the stable error code
    evaluation.quota_exceeded and a Retry-After header (seconds until the
    window rolls over).
  • Disabled by default (enabled: false, subjects_per_minute: 6000 once
    enabled), so existing deployments are unaffected until an operator opts in.

Part of #78 (carries former #60 scope: "machine batch quota").

Design rationale

  • New module crates/registry-notary-server/src/machine_quota.rs: a
    fixed-window counter keyed by principal_id, mirroring the structure of
    the existing SelfAttestationRateLimiter but with cost-aware (rather than
    one-per-call) consumption and a bounded map with oldest-window eviction.
  • Enforcement lives in the evaluate/batch_evaluate handlers, in the
    non-self-attestation branch, after self-attestation classification and
    before the runtime is invoked.
  • EvidenceError::MachineQuotaExceeded { retry_after_seconds } is a new
    #[non_exhaustive] variant mapping to 429 / evaluation.quota_exceeded.
  • This is intentionally independent from, and does not touch, the existing
    per-request max_subjects batch-size cap, source limits, or the
    self-attestation public rate limiters.

Review notes (security-sensitive)

This adds a new denial surface on the evaluation path, so flagging for
explicit review:

  • Self-attestation principals never reach this limiter. Enforcement is
    gated on EvidencePrincipal::is_self_attestation() being false; a
    dedicated integration test (self_attestation_evaluate_succeeds_while_machine_quota_is_exhausted_for_same_principal_id)
    authenticates a self-attestation citizen JWT with the same sub as an
    already-exhausted machine principal and confirms the self-attestation call
    still succeeds.
  • The quota keys on principal_id, which is operator-configured only under
    auth.mode: api_key (the key's id). Under auth.mode: oidc,
    principal_id is the JWT sub (or whichever claim principal_claim
    names) — i.e. caller-supplied, not a static operator-configured id. This
    is the same identity Notary already uses for audit principal identity, but
    it's worth calling out explicitly since it's now also a rate-limit key.
  • Fails closed: a principal_id too long for the bounded key type is
    treated as over-quota rather than silently bypassing the limiter.
  • No change to authentication, credential issuance, signing, or audit
    redaction logic.

Verification

From the worktree root:

  • cargo fmt --check — clean
  • cargo check --locked --workspace --all-targets — clean
  • cargo clippy --locked -p registry-notary-core -p registry-notary-server --all-targets -- -D warnings — clean
  • cargo test --locked -p registry-notary-core -p registry-notary-server — all green:
    • registry-notary-core lib: 322 passed
    • registry-notary-server lib: 326 passed (includes 8 new machine_quota unit tests: exact boundary, window expiry, cost-exceeds-remaining rejects whole batch without partial consumption, map bound/eviction, disabled bypass, per-principal isolation, oversized-key fail-closed, zero-cost no-op)
    • New machine_quota_http_test integration file: 5/5 passed (disabled-by-default allows repeated max-size batches; enabled quota returns 429 deterministically with stable code + Retry-After; two machine credentials have independent budgets; single-evaluate and batch-evaluate share one budget; self-attestation bypasses an exhausted machine quota for a colliding principal_id)
    • All pre-existing registry-notary-server integration test files (standalone_http, concurrency_http_test, target_matching_contract_test, etc.) unchanged and passing
  • OpenAPI: the 429 response on /v1/evaluations and /v1/batch-evaluations already existed (from self-attestation rate limiting); updated its description to also mention the machine quota and regenerated the committed baseline via cargo run -q -p registry-notary -- openapi > products/notary/openapi/registry-notary.openapi.json. just openapi-check (from products/notary) passes.
  • just exposure-check (from products/notary) currently fails on main for an unrelated, pre-existing reason (referenced enforcement test file does not exist: crates/registry-notary-server/src/standalone.rs, which is a path-resolution issue in the check script, not a missing file); confirmed via git stash that this failure reproduces identically without this branch's changes.
  • Docs: added a "Machine Evaluation Quota" subsection to products/notary/docs/operator-config-reference.md and an Unreleased/Added entry to products/notary/CHANGELOG.md.

No deviations from the original design spec.

Registry Notary had per-request size caps and source limits, but no budget
on sustained evaluate/batch_evaluate volume from a single machine principal.
Add evidence.machine_quota: a fixed one-minute window, counted in subjects
(a single evaluate costs 1, a batch costs items.len()), keyed by
principal_id, and enforced only for principals that are not classified as
self-attestation. A request whose cost would exceed the remaining budget is
rejected whole, so a denied batch never partially consumes the window.
Exhaustion returns 429 with the stable code evaluation.quota_exceeded and a
Retry-After header.

The quota is disabled by default (subjects_per_minute: 6000 when enabled),
so existing deployments are unaffected until an operator opts in.

Part of #78 (carries former #60 scope).

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 222696a96e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +6279 to +6282
let batch_cost = u32::try_from(request.items.len()).unwrap_or(u32::MAX);
if let Err(error) = state
.machine_quota_limiter
.check_and_consume(&principal.principal_id, batch_cost)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve idempotent batch retries before charging quota

When machine quota is enabled, this check runs before runtime.batch_evaluate reaches its existing idempotency-key lookup (store.idempotent_batch). If a client successfully submits a batch that uses the remaining budget but loses the response, retrying the same Idempotency-Key and body is rejected with 429 (or consumes more quota) instead of returning the cached response, so the batch endpoint is no longer safely retryable under the quota.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Fixed in 5c0d46d: batch idempotency lookup now happens before machine quota debit, and idempotent replay returns the cached response without spending more quota. Added idempotent_batch_replay_does_not_consume_machine_quota; the machine quota HTTP suite passes.

Comment on lines +6279 to +6282
let batch_cost = u32::try_from(request.items.len()).unwrap_or(u32::MAX);
if let Err(error) = state
.machine_quota_limiter
.check_and_consume(&principal.principal_id, batch_cost)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject oversized batches before consuming quota

With machine quota enabled, this consumes items.len() before runtime.batch_evaluate enforces the per-claim max_subjects cap. An over-limit batch can therefore either spend quota and then return 413, or return 429 instead of the existing batch.too_large response when its size exceeds the remaining quota, even though the request is invalid under the independent batch-size cap.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Fixed in 5c0d46d: batch max-subject validation now runs before quota debit, so oversized batches keep returning 413 batch.too_large and do not consume machine quota. Added oversized_batch_does_not_consume_machine_quota; the machine quota HTTP suite passes.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5c0d46d55d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

`evaluation.quota_exceeded` and a `Retry-After` header giving the number of
seconds until the window rolls over. The quota is disabled by default; enable
it and size `subjects_per_minute` to the traffic pattern of your machine
callers before relying on it in production.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark machine quotas as per-instance

In multi-replica deployments this quota is only enforced per process: the limiter stores counters in an in-memory Mutex<HashMap<...>> and each RegistryNotaryApiState constructs its own limiter, so a client balanced across N Notary replicas can spend up to N× subjects_per_minute. Advising operators to size this before relying on it in production is misleading unless the docs call it per-instance or the limiter uses shared storage.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Fixed in 47f608a: the operator config reference now states the quota is enforced per Notary instance, that N replicas give a caller up to N x subjects_per_minute, and points operators at replica-count sizing or a shared limiter.

The machine evaluation quota limiter keeps its counters in an in-memory
map and each RegistryNotaryApiState builds its own limiter, so the quota
is enforced per Notary process, not cluster-wide. Document that N
replicas give a caller up to N x subjects_per_minute, and point
operators at replica-count sizing or a shared limiter.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
@jeremi

jeremi commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

Superseded by the integration meta-PR #292 — this branch is merged there unchanged (with cross-PR conflicts resolved once). Branch retained.

@jeremi jeremi closed this Jul 7, 2026
@jeremi jeremi deleted the feat/notary-machine-batch-quota branch July 11, 2026 09:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant