From d579c93d88a6e3c57fffbdd64e62520b789b779b Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sun, 10 May 2026 23:37:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(mcp):=2011=20audit-fix=20MCP=20tools=20?= =?UTF-8?q?=E2=80=94=20approvals,=20break-glass,=20bootstrap,=20audit-cate?= =?UTF-8?q?gory=20(MED-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit 2026-05-10 MED-13 closure. WHAT. 11 new MCP tools rounding out the operator surface for workflows that previously had GUI + CLI coverage but no MCP equivalent: Approval workflow (4): certctl_approval_list GET /v1/approvals approval.read certctl_approval_get GET /v1/approvals/{id} approval.read certctl_approval_approve POST /v1/approvals/{id}/approve approval.approve certctl_approval_reject POST /v1/approvals/{id}/reject approval.reject Break-glass credential admin (4): certctl_breakglass_list GET /v1/auth/breakglass/credentials certctl_breakglass_set_password POST /v1/auth/breakglass/credentials certctl_breakglass_unlock POST /v1/auth/breakglass/credentials/{actor_id}/unlock certctl_breakglass_remove DELETE /v1/auth/breakglass/credentials/{actor_id} All gated auth.breakglass.admin; surface invisible (404 not 403) when CERTCTL_BREAKGLASS_ENABLED=false. Bootstrap (2): certctl_bootstrap_status GET /v1/auth/bootstrap (auth-exempt; safe probe) certctl_bootstrap_consume POST /v1/auth/bootstrap (auth-exempt; one-shot mint) Audit category filter (1): certctl_audit_list_with_category GET /v1/audit?category= audit.read WHY. certctl_bootstrap_consume is the load-bearing day-0 primitive: a fresh server with no admin actors lets the holder of CERTCTL_BOOTSTRAP_TOKEN mint a fresh admin API key. Exposing it via MCP without a security gate would let a downstream caller mint admin from any chat transcript / log surface that captured the bootstrap token. The tool description carries an explicit cautious-wording comment: CAUTION: NEVER WIRE THIS TO AUTONOMOUS OPERATION. A leaked bootstrap token from any log, telemetry, or chat-transcript surface lets a downstream caller mint a fresh admin API key bypassing every other access-control gate. Run this manually, exactly once, from a trusted shell. Similarly certctl_breakglass_set_password's description flags that the password crosses the MCP transport in plaintext; the server-side handler hashes with Argon2id before persisting + the audit row redacts, but client-side logging must NEVER capture the payload. HOW. internal/mcp/tools_audit_fix.go (NEW): registerAuditFixTools(s, c) — declares the 11 tools via gomcp.AddTool. Each tool routes through the existing Client.Get/ Post/Delete helpers; the server-side rbacGate wrappers (or auth-exempt allowlist, for bootstrap) handle authorization. internal/mcp/types.go: Adds 5 input structs: ApprovalIDInput (get/approve/reject) BreakglassActorIDInput (unlock/remove) BreakglassSetPasswordInput (set_password — flagged plaintext) BootstrapConsumeInput (token + key_name; cautious comment) AuditListWithCategoryInput (category + optional limit/since/until/actor_id) Each tagged with jsonschema descriptions for LLM tool discovery. internal/mcp/tools.go: RegisterTools now calls registerAuditFixTools after the existing Bundle 2 Phase 9 registrar. internal/mcp/tools_per_tool_test.go: allHappyPathCases extended with 11 new entries. The existing TestMCP_AllTools_HappyPath dispatches each tool via the in-memory MCP transport against a 2xx mock backend and asserts the wrapper-layer fence wraps the response; TestMCP_AllTools_ErrorPath dispatches against a 5xx mock and asserts MCP_ERROR fence. TestMCP_RegisterTools_DispatchableToolCount confirms every new tool is dispatchable by name. VERIFY. - go vet ./internal/mcp/... PASS - go test -short -count=1 -run 'TestMCP_AllTools_HappyPath|TestMCP_AllTools_ErrorPath| TestMCP_RegisterTools_DispatchableToolCount' ./internal/mcp/... PASS - go test -short -count=1 ./internal/mcp/... PASS (0.3s) Refs: cowork/auth-bundles-audit-2026-05-10.md MED-13 cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 4 --- CHANGELOG.md | 13 ++ internal/mcp/tools.go | 7 + internal/mcp/tools_audit_fix.go | 221 ++++++++++++++++++++++++++++ internal/mcp/tools_per_tool_test.go | 13 ++ internal/mcp/types.go | 46 ++++++ 5 files changed, 300 insertions(+) create mode 100644 internal/mcp/tools_audit_fix.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eecdf6..cd9be7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,19 @@ RFC-9207 discovery. Providers that don't advertise support (the majority today) keep pre-fix behavior — back-compat is preserved. +- **11 new MCP tools (Audit 2026-05-10 MED-13).** Approval workflow + (`certctl_approval_list` / `_get` / `_approve` / `_reject`), break-glass + credential admin (`certctl_breakglass_list` / `_set_password` / + `_unlock` / `_remove`), bootstrap status + consume + (`certctl_bootstrap_status` / `_consume`), and audit category filter + (`certctl_audit_list_with_category`). All route through the existing + HTTP client so server-side permission gates fire unchanged. + `certctl_bootstrap_consume`'s tool description carries an explicit + "NEVER WIRE THIS TO AUTONOMOUS OPERATION" warning — a leaked + bootstrap token mints a fresh admin API key bypassing every other + access-control gate, so the tool is for one-shot manual operator + invocation only. + - **JWKS auto-refresh on cache-miss (Audit 2026-05-10 MED-6).** When the IdP rotates its signing key between pre-login + callback, the cached JWKS no longer contains the kid referenced by the inbound ID diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 4e3a78c..afec74a 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -51,6 +51,13 @@ func RegisterTools(s *gomcp.Server, client *Client) { // existing HTTP client; permission gates fire server-side via the // Phase-5 rbacGate wrappers. See internal/mcp/tools_auth_bundle2.go. registerAuthBundle2Tools(s, client) + // Audit 2026-05-10 MED-13 — 11 tools rounding out the operator + // surface: approvals (4) + break-glass admin (4) + bootstrap + // status/consume (2) + audit category filter (1). See + // internal/mcp/tools_audit_fix.go for the per-tool wiring + the + // security comment on certctl_bootstrap_consume (never wire to + // autonomous operation; one-shot token-minting primitive). + registerAuditFixTools(s, client) // Phase G P1-33 (POST /api/v1/agents/{id}/discoveries) is // intentionally NOT exposed via MCP — it is a machine-to-machine // channel for agents to push filesystem-scan reports, not an diff --git a/internal/mcp/tools_audit_fix.go b/internal/mcp/tools_audit_fix.go new file mode 100644 index 0000000..27377b5 --- /dev/null +++ b/internal/mcp/tools_audit_fix.go @@ -0,0 +1,221 @@ +package mcp + +// Audit 2026-05-10 MED-13 closure — 11 new MCP tools that round out +// the MCP surface for the operator workflows that previously had GUI + +// CLI coverage but no MCP equivalent: approval workflow (4), +// break-glass credential admin (4), bootstrap-status/consume (2), +// audit list with category filter (1). +// +// Coverage map (each tool → HTTP endpoint → permission): +// +// certctl_approval_list GET /v1/approvals approval.read +// certctl_approval_get GET /v1/approvals/{id} approval.read +// certctl_approval_approve POST /v1/approvals/{id}/approve approval.approve +// certctl_approval_reject POST /v1/approvals/{id}/reject approval.reject +// certctl_breakglass_list GET /v1/auth/breakglass/credentials auth.breakglass.admin +// certctl_breakglass_set_password POST /v1/auth/breakglass/credentials auth.breakglass.admin +// certctl_breakglass_unlock POST /v1/auth/breakglass/credentials/{actor_id}/unlock auth.breakglass.admin +// certctl_breakglass_remove DELETE /v1/auth/breakglass/credentials/{actor_id} auth.breakglass.admin +// certctl_bootstrap_status GET /v1/auth/bootstrap (token; auth-exempt) +// certctl_bootstrap_consume POST /v1/auth/bootstrap (token; auth-exempt) +// certctl_audit_list_with_category GET /v1/audit?category= audit.read +// +// Hygiene notes carried into the audit row by the server-side handler: +// - approval reject + breakglass set/remove are PERMANENTLY operator- +// consequential. MCP tools simply pass the call through; the +// server-side endpoint emits the audit row. +// - bootstrap_consume is the load-bearing one-shot token-exchange +// primitive. Tool description carries an explicit cautious-wording +// comment: "never wire this to autonomous operation — a leaked +// bootstrap token mints a fresh admin API key." + +import ( + "context" + "net/url" + + gomcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerAuditFixTools(s *gomcp.Server, c *Client) { + // ── Approvals (4) ─────────────────────────────────────────────────── + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_approval_list", + Description: "List pending approval requests (GET /v1/approvals). Approval workflow primitive: certificate issuance + profile-edit operations gated on `CertificateProfile.RequiresApproval=true` materialize an `issuance_approval_requests` row that one approver of a different actor than the requester must approve before the request actually executes. Permission: approval.read.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, _ struct{}) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/approvals", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_approval_get", + Description: "Get a single approval request by id (GET /v1/approvals/{id}). The response carries the approval payload — a JSON envelope with `before`+`after` for profile edits, or the full `IssuanceRequest` for certificate issuance. Permission: approval.read.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ApprovalIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/approvals/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_approval_approve", + Description: "Approve a pending approval request (POST /v1/approvals/{id}/approve). The server-side service-layer rejects with ErrApproveBySameActor if the caller is the same actor who originated the request (same-actor self-approve is forbidden — the security primitive requires a SECOND human/key/actor sign-off). On success, the approval executes the requested operation. Permission: approval.approve.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ApprovalIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/approvals/"+input.ID+"/approve", map[string]string{}) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_approval_reject", + Description: "Reject a pending approval request (POST /v1/approvals/{id}/reject). The originating request is permanently denied; a new request must be created if the requester still wants the operation. Permission: approval.reject.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ApprovalIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/approvals/"+input.ID+"/reject", map[string]string{}) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + // ── Break-glass (4) ───────────────────────────────────────────────── + // + // Break-glass is a deliberate bypass of the SSO security boundary. + // The whole feature is invisible (404 NOT 403) when + // CERTCTL_BREAKGLASS_ENABLED=false. Operators turn it on during SSO + // incidents and OFF after recovery. + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_breakglass_list", + Description: "List configured break-glass credentials (GET /v1/auth/breakglass/credentials). Each row carries the actor_id + role + lockout-counter state. Break-glass is a deliberate SSO-bypass: it lets a designated admin log in via username+password when the OIDC IdP is down. Permission: auth.breakglass.admin. Returns 404 when CERTCTL_BREAKGLASS_ENABLED is false.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, _ struct{}) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/auth/breakglass/credentials", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_breakglass_set_password", + Description: "Set or update a break-glass credential password (POST /v1/auth/breakglass/credentials). Body: {actor_id, password, role_id}. The server-side handler hashes the password with Argon2id (RFC 9106, m=64MiB, t=3, p=4) before persisting. Returns 404 when CERTCTL_BREAKGLASS_ENABLED is false. NEVER log the password — the MCP transport sees plaintext; the server-side audit row redacts. Permission: auth.breakglass.admin.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input BreakglassSetPasswordInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/auth/breakglass/credentials", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_breakglass_unlock", + Description: "Reset the lockout counter on a break-glass credential (POST /v1/auth/breakglass/credentials/{actor_id}/unlock). Use after a failed-attempts lockout: the credential is locked for CERTCTL_BREAKGLASS_LOCKOUT_DURATION after CERTCTL_BREAKGLASS_LOCKOUT_THRESHOLD bad attempts; this tool clears the counter ahead of the natural expiry. Permission: auth.breakglass.admin.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input BreakglassActorIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/auth/breakglass/credentials/"+input.ActorID+"/unlock", map[string]string{}) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_breakglass_remove", + Description: "Permanently remove a break-glass credential (DELETE /v1/auth/breakglass/credentials/{actor_id}). Operator-consequential — once removed, the actor can no longer log in via break-glass; a new credential must be set via certctl_breakglass_set_password. Permission: auth.breakglass.admin.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input BreakglassActorIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Delete("/api/v1/auth/breakglass/credentials/" + input.ActorID) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + // ── Bootstrap (2) ─────────────────────────────────────────────────── + // + // The bootstrap endpoints (GET probe + POST consume) are + // AUTH-EXEMPT — they authenticate via the + // CERTCTL_BOOTSTRAP_TOKEN pre-shared secret, not via the + // caller's API key. The probe is safe; the consume is the + // load-bearing one-shot that mints an admin API key on a fresh + // server. NEVER WIRE certctl_bootstrap_consume INTO AUTONOMOUS + // OPERATION — a leaked bootstrap token from any log/telemetry/ + // chat-transcript surface would let a downstream caller mint a + // fresh admin key. + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_bootstrap_status", + Description: "Probe whether the day-0 bootstrap endpoint is currently callable (GET /v1/auth/bootstrap). Returns 200 with `{available: bool, reason: }` — `available=true` only on a fresh server with no admin-roled actors AND with CERTCTL_BOOTSTRAP_TOKEN set. This tool is safe — read-only, no credentials, no audit row.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, _ struct{}) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/auth/bootstrap", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_bootstrap_consume", + Description: "Consume the day-0 bootstrap token to mint a fresh admin API key (POST /v1/auth/bootstrap). Body: {token, key_name}. This is the load-bearing one-shot primitive that creates the FIRST admin key on a fresh certctl server. CAUTION: NEVER WIRE THIS TO AUTONOMOUS OPERATION. A leaked bootstrap token from any log, telemetry, or chat-transcript surface lets a downstream caller mint a fresh admin key bypassing every other access-control gate. Run this manually, exactly once, from a trusted shell. The server-side audit row redacts the token but preserves the resulting key_id. AUTH-EXEMPT (the token IS the auth).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input BootstrapConsumeInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/auth/bootstrap", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + // ── Audit category filter (1) ─────────────────────────────────────── + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_audit_list_with_category", + Description: "List audit events filtered by category (GET /v1/audit?category=). Categories: auth (login/logout/role changes), pki (issuance/renew/revoke), config (provider/profile/issuer edits), system (startup/shutdown/scheduler events), security (alerts, intrusion-detection). Pass `category` to narrow. Other query params (limit, since, until, actor_id) accepted verbatim. Permission: audit.read. Use this when investigating a specific class of operation; for full unfiltered access use the underlying GET /v1/audit directly.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AuditListWithCategoryInput) (*gomcp.CallToolResult, any, error) { + q := url.Values{} + if input.Category != "" { + q.Set("category", input.Category) + } + if input.Limit > 0 { + q.Set("limit", intToString(input.Limit)) + } + if input.Since != "" { + q.Set("since", input.Since) + } + if input.Until != "" { + q.Set("until", input.Until) + } + if input.ActorID != "" { + q.Set("actor_id", input.ActorID) + } + data, err := c.Get("/api/v1/audit", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// intToString is a tiny stdlib-free int formatter used by the +// audit category tool to encode int Limit into the query string +// without dragging in strconv at the call site (keeps the tool +// definitions compact). +func intToString(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + buf := [20]byte{} + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/internal/mcp/tools_per_tool_test.go b/internal/mcp/tools_per_tool_test.go index cd9338f..7442bb4 100644 --- a/internal/mcp/tools_per_tool_test.go +++ b/internal/mcp/tools_per_tool_test.go @@ -452,6 +452,19 @@ var allHappyPathCases = []toolCase{ {"certctl_auth_remove_group_mapping", map[string]any{"id": "gm-1"}, http.MethodDelete, "/api/v1/auth/oidc/group-mappings/gm-1"}, {"certctl_auth_list_sessions", map[string]any{}, http.MethodGet, "/api/v1/auth/sessions"}, {"certctl_auth_revoke_session", map[string]any{"id": "ses-abc"}, http.MethodDelete, "/api/v1/auth/sessions/ses-abc"}, + + // Audit 2026-05-10 MED-13 — 11 tools (approvals + breakglass + bootstrap + audit-category). + {"certctl_approval_list", map[string]any{}, http.MethodGet, "/api/v1/approvals"}, + {"certctl_approval_get", map[string]any{"id": "aprq-1"}, http.MethodGet, "/api/v1/approvals/aprq-1"}, + {"certctl_approval_approve", map[string]any{"id": "aprq-1"}, http.MethodPost, "/api/v1/approvals/aprq-1/approve"}, + {"certctl_approval_reject", map[string]any{"id": "aprq-1"}, http.MethodPost, "/api/v1/approvals/aprq-1/reject"}, + {"certctl_breakglass_list", map[string]any{}, http.MethodGet, "/api/v1/auth/breakglass/credentials"}, + {"certctl_breakglass_set_password", map[string]any{"actor_id": "bg-admin1", "password": "test-pass-strong-1", "role_id": "r-admin"}, http.MethodPost, "/api/v1/auth/breakglass/credentials"}, + {"certctl_breakglass_unlock", map[string]any{"actor_id": "bg-admin1"}, http.MethodPost, "/api/v1/auth/breakglass/credentials/bg-admin1/unlock"}, + {"certctl_breakglass_remove", map[string]any{"actor_id": "bg-admin1"}, http.MethodDelete, "/api/v1/auth/breakglass/credentials/bg-admin1"}, + {"certctl_bootstrap_status", map[string]any{}, http.MethodGet, "/api/v1/auth/bootstrap"}, + {"certctl_bootstrap_consume", map[string]any{"token": "test-token", "key_name": "day-zero-admin"}, http.MethodPost, "/api/v1/auth/bootstrap"}, + {"certctl_audit_list_with_category", map[string]any{"category": "auth"}, http.MethodGet, "/api/v1/audit"}, } // TestMCP_AllTools_HappyPath dispatches every tool against the mock API in diff --git a/internal/mcp/types.go b/internal/mcp/types.go index bd9318b..fb30779 100644 --- a/internal/mcp/types.go +++ b/internal/mcp/types.go @@ -689,3 +689,49 @@ type AuthListSessionsInput struct { type AuthRevokeSessionInput struct { ID string `json:"id" jsonschema:"Session ID (e.g. ses-abc123). Server-side own-bypass: caller may revoke their own session even without auth.session.revoke."` } + +// ============================================================================= +// Audit 2026-05-10 MED-13 — input shapes for the 11 new MCP tools +// (approvals + breakglass + bootstrap + audit category filter). +// ============================================================================= + +// ApprovalIDInput is the id-only input for approval get/approve/reject. +type ApprovalIDInput struct { + ID string `json:"id" jsonschema:"Approval request ID (e.g. aprq-abc123). Returned by certctl_approval_list."` +} + +// BreakglassActorIDInput is the actor-id-only input for the unlock + remove tools. +type BreakglassActorIDInput struct { + ActorID string `json:"actor_id" jsonschema:"Break-glass actor ID (e.g. bg-admin1). Listed by certctl_breakglass_list."` +} + +// BreakglassSetPasswordInput is the body for certctl_breakglass_set_password. +// +// SECURITY: the password field crosses the MCP transport in +// plaintext. The server-side handler hashes with Argon2id before +// persisting; the audit row redacts the password column. Never log +// this payload at the client side. +type BreakglassSetPasswordInput struct { + ActorID string `json:"actor_id" jsonschema:"Break-glass actor ID (e.g. bg-admin1). New row created if not present."` + Password string `json:"password" jsonschema:"Plaintext password (hashed server-side with Argon2id). Choose >=14 chars from a strong-entropy source; this is the SSO-bypass credential."` + RoleID string `json:"role_id" jsonschema:"Role ID granted on successful break-glass login (e.g. r-admin). Typically r-admin for production break-glass."` +} + +// BootstrapConsumeInput is the body for certctl_bootstrap_consume. +// +// SECURITY: NEVER wire this tool into autonomous operation. A leaked +// bootstrap token mints a fresh admin API key bypassing every other +// access-control gate. Run manually, once, from a trusted shell. +type BootstrapConsumeInput struct { + Token string `json:"token" jsonschema:"The pre-shared CERTCTL_BOOTSTRAP_TOKEN value (one-shot, constant-time-compared server-side, never logged)."` + KeyName string `json:"key_name" jsonschema:"Human-readable name for the new admin API key (e.g. 'day-zero-admin'). Subsequently visible in certctl_auth_list_keys."` +} + +// AuditListWithCategoryInput is the input for the category-filtered audit list. +type AuditListWithCategoryInput struct { + Category string `json:"category,omitempty" jsonschema:"Audit category filter. One of: auth, pki, config, system, security. Empty returns unfiltered (equivalent to GET /v1/audit)."` + Limit int `json:"limit,omitempty" jsonschema:"Maximum rows to return. Server default applies when 0."` + Since string `json:"since,omitempty" jsonschema:"RFC3339 timestamp lower bound (inclusive). Optional."` + Until string `json:"until,omitempty" jsonschema:"RFC3339 timestamp upper bound (exclusive). Optional."` + ActorID string `json:"actor_id,omitempty" jsonschema:"Filter by originating actor ID. Optional."` +}