diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5de334571..12cb118d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3379,3 +3379,17 @@ Core Session or container creation. One conversation/Agent binding freezes the request on first execution; later messages and observer retries reuse it. Separate conversations get independent Sessions and environments. Edits affect future Sessions only. Keep the existing product navigation and direct empty-chat composer. + +### API-key write provenance + +Public resource writes carry authenticated key provenance separately from the +execution principal. Persist their operation record and genuine creation ownership +in the same business transaction; no best-effort response middleware or async audit +queue. A failed audit must roll back the write. Internal lifecycle/refresh work does +not acquire public provenance. Retries never replace ownership. Environment uploads +persist safe request origin before dispatch and record success with the confirmed +receipt, not the native filesystem call. Never put payloads, paths or secrets in +audit metadata. Read models are deployment-authenticated `/core/v1` extensions; +keep `/v1` wire contracts unchanged. See +[write-audit.md](contracts/agents-api/write-audit.md) for coverage, retention and +console integration. Do not confuse key identity with Session creator identity. diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index aaba9946c..52ad410e6 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -905,3 +905,7 @@ Session or Environment observations and is not a readiness endpoint. Model endpoints and credentials may be supplied at Session creation through the [write-only execution extension](model-execution.md). Provider catalogs and their business permissions remain client/product responsibilities. + +API-key ownership and write history are documented in +[write-audit.md](write-audit.md). These are deployment-authenticated Core console +extensions; the pinned public `/v1` schema is unchanged. diff --git a/contracts/agents-api/sandbox-manager.openapi.yaml b/contracts/agents-api/sandbox-manager.openapi.yaml index 26ae534b8..e9d7d2807 100644 --- a/contracts/agents-api/sandbox-manager.openapi.yaml +++ b/contracts/agents-api/sandbox-manager.openapi.yaml @@ -23,6 +23,13 @@ definitions: name: type: string type: object + api.ResourceOwnerList: + properties: + data: + items: + $ref: '#/definitions/store.ResourceOwner' + type: array + type: object api.SandboxAllocationList: properties: data: @@ -82,6 +89,19 @@ definitions: $ref: '#/definitions/store.RuntimeNode' type: array type: object + store.AuditAPIKey: + properties: + id: + type: string + kind: + type: string + name: + type: string + prefix: + type: string + revoked_at: + type: string + type: object store.IssuedExecutorCredential: properties: environment_id: @@ -119,6 +139,13 @@ definitions: revoked_at: type: string type: object + store.ResourceOwner: + properties: + api_key: + $ref: '#/definitions/store.AuditAPIKey' + resource_id: + type: string + type: object store.RuntimeDeploymentView: properties: core_url: @@ -273,6 +300,38 @@ definitions: maintenance: type: boolean type: object + store.WriteOperation: + properties: + action: + type: string + api_key: + $ref: '#/definitions/store.AuditAPIKey' + created_at: + type: string + id: + type: string + parent_id: + type: string + request_id: + type: string + resource_id: + type: string + resource_type: + type: string + trace_id: + type: string + type: object + store.WriteOperationPage: + properties: + data: + items: + $ref: '#/definitions/store.WriteOperation' + type: array + has_more: + type: boolean + next_cursor: + type: string + type: object v1.APIError: properties: code: @@ -519,6 +578,53 @@ paths: summary: Revoke a project API key tags: - Project API Keys + /core/v1/resource-owners: + get: + description: Deployment administrator only. A configured binding selects the project. Returns null for resources without recorded creation provenance, including historical and foreign resources. No key secret is returned. + parameters: + - description: Configured caller SHA-256 binding selector + in: query + name: binding_digest + required: true + type: string + - description: Resource type + in: query + name: resource_type + required: true + type: string + - description: Comma-separated public resource IDs, maximum 100 + in: query + name: resource_ids + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.ResourceOwnerList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: Batch lookup resource creation keys + tags: + - Write Audit /core/v1/sandbox/deployment: get: description: Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. @@ -1010,6 +1116,71 @@ paths: summary: List retained allocations on a sandbox node tags: - Sandbox Manager + /core/v1/write-operations: + get: + description: Deployment administrator only. Reverse chronological keyset pagination over committed writes. Creation records remain; other records follow configured retention. A configured binding selects the project, never a caller-supplied tenant. + parameters: + - description: Configured caller SHA-256 binding selector + in: query + name: binding_digest + required: true + type: string + - description: Issued UUID or static key identifier + in: query + name: key_id + type: string + - description: Resource type + in: query + name: resource_type + type: string + - description: Public resource ID + in: query + name: resource_id + type: string + - description: Inclusive RFC3339 timestamp + in: query + name: created_after + type: string + - description: Exclusive RFC3339 timestamp + in: query + name: created_before + type: string + - description: Page size, 1-100, default 50 + in: query + name: limit + type: integer + - description: Opaque next_cursor from the preceding page + in: query + name: after + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/store.WriteOperationPage' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: Query API-key write operations + tags: + - Write Audit schemes: - http - https diff --git a/contracts/agents-api/write-audit.md b/contracts/agents-api/write-audit.md new file mode 100644 index 000000000..cad2ce026 --- /dev/null +++ b/contracts/agents-api/write-audit.md @@ -0,0 +1,131 @@ +# API-key write provenance + +This Core extension does not change the pinned public `/v1` protocol. It serves +administrator consoles; it is not an HTTP access log, execution event stream or +replacement for a Session's creator identity. + +## Authentication and ownership + +Authentication carries the actual issued key UUID, independently of its inherited +principal. Static configured keys use `static:` as a stable, +non-secret identifier and the digest's first eight characters as the display prefix. +Optional `name` labels a static key; optional `kind: "console"` identifies a trusted +console credential (`static` is the default). Newly generated installer bindings use +`name: "Console", kind: "console"`. These fields describe the credential, not the +HTTP client: using that same key in an SDK retains its console label. Client headers +cannot assert a key identity or console origin. Display renaming does not change ID. + +True creation stores ownership in the same transaction as the resource and operation. +Updates, retries and successful no-ops never replace it. Initial Skill versions and +new Session Environments share their parent creation operation's provenance. Runtime +produced Artifacts have no API-key creation anchor; deleting one is still recorded. +Missing historical or foreign ownership is `null`. No historical resources are backfilled. + +An issued key's current `revoked_at` comes from its retained key row. Revocation +prevents new authentication but does not invalidate already admitted work or erase +history. Removing a static key from configuration stops authentication; there is no +static-key revocation timestamp, so `revoked_at` remains null. Recorded static key +name/prefix/kind are immutable snapshots. Deleting a resource does not delete its +operation history or creation anchor. + +## Transaction and success boundary + +A successful write means a durable business commit, not successful HTTP delivery or +successful subsequent model execution. Each request has a server-generated +`request_id` for deduplication and the request's diagnostic `trace_id`. A shared +trace across requests is not an idempotency key. Business rollback also rolls back +the operation and ownership. Failure to persist the audit fails the transaction. +Reads, failed validation/authorization and internal maintenance writes are not logged. + +Session input is recorded when durably admitted, including prepared-Environment +reservations. Later execution failure or response disconnection does not undo that +accepted write. Explicit successful empty-input and creation/deletion retries get +one operation without changing ownership. Automatic promotion/refresh/cleanup does +not create another public operation. + +Environment file bytes are written by the existing Runtime, outside PostgreSQL. +Preparation durably stores only the safe key/request/trace origin. The confirmed +success receipt and audit commit together; uncertain/failed uploads are not reported +as successful operations. Recovery uses the saved origin. This is not a claim of +atomic transactions across the filesystem and PostgreSQL. + +Recorded fields are ID, timestamp, safe key metadata, action, resource type/ID, +parent ID, request ID and trace ID. Never store request/response bodies, bearer +secrets, model credentials, tokens, file paths or file contents in these tables. + +| Public write | Action | Resource / parent | +| --- | --- | --- | +| Agent create/update/delete | create/update/delete | agent | +| Session create/update/delete | create/update/delete | session | +| Session events | send_events | session | +| Session Artifact delete | delete | artifact / session | +| Environment file upload | upload_file | environment / session | +| Environment Template create/update/delete | create/update/delete | environment_template | +| Skill create/default/delete | create/update_default_version/delete | skill | +| Skill version upload/delete | upload_version/delete | skill_version / skill | +| Source File upload/delete | create/delete | file | +| Vault create/delete | create/delete | vault | +| Credential create/replace/delete | create/update/delete | credential / vault | + +Explicit public OAuth Credential writes are covered. Automatic OAuth refresh is not +a separate public write. Public resource IDs retain their existing formats. + +## Console queries + +Both endpoints require the deployment Bearer credential used by +`/core/v1/project-api-keys`. Project keys, including console project credentials, +cannot call them. Required `binding_digest` selects an existing configured static +binding and its tenant/project; it does not authenticate. There is no arbitrary +tenant selector. Two bindings for the same project see the same project history. + +### Batch ownership + +`GET /core/v1/resource-owners?binding_digest=...&resource_type=agent&resource_ids=id1,id2` + +`resource_type` is one of `agent`, `session`, `environment`, +`environment_template`, `skill`, `skill_version`, `file`, `vault`, `credential`, +`artifact`. Pass 1–100 comma-separated public IDs. Results preserve input order: + +```json +{"data":[ + {"resource_id":"id1","api_key":{"id":"key-uuid","name":"SDK","prefix":"pc_example","kind":"issued","revoked_at":null}}, + {"resource_id":"id2","api_key":null} +]} +``` + +### Operations + +`GET /core/v1/write-operations?binding_digest=...&limit=50` + +Optional filters: `key_id`, `resource_type`, `resource_id`, `created_after` +(inclusive RFC3339 timestamp), `created_before` (exclusive RFC3339 timestamp). +`limit` is 1–100, default 50. Pass the previous `next_cursor` as `after`, keeping +filters unchanged. Results sort by descending `(created_at, id)` with keyset +pagination; a cursor is not an authorization token or a snapshot of future writes. +The response is `{ "data": [...], "has_more": false, "next_cursor": "" }`. +Each operation contains `id`, `created_at`, `api_key`, `action`, `resource_type`, +`resource_id`, `parent_id`, `request_id`, `trace_id`. An absent parent is an empty +string. Empty pages contain `data: []`. + +Malformed/duplicate/unknown query parameters return 400, missing configured binding +404, invalid deployment authentication 401. These rules belong only to the new +Core routes and do not alter public list parsing or errors. Creation records remain +queryable after resource deletion; expired non-creation records do not. + +## Retention + +`AGENTS_API_WRITE_AUDIT_RETENTION` accepts a Go duration of at least one hour; +default `2160h` (90 days). Every minute Core removes at most 1,000 expired +non-creation records in a bounded transaction. Creation records and anchors are +retained permanently, which includes the entire lifetime of a resource and its +history after deletion. Key revocation/resource deletion never triggers cleanup. +Retention removes only aged non-creation operations; it does not erase key metadata, +resource rows or ownership. A retention backlog can take multiple passes to drain. + +## Validation + +The batch's Store tests cover each mutation, induced audit-write failure with +business rollback, tenant isolation, ownership immutability, revocation, cursor +filters and retention. HTTP tests cover authenticated source propagation and +administrator-only query validation. Live evidence and remaining limitations are +recorded separately; passing these tests does not claim full Agents API compatibility. diff --git a/deploy/install/install.py b/deploy/install/install.py index 5f88ff4c5..78187341d 100644 --- a/deploy/install/install.py +++ b/deploy/install/install.py @@ -276,6 +276,7 @@ def initialize(root, args, manifest): state["device_gid"] = device_gid write_json(config / "keys.json", [{"tenant_id": str(uuid.uuid4()), "organization_id": "installation", "project_id": "default", "subject_kind": "service_account", "subject_id": "operator", + "name": "Console", "kind": "console", "token_sha256": hashlib.sha256(token.encode()).hexdigest()}]) private_write(config / "credential.key", base64.b64encode(secrets.token_bytes(32)).decode()) private_write(config / "database.password", secrets.token_hex(32)) diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index eb4ac87ff..870f900e5 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -146,3 +146,14 @@ Host virtualization, credentials, tenant isolation, durable state and actual execution are release acceptance requirements. Other missing operational screens or low-frequency improvements belong in the backlog; they do not turn this batch into a Web redesign or a complete protocol-compatibility claim. + +### API-key write history + +Core records committed public resource writes and their key ownership for the +administrator console. Configure `AGENTS_API_WRITE_AUDIT_RETENTION` (Go duration, +minimum `1h`, default `2160h`) to control non-creation history. Creation ownership +remains permanently; removing keys or resources does not cascade-delete records. +Static key bindings may set a safe `name` and `kind: "console"` for console-owned +credentials. Existing resources without recorded provenance return null. See the +[query contract](../../contracts/agents-api/write-audit.md) for deployment-authenticated +batch ownership and cursor history endpoints. These APIs do not log bodies or secrets. diff --git a/services/agents-api/cmd/server/main.go b/services/agents-api/cmd/server/main.go index b678f6167..985126151 100644 --- a/services/agents-api/cmd/server/main.go +++ b/services/agents-api/cmd/server/main.go @@ -106,6 +106,17 @@ func run() error { if err := executionStore.EnsureProjectScopes(ready, auth.ProjectScopes()); err != nil { return err } + auditRetention, err := writeAuditRetention() + if err != nil { + return err + } + auditCleanupCtx, cancelAuditCleanup := context.WithCancel(ctx) + auditCleanupDone := make(chan struct{}) + go func() { + defer close(auditCleanupDone) + runWriteAuditCleanup(auditCleanupCtx, executionStore, auditRetention) + }() + defer func() { cancelAuditCleanup(); <-auditCleanupDone }() var workerDone chan error var worker *execution.Worker managedNodes, err := configureManagedNodes(executionStore, func(ctx context.Context) error { @@ -175,7 +186,7 @@ func run() error { return err } } - options = append(options, api.WithProjectAPIKeys(executionStore, keyAdmin)) + options = append(options, api.WithProjectAPIKeys(executionStore, keyAdmin), api.WithWriteAudit(executionStore, keyAdmin)) if history.Reader != nil { historyResolver, resolverErr := historystoreresolver.NewResolver(executionStore) if resolverErr != nil { diff --git a/services/agents-api/cmd/server/write_audit.go b/services/agents-api/cmd/server/write_audit.go new file mode 100644 index 000000000..c1524cdc2 --- /dev/null +++ b/services/agents-api/cmd/server/write_audit.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "errors" + "os" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +type writeAuditPruner interface { + DeleteExpiredWriteOperations(context.Context, time.Time, int) (int64, error) +} + +func writeAuditRetention() (time.Duration, error) { + value := os.Getenv("AGENTS_API_WRITE_AUDIT_RETENTION") + if value == "" { + return 90 * 24 * time.Hour, nil + } + duration, err := time.ParseDuration(value) + if err != nil || duration < time.Hour { + return 0, errors.New("AGENTS_API_WRITE_AUDIT_RETENTION must be a duration of at least 1h") + } + return duration, nil +} + +func runWriteAuditCleanup(ctx context.Context, s writeAuditPruner, retention time.Duration) { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + pruneCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + _, err := s.DeleteExpiredWriteOperations(pruneCtx, time.Now().Add(-retention), 1000) + cancel() + if err != nil && ctx.Err() == nil { + log.Ctx(ctx).Warn("Write audit retention cleanup failed") + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/services/agents-api/cmd/server/write_audit_test.go b/services/agents-api/cmd/server/write_audit_test.go new file mode 100644 index 000000000..aaa3cad4e --- /dev/null +++ b/services/agents-api/cmd/server/write_audit_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestWriteAuditRetention(t *testing.T) { + for _, test := range []struct { + value string + want time.Duration + bad bool + }{{"", 90 * 24 * time.Hour, false}, {"24h", 24 * time.Hour, false}, {"0", 0, true}, {"30m", 0, true}, {"-1h", 0, true}, {"90d", 0, true}} { + t.Run(test.value, func(t *testing.T) { + t.Setenv("AGENTS_API_WRITE_AUDIT_RETENTION", test.value) + got, err := writeAuditRetention() + if (err != nil) != test.bad || (!test.bad && got != test.want) { + t.Fatalf("%v %v", got, err) + } + }) + } +} + +type auditPruneProbe struct { + cancel context.CancelFunc + called bool + cutoff time.Time + limit int + deadline bool +} + +func (p *auditPruneProbe) DeleteExpiredWriteOperations(ctx context.Context, cutoff time.Time, limit int) (int64, error) { + p.called = true + p.cutoff = cutoff + p.limit = limit + _, p.deadline = ctx.Deadline() + p.cancel() + return 0, errors.New("test") +} +func TestWriteAuditCleanupBoundedAndCancellable(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + probe := &auditPruneProbe{cancel: cancel} + before := time.Now().Add(-24 * time.Hour) + runWriteAuditCleanup(ctx, probe, 24*time.Hour) + if !probe.called || probe.limit != 1000 || !probe.deadline || probe.cutoff.Before(before) || probe.cutoff.After(time.Now().Add(-24*time.Hour)) { + t.Fatalf("bad cleanup %+v", probe) + } +} diff --git a/services/agents-api/internal/api/auth.go b/services/agents-api/internal/api/auth.go index b00b0456b..0808faa19 100644 --- a/services/agents-api/internal/api/auth.go +++ b/services/agents-api/internal/api/auth.go @@ -8,6 +8,11 @@ import ( "net/http" "strings" "time" + "unicode" + "unicode/utf8" + + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" @@ -16,6 +21,8 @@ import ( // APIKey binds a service credential to an execution principal, not a product user. // Configuration stores the SHA-256 hex digest, never the plaintext key. type APIKey struct { + Name string `json:"name,omitempty"` + Kind string `json:"kind,omitempty"` TokenSHA256 string `json:"token_sha256"` TenantID string `json:"tenant_id"` OrganizationID string `json:"organization_id"` @@ -27,13 +34,14 @@ type APIKey struct { type Authenticator struct { principals map[[32]byte]identity.Principal projects []identity.ProjectScope + sources map[[32]byte]writeaudit.Source } func NewAuthenticator(keys []APIKey) (*Authenticator, error) { if len(keys) == 0 { return nil, errors.New("at least one Agents API key is required") } - a := &Authenticator{principals: make(map[[32]byte]identity.Principal, len(keys))} + a := &Authenticator{principals: make(map[[32]byte]identity.Principal, len(keys)), sources: make(map[[32]byte]writeaudit.Source, len(keys))} for _, key := range keys { principal := identity.Principal{ProjectScope: identity.ProjectScope{TenantID: key.TenantID, OrganizationID: key.OrganizationID, ProjectID: key.ProjectID}, SubjectKind: key.SubjectKind, SubjectID: key.SubjectID} if err := principal.Validate(); err != nil { @@ -47,6 +55,17 @@ func NewAuthenticator(keys []APIKey) (*Authenticator, error) { if _, exists := a.principals[hash]; exists { return nil, errors.New("duplicate API key digest") } + if key.Kind == "" { + key.Kind = "static" + } + if key.Kind != "static" && key.Kind != "console" { + return nil, errors.New("configured API key kind must be static or console") + } + if !utf8.ValidString(key.Name) || utf8.RuneCountInString(key.Name) > 80 || strings.ContainsFunc(key.Name, unicode.IsControl) { + return nil, errors.New("configured API key name must contain at most 80 characters without controls") + } + digestID := hex.EncodeToString(hash[:]) + a.sources[hash] = writeaudit.Source{KeyID: "static:" + digestID, Name: strings.TrimSpace(key.Name), Prefix: digestID[:8], Kind: key.Kind, TenantID: principal.TenantID} a.principals[hash] = principal a.projects = append(a.projects, principal.ProjectScope) } @@ -84,30 +103,36 @@ func principalScopeHeaders(r *http.Request, principal identity.Principal) bool { } func (h *Handler) resolvePrincipal(r *http.Request) (identity.Principal, bool, error) { + principal, _, ok, err := h.resolveCaller(r) + return principal, ok, err +} + +func (h *Handler) resolveCaller(r *http.Request) (identity.Principal, writeaudit.Source, bool, error) { digest, valid := projectBearerDigest(r) if !valid { - return identity.Principal{}, false, nil + return identity.Principal{}, writeaudit.Source{}, false, nil } if principal, ok := h.auth.principals[digest]; ok { - return principal, principalScopeHeaders(r, principal), nil + return principal, h.auth.sources[digest], principalScopeHeaders(r, principal), nil } if h.projectKeys == nil { - return identity.Principal{}, false, nil + return identity.Principal{}, writeaudit.Source{}, false, nil } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() binding, err := h.projectKeys.ResolveProjectAPIKey(ctx, hex.EncodeToString(digest[:])) if errors.Is(err, store.ErrNotFound) { - return identity.Principal{}, false, nil + return identity.Principal{}, writeaudit.Source{}, false, nil } if err != nil { - return identity.Principal{}, false, err + return identity.Principal{}, writeaudit.Source{}, false, err } parent, ok := h.auth.staticBinding(binding.BindingDigest) if !ok || parent != binding.Principal || !principalScopeHeaders(r, parent) { - return identity.Principal{}, false, nil + return identity.Principal{}, writeaudit.Source{}, false, nil } - return parent, true, nil + source := writeaudit.Source{KeyID: binding.Key.ID, Name: binding.Key.Name, Prefix: binding.Key.Prefix, Kind: "issued", TenantID: parent.TenantID} + return parent, source, true, nil } func matchesScopeHeader(r *http.Request, name, expected string) bool { @@ -145,7 +170,7 @@ func (h *Handler) authenticateProject(next http.Handler) http.Handler { func (h *Handler) authenticateCaller(next http.Handler, reportInvalidKey bool) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - principal, ok, err := h.resolvePrincipal(r) + principal, source, ok, err := h.resolveCaller(r) if err != nil { writeError(w, http.StatusServiceUnavailable, "authentication_unavailable", "API key authentication is temporarily unavailable.") return @@ -161,7 +186,15 @@ func (h *Handler) authenticateCaller(next http.Handler, reportInvalidKey bool) h writeError(w, http.StatusUnauthorized, code, "A valid Agents API bearer key is required.") return } - next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalContextKey{}, principal))) + ctx := context.WithValue(r.Context(), principalContextKey{}, principal) + if strings.HasPrefix(r.URL.Path, "/v1/") && (r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodDelete) { + source.RequestID, _ = log.RequestIDFromContext(ctx) + if carrier, ok := log.TraceFromContext(ctx); ok { + source.TraceID = carrier.Trace.String() + } + ctx = writeaudit.WithSource(ctx, source) + } + next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 470a58f13..7cb048e89 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -46,6 +46,7 @@ type Handler struct { store ResourceStore auth *Authenticator projectKeys ProjectAPIKeyStore + writeAudit WriteAuditStore harnesses map[string]bool modelProviderDefaults ModelProviderDefaults engine string @@ -107,6 +108,7 @@ func (h *Handler) routes() *chi.Mux { }) h.registerSandboxManagerRoutes(router) h.registerProjectAPIKeyRoutes(router) + h.registerWriteAuditRoutes(router) h.registerEnvironmentExecutorRoutes(router) router.Route("/v1", func(r chi.Router) { r.Use(h.authenticate) diff --git a/services/agents-api/internal/api/inputs.go b/services/agents-api/internal/api/inputs.go index c8d441bd6..c600a31c0 100644 --- a/services/agents-api/internal/api/inputs.go +++ b/services/agents-api/internal/api/inputs.go @@ -66,6 +66,9 @@ func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, err) return } + if !h.auditSessionOperation(w, r, chi.URLParam(r, "session_id"), "send_events") { + return + } w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusAccepted) return diff --git a/services/agents-api/internal/api/session_creation_identity.go b/services/agents-api/internal/api/session_creation_identity.go index 0e48d93fd..a669b678f 100644 --- a/services/agents-api/internal/api/session_creation_identity.go +++ b/services/agents-api/internal/api/session_creation_identity.go @@ -54,13 +54,16 @@ func (h *Handler) recoverSessionCreation(w http.ResponseWriter, r *http.Request, writeError(w, http.StatusServiceUnavailable, "stream_unavailable", "Live events are unavailable.") return true } + if !h.auditSessionOperation(w, r, result.Session.ID, "create") { + return true + } // Recorded-intent lookup finds an existing creation, which sends no events. h.respondSessionCreationStream(w, r, events, nil, result) } else { session, err := h.store.GetSession(r.Context(), tenantID(r), result.Session.ID) if err != nil { writeStoreError(w, r, err) - } else { + } else if h.auditSessionOperation(w, r, session.ID, "create") { h.respondSessionStatus(w, r, session, http.StatusCreated) } } diff --git a/services/agents-api/internal/api/session_write_audit.go b/services/agents-api/internal/api/session_write_audit.go new file mode 100644 index 000000000..264cc7af3 --- /dev/null +++ b/services/agents-api/internal/api/session_write_audit.go @@ -0,0 +1,29 @@ +package api + +import ( + "context" + "errors" + "net/http" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" +) + +type sessionWriteAuditor interface { + AuditSessionOperation(context.Context, string, string, string) error +} + +func (h *Handler) auditSessionOperation(w http.ResponseWriter, r *http.Request, sessionID, action string) bool { + if _, ok := writeaudit.FromContext(r.Context()); !ok { + return true + } + auditor, ok := h.store.(sessionWriteAuditor) + if !ok { + writeStoreError(w, r, errors.New("session write audit is unavailable")) + return false + } + if err := auditor.AuditSessionOperation(r.Context(), tenantID(r), sessionID, action); err != nil { + writeStoreError(w, r, err) + return false + } + return true +} diff --git a/services/agents-api/internal/api/session_write_audit_test.go b/services/agents-api/internal/api/session_write_audit_test.go new file mode 100644 index 000000000..4922d8fc8 --- /dev/null +++ b/services/agents-api/internal/api/session_write_audit_test.go @@ -0,0 +1,91 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +// General wire fixtures do not persist resources; dedicated audit fixtures below +// check the no-op/replay boundary independently of business Store auditing. +func (s *recordingStore) AuditSessionOperation(ctx context.Context, tenant, session, action string) error { + if auditor, ok := s.ResourceStore.(sessionWriteAuditor); ok { + return auditor.AuditSessionOperation(ctx, tenant, session, action) + } + return nil +} + +func (f *streamFixture) AuditSessionOperation(context.Context, string, string, string) error { + return nil +} + +type auditedSessionFixture struct { + streamFixture + err error + actions []string + source writeaudit.Source +} + +func (f *auditedSessionFixture) FindSessionCreation(context.Context, string, string, json.RawMessage, identity.Subject) (store.SessionCreation, error) { + return store.SessionCreation{Session: f.session}, nil +} + +func (f *auditedSessionFixture) AuditSessionOperation(ctx context.Context, tenant, session, action string) error { + if tenant != f.session.TenantID || session != f.session.ID { + return store.ErrNotFound + } + f.source, _ = writeaudit.FromContext(ctx) + f.actions = append(f.actions, action) + return f.err +} + +func TestSessionAuditOnlyRoutesFailClosed(t *testing.T) { + for _, route := range []string{"empty-events", "creation-replay", "stream-replay"} { + for _, fail := range []bool{false, true} { + t.Run(route+map[bool]string{false: "/commit", true: "/rollback"}[fail], func(t *testing.T) { + tenant, session := uuid.NewString(), uuid.NewString() + f := &auditedSessionFixture{streamFixture: streamFixture{session: store.Session{ID: session, TenantID: tenant, Configuration: json.RawMessage(`{"environment":{"type":"none"},"agent":{"id":"agent","model":"test"}}`)}}} + if fail { + f.err = errors.New("audit unavailable") + } + h := &Handler{store: f} + ctx := context.WithValue(t.Context(), principalContextKey{}, identity.Principal{ProjectScope: identity.ProjectScope{TenantID: tenant}, SubjectKind: "service_account", SubjectID: "test"}) + ctx = writeaudit.WithSource(ctx, writeaudit.Source{TenantID: tenant, RequestID: "request", TraceID: "trace"}) + routeContext := chi.NewRouteContext() + routeContext.URLParams.Add("session_id", session) + ctx = context.WithValue(ctx, chi.RouteCtxKey, routeContext) + r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(`{"events":[]}`)).WithContext(ctx) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + if route == "empty-events" { + h.createEvents(w, r) + } else if !h.recoverSessionCreation(w, r, "key", json.RawMessage(`{}`), route == "stream-replay") { + t.Fatal("replay not handled") + } + wantStatus, wantAction := 201, "create" + if route == "empty-events" { + wantStatus, wantAction = 202, "send_events" + } + if route == "stream-replay" { + wantStatus = 201 + } + if fail { + wantStatus = 500 + } + if w.Code != wantStatus || len(f.actions) != 1 || f.actions[0] != wantAction || f.source.RequestID != "request" { + t.Fatal(w.Code, w.Body.String(), f.actions, f.source) + } + }) + } + } +} diff --git a/services/agents-api/internal/api/write_audit.go b/services/agents-api/internal/api/write_audit.go new file mode 100644 index 000000000..a6ae99273 --- /dev/null +++ b/services/agents-api/internal/api/write_audit.go @@ -0,0 +1,176 @@ +package api + +import ( + "context" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +// WriteAuditStore exposes safe read models, never request bodies or credentials. +type WriteAuditStore interface { + GetResourceOwners(context.Context, string, string, []string) ([]store.ResourceOwner, error) + ListWriteOperations(context.Context, string, store.WriteOperationFilter) (store.WriteOperationPage, error) +} + +type ResourceOwnerList struct { + Data []store.ResourceOwner `json:"data"` +} + +func WithWriteAudit(s WriteAuditStore, auth *DeploymentAuthenticator) Option { + return func(h *Handler) { + h.writeAudit = s + if auth != nil { + h.deploymentAuth = auth + } + } +} + +func (h *Handler) registerWriteAuditRoutes(r chi.Router) { + if h.writeAudit == nil || h.deploymentAuth == nil { + return + } + r.Group(func(r chi.Router) { + r.Use(h.deploymentAuth.authenticate) + r.Get("/core/v1/resource-owners", h.getResourceOwners) + r.Get("/core/v1/write-operations", h.listWriteOperations) + }) +} + +func (h *Handler) writeAuditScope(w http.ResponseWriter, r *http.Request, allowed ...string) (url.Values, string, bool) { + values, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return nil, "", false + } + for name, entries := range values { + recognized := name == "binding_digest" + for _, field := range allowed { + if name == field { + recognized = true + } + } + if !recognized || len(entries) != 1 || entries[0] == "" { + writeStoreError(w, r, store.ErrInvalidInput) + return nil, "", false + } + } + principal, ok := h.auth.staticBinding(values.Get("binding_digest")) + if !ok { + writeStoreError(w, r, store.ErrNotFound) + return nil, "", false + } + return values, principal.TenantID, true +} + +// @Summary Batch lookup resource creation keys +// @Description Deployment administrator only. A configured binding selects the project. Returns null for resources without recorded creation provenance, including historical and foreign resources. No key secret is returned. +// @Tags Write Audit +// @Produce json +// @Security DeploymentAdminAuth +// @Param binding_digest query string true "Configured caller SHA-256 binding selector" +// @Param resource_type query string true "Resource type" +// @Param resource_ids query string true "Comma-separated public resource IDs, maximum 100" +// @Success 200 {object} api.ResourceOwnerList +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /core/v1/resource-owners [get] +func (h *Handler) getResourceOwners(w http.ResponseWriter, r *http.Request) { + values, tenant, ok := h.writeAuditScope(w, r, "resource_type", "resource_ids") + if !ok { + return + } + ids := strings.Split(values.Get("resource_ids"), ",") + if !store.ValidAuditResourceType(values.Get("resource_type")) || len(ids) > 100 { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + for _, id := range ids { + if id == "" || len(id) > 256 || strings.TrimSpace(id) != id { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + } + owners, err := h.writeAudit.GetResourceOwners(r.Context(), tenant, values.Get("resource_type"), ids) + if err != nil { + writeStoreError(w, r, err) + return + } + if owners == nil { + owners = []store.ResourceOwner{} + } + writeJSON(w, http.StatusOK, ResourceOwnerList{Data: owners}) +} + +// @Summary Query API-key write operations +// @Description Deployment administrator only. Reverse chronological keyset pagination over committed writes. Creation records remain; other records follow configured retention. A configured binding selects the project, never a caller-supplied tenant. +// @Tags Write Audit +// @Produce json +// @Security DeploymentAdminAuth +// @Param binding_digest query string true "Configured caller SHA-256 binding selector" +// @Param key_id query string false "Issued UUID or static key identifier" +// @Param resource_type query string false "Resource type" +// @Param resource_id query string false "Public resource ID" +// @Param created_after query string false "Inclusive RFC3339 timestamp" +// @Param created_before query string false "Exclusive RFC3339 timestamp" +// @Param limit query int false "Page size, 1-100, default 50" +// @Param after query string false "Opaque next_cursor from the preceding page" +// @Success 200 {object} store.WriteOperationPage +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /core/v1/write-operations [get] +func (h *Handler) listWriteOperations(w http.ResponseWriter, r *http.Request) { + values, tenant, ok := h.writeAuditScope(w, r, "key_id", "resource_type", "resource_id", "created_after", "created_before", "limit", "after") + if !ok { + return + } + filter := store.WriteOperationFilter{KeyID: values.Get("key_id"), ResourceType: values.Get("resource_type"), ResourceID: values.Get("resource_id"), After: values.Get("after"), Limit: 50} + if filter.ResourceType != "" && !store.ValidAuditResourceType(filter.ResourceType) { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + for _, value := range []string{filter.KeyID, filter.ResourceID} { + if len(value) > 256 { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + } + if len(filter.After) > 2048 { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + if value := values.Get("limit"); value != "" { + n, err := strconv.Atoi(value) + if err != nil || n < 1 || n > 100 { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + filter.Limit = n + } + for key, target := range map[string]**time.Time{"created_after": &filter.CreatedAfter, "created_before": &filter.CreatedBefore} { + if value := values.Get(key); value != "" { + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + *target = &parsed + } + } + if filter.CreatedAfter != nil && filter.CreatedBefore != nil && !filter.CreatedAfter.Before(*filter.CreatedBefore) { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + page, err := h.writeAudit.ListWriteOperations(r.Context(), tenant, filter) + if err != nil { + writeStoreError(w, r, err) + return + } + if page.Data == nil { + page.Data = []store.WriteOperation{} + } + writeJSON(w, http.StatusOK, page) +} diff --git a/services/agents-api/internal/api/write_audit_test.go b/services/agents-api/internal/api/write_audit_test.go new file mode 100644 index 000000000..130d719ee --- /dev/null +++ b/services/agents-api/internal/api/write_audit_test.go @@ -0,0 +1,199 @@ +package api + +import ( + "context" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" + "github.com/google/uuid" +) + +type auditQueryFixture struct { + tenant, resourceType string + ids []string + filter store.WriteOperationFilter + calls int +} + +func (s *auditQueryFixture) GetResourceOwners(_ context.Context, tenant, kind string, ids []string) ([]store.ResourceOwner, error) { + s.calls++ + s.tenant = tenant + s.resourceType = kind + s.ids = ids + result := make([]store.ResourceOwner, len(ids)) + for i, id := range ids { + result[i].ResourceID = id + } + return result, nil +} +func (s *auditQueryFixture) ListWriteOperations(_ context.Context, tenant string, filter store.WriteOperationFilter) (store.WriteOperationPage, error) { + s.calls++ + s.tenant = tenant + s.filter = filter + return store.WriteOperationPage{}, nil +} +func TestWriteAuditQueriesDeploymentScopeAndValidation(t *testing.T) { + key := callerBinding() + auth, err := NewAuthenticator([]APIKey{key}) + if err != nil { + t.Fatal(err) + } + admin, err := NewDeploymentAuthenticator([]string{device.HashCredential("admin")}) + if err != nil { + t.Fatal(err) + } + queries := &auditQueryFixture{} + h, err := NewHandler(&recordingStore{}, auth, "codex", WithWriteAudit(queries, admin)) + if err != nil { + t.Fatal(err) + } + owners := "/core/v1/resource-owners?binding_digest=" + key.TokenSHA256 + "&resource_type=agent&resource_ids=first,second" + for _, token := range []string{"", "caller", "foreign"} { + w := projectKeyHTTP(h, "GET", owners, token, "") + if w.Code != 401 || queries.calls != 0 { + t.Fatalf("unauthorized query %d calls%d", w.Code, queries.calls) + } + } + w := projectKeyHTTP(h, "GET", owners, "admin", "") + if w.Code != 200 || queries.tenant != key.TenantID || queries.resourceType != "agent" || len(queries.ids) != 2 { + t.Fatalf("batch %d %s", w.Code, w.Body) + } + if !strings.Contains(w.Body.String(), `"api_key":null`) { + t.Fatalf("history must be null: %s", w.Body) + } + history := "/core/v1/write-operations?binding_digest=" + key.TokenSHA256 + w = projectKeyHTTP(h, "GET", history+"&key_id=some-key&resource_type=credential&resource_id=resource&limit=2&after=cursor&created_after=2026-01-01T00:00:00Z&created_before=2026-02-01T00:00:00Z", "admin", "") + if w.Code != 200 || queries.filter.Limit != 2 || queries.filter.KeyID != "some-key" || queries.filter.After != "cursor" || queries.filter.CreatedAfter == nil || queries.filter.CreatedBefore == nil { + t.Fatalf("history %d %s filter%+v", w.Code, w.Body, queries.filter) + } + for _, path := range []string{ + owners + "&resource_type=file", strings.Replace(owners, "agent", "unknown", 1), strings.Replace(owners, "first,second", "", 1), + owners + "&tenant_id=foreign", owners + "&bad=%GG", strings.Replace(owners, "first,second", strings.Repeat("id,", 100)+"id", 1), + history + "&limit=0", history + "&limit=101", history + "&limit=bad", history + "&limit=", history + "&created_after=yesterday", history + "&resource_type=unknown", + history + "&created_after=2026-02-01T00:00:00Z&created_before=2026-01-01T00:00:00Z", + } { + count := queries.calls + w = projectKeyHTTP(h, "GET", path, "admin", "") + if w.Code != 400 || queries.calls != count { + t.Errorf("invalid query %s returned%d", path, w.Code) + } + } + count := queries.calls + w = projectKeyHTTP(h, "GET", strings.Replace(owners, key.TokenSHA256, device.HashCredential("absent"), 1), "admin", "") + if w.Code != 404 || queries.calls != count { + t.Fatalf("missing binding %d", w.Code) + } + w = projectKeyHTTP(h, "POST", owners, "admin", `{}`) + if w.Code != 405 { + t.Fatalf("readonly endpoint %d", w.Code) + } +} + +func TestAuthenticatedWriteProvenance(t *testing.T) { + key := callerBinding() + key.Name = "Console" + key.Kind = "console" + auth, err := NewAuthenticator([]APIKey{key}) + if err != nil { + t.Fatal(err) + } + principal := auth.principals[[32]byte(mustDigestForAudit(t, key.TokenSHA256))] + issued := store.ProjectAPIKey{ID: uuid.NewString(), Name: "SDK", Prefix: "pc_12345678"} + keys := &projectKeyStoreFixture{binding: store.ProjectAPIKeyBinding{BindingDigest: key.TokenSHA256, Principal: principal, Key: issued}} + h := &Handler{auth: auth, projectKeys: keys} + var source writeaudit.Source + var got bool + handler := agentsResponseHeaders(log.HTTPMiddleware(h.authenticateCaller(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + source, got = writeaudit.FromContext(r.Context()) + w.WriteHeader(204) + }), true))) + for _, test := range []struct { + method, path, token, kind, id string + present bool + }{ + {"POST", "/v1/agents", "caller", "console", "static:" + key.TokenSHA256, true}, + {"DELETE", "/v1/files/file-one", "issued-project-key", "issued", issued.ID, true}, + {"GET", "/v1/agents", "caller", "", "", false}, + {"POST", "/core/v1/anything", "caller", "", "", false}, + } { + r := httptest.NewRequest(test.method, test.path, nil) + r.Header.Set("Authorization", "Bearer "+test.token) + r.Header.Set("X-API-Key-ID", "forged") + r.Header.Set("X-Request-Id", "forged") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != 204 || got != test.present { + t.Fatalf("source eligibility%d %v", w.Code, got) + } + if got && (source.KeyID != test.id || source.Kind != test.kind || source.TenantID != key.TenantID || source.RequestID != w.Header().Get("X-Request-Id") || source.RequestID == "forged" || len(source.TraceID) != 32) { + t.Fatalf("source %+v", source) + } + } + keys.resolve = store.ErrNotFound + w := projectKeyHTTP(handler, "POST", "/v1/agents", "issued-project-key", "") + if w.Code != 401 { + t.Fatalf("revoked key %d", w.Code) + } +} + +func mustDigestForAudit(t *testing.T, s string) []byte { + t.Helper() + out, err := hex.DecodeString(s) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestStaticAuditIdentityStableAcrossDisplayChange(t *testing.T) { + key := callerBinding() + a, err := NewAuthenticator([]APIKey{key}) + if err != nil { + t.Fatal(err) + } + key.Name = "Renamed" + key.Kind = "console" + b, err := NewAuthenticator([]APIKey{key}) + if err != nil { + t.Fatal(err) + } + for hash, source := range a.sources { + if source.KeyID != b.sources[hash].KeyID || source.Prefix != b.sources[hash].Prefix { + t.Fatal("display change replaced key identity") + } + } + for _, edit := range []func(*APIKey){func(k *APIKey) { k.Kind = "issued" }, func(k *APIKey) { k.Name = "bad\nname" }, func(k *APIKey) { k.Name = strings.Repeat("a", 81) }} { + bad := key + edit(&bad) + if _, err := NewAuthenticator([]APIKey{bad}); err == nil { + t.Fatal("invalid audit config accepted") + } + } +} + +func TestAuditQueryJSONSafeFields(t *testing.T) { + now := time.Now().UTC() + raw, err := json.Marshal(ResourceOwnerList{Data: []store.ResourceOwner{{ResourceID: "r", APIKey: &store.AuditAPIKey{ID: "key", Name: "sdk", Prefix: "pc_prefix", Kind: "issued", RevokedAt: &now}}}}) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"id", "name", "prefix", "kind", "revoked_at"} { + if !strings.Contains(string(raw), `"`+name+`"`) { + t.Fatal(name) + } + } + for _, name := range []string{"token_sha256", "binding_digest", "tenant_id"} { + if strings.Contains(string(raw), name) { + t.Fatal(name) + } + } +} diff --git a/services/agents-api/internal/db/queries/environment_file_writes.sql b/services/agents-api/internal/db/queries/environment_file_writes.sql index 887b394bd..aa410f249 100644 --- a/services/agents-api/internal/db/queries/environment_file_writes.sql +++ b/services/agents-api/internal/db/queries/environment_file_writes.sql @@ -6,8 +6,8 @@ JOIN sessions s ON s.id = e.session_id WHERE s.tenant_id = sqlc.arg(tenant_id) AND e.id = sqlc.arg(environment_id) AND w.id = sqlc.arg(id); -- name: CreateEnvironmentFileWrite :one -INSERT INTO environment_file_writes(id, environment_id, device_id, request_sha256) -VALUES ($1, $2, $3, $4) RETURNING *; +INSERT INTO environment_file_writes(id, environment_id, device_id, request_sha256, audit_source) +VALUES ($1, $2, $3, $4, $5) RETURNING *; -- name: SettleEnvironmentFileWrite :one UPDATE environment_file_writes SET state = $3, settled_at = clock_timestamp() diff --git a/services/agents-api/internal/db/queries/project_api_keys.sql b/services/agents-api/internal/db/queries/project_api_keys.sql index 75f3a6243..b446c0649 100644 --- a/services/agents-api/internal/db/queries/project_api_keys.sql +++ b/services/agents-api/internal/db/queries/project_api_keys.sql @@ -17,5 +17,5 @@ WHERE id = $1 AND binding_digest = $2 AND tenant_id = $3 AND organization_id = $ AND project_id = $5 AND subject_kind = $6 AND subject_id = $7; -- name: ResolveProjectAPIKey :one -SELECT binding_digest, tenant_id, organization_id, project_id, subject_kind, subject_id +SELECT id, name, prefix, created_at, revoked_at, binding_digest, tenant_id, organization_id, project_id, subject_kind, subject_id FROM project_api_keys WHERE token_sha256 = $1 AND revoked_at IS NULL; diff --git a/services/agents-api/internal/db/queries/write_audit.sql b/services/agents-api/internal/db/queries/write_audit.sql new file mode 100644 index 000000000..19f162eae --- /dev/null +++ b/services/agents-api/internal/db/queries/write_audit.sql @@ -0,0 +1,42 @@ +-- name: InsertWriteAuditOperation :one +INSERT INTO write_audit_operations (id, tenant_id, key_id, key_name, key_prefix, key_kind, action, resource_type, resource_id, parent_id, request_id, trace_id) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) +ON CONFLICT (tenant_id, request_id) DO NOTHING +RETURNING id; + +-- name: InsertWriteAuditOwner :exec +INSERT INTO write_audit_owners (tenant_id, resource_type, resource_id, parent_id, operation_id) +VALUES ($1,$2,$3,$4,$5) +ON CONFLICT (tenant_id, resource_type, resource_id) DO NOTHING; + +-- name: GetResourceOwners :many +SELECT o.resource_id, a.key_id, a.key_name, a.key_prefix, a.key_kind, k.revoked_at +FROM write_audit_owners o +JOIN write_audit_operations a ON a.tenant_id = o.tenant_id AND a.id = o.operation_id +LEFT JOIN project_api_keys k ON k.tenant_id = a.tenant_id AND k.id = CASE WHEN a.key_kind = 'issued' THEN a.key_id::uuid END +WHERE o.tenant_id = $1 AND o.resource_type = $2 AND o.resource_id = ANY($3::text[]); + +-- name: ListWriteOperations :many +SELECT a.*, k.revoked_at +FROM write_audit_operations a +LEFT JOIN project_api_keys k ON k.tenant_id = a.tenant_id AND k.id = CASE WHEN a.key_kind = 'issued' THEN a.key_id::uuid END +WHERE a.tenant_id = sqlc.arg(tenant_id) + AND (sqlc.arg(key_id)::text = '' OR a.key_id = sqlc.arg(key_id)) + AND (sqlc.arg(resource_type)::text = '' OR a.resource_type = sqlc.arg(resource_type)) + AND (sqlc.arg(resource_id)::text = '' OR a.resource_id = sqlc.arg(resource_id)) + AND (sqlc.narg(created_after)::timestamptz IS NULL OR a.created_at >= sqlc.narg(created_after)) + AND (sqlc.narg(created_before)::timestamptz IS NULL OR a.created_at < sqlc.narg(created_before)) + AND (sqlc.narg(after_time)::timestamptz IS NULL OR (a.created_at, a.id) < (sqlc.narg(after_time), sqlc.arg(after_id)::uuid)) +ORDER BY a.created_at DESC, a.id DESC +LIMIT sqlc.arg(page_limit); + +-- name: GetWriteAuditCursor :one +SELECT created_at FROM write_audit_operations WHERE tenant_id = $1 AND id = $2; + +-- name: DeleteExpiredWriteOperations :execrows +DELETE FROM write_audit_operations WHERE id IN ( + SELECT a.id FROM write_audit_operations a + WHERE a.created_at < $1 AND NOT EXISTS (SELECT 1 FROM write_audit_owners o WHERE o.operation_id = a.id) + ORDER BY a.created_at, a.id LIMIT $2 + FOR UPDATE SKIP LOCKED +); diff --git a/services/agents-api/internal/db/sqlc/environment_file_writes.sql.go b/services/agents-api/internal/db/sqlc/environment_file_writes.sql.go index 470dbde04..847fa0061 100644 --- a/services/agents-api/internal/db/sqlc/environment_file_writes.sql.go +++ b/services/agents-api/internal/db/sqlc/environment_file_writes.sql.go @@ -12,8 +12,8 @@ import ( ) const createEnvironmentFileWrite = `-- name: CreateEnvironmentFileWrite :one -INSERT INTO environment_file_writes(id, environment_id, device_id, request_sha256) -VALUES ($1, $2, $3, $4) RETURNING id, environment_id, device_id, request_sha256, state, created_at, settled_at +INSERT INTO environment_file_writes(id, environment_id, device_id, request_sha256, audit_source) +VALUES ($1, $2, $3, $4, $5) RETURNING id, environment_id, device_id, request_sha256, state, created_at, settled_at, audit_source ` type CreateEnvironmentFileWriteParams struct { @@ -21,6 +21,7 @@ type CreateEnvironmentFileWriteParams struct { EnvironmentID pgtype.UUID `json:"environment_id"` DeviceID pgtype.UUID `json:"device_id"` RequestSha256 string `json:"request_sha256"` + AuditSource []byte `json:"audit_source"` } func (q *Queries) CreateEnvironmentFileWrite(ctx context.Context, arg CreateEnvironmentFileWriteParams) (EnvironmentFileWrite, error) { @@ -29,6 +30,7 @@ func (q *Queries) CreateEnvironmentFileWrite(ctx context.Context, arg CreateEnvi arg.EnvironmentID, arg.DeviceID, arg.RequestSha256, + arg.AuditSource, ) var i EnvironmentFileWrite err := row.Scan( @@ -39,6 +41,7 @@ func (q *Queries) CreateEnvironmentFileWrite(ctx context.Context, arg CreateEnvi &i.State, &i.CreatedAt, &i.SettledAt, + &i.AuditSource, ) return i, err } @@ -71,7 +74,7 @@ func (q *Queries) EnvironmentFileWriteHasPendingInput(ctx context.Context, sessi } const getEnvironmentFileWrite = `-- name: GetEnvironmentFileWrite :one -SELECT w.id, w.environment_id, w.device_id, w.request_sha256, w.state, w.created_at, w.settled_at, e.session_id +SELECT w.id, w.environment_id, w.device_id, w.request_sha256, w.state, w.created_at, w.settled_at, w.audit_source, e.session_id FROM environment_file_writes w JOIN environments e ON e.id = w.environment_id JOIN sessions s ON s.id = e.session_id @@ -100,6 +103,7 @@ func (q *Queries) GetEnvironmentFileWrite(ctx context.Context, arg GetEnvironmen &i.EnvironmentFileWrite.State, &i.EnvironmentFileWrite.CreatedAt, &i.EnvironmentFileWrite.SettledAt, + &i.EnvironmentFileWrite.AuditSource, &i.SessionID, ) return i, err @@ -107,7 +111,7 @@ func (q *Queries) GetEnvironmentFileWrite(ctx context.Context, arg GetEnvironmen const settleEnvironmentFileWrite = `-- name: SettleEnvironmentFileWrite :one UPDATE environment_file_writes SET state = $3, settled_at = clock_timestamp() -WHERE environment_id = $1 AND id = $2 AND state = 'pending' RETURNING id, environment_id, device_id, request_sha256, state, created_at, settled_at +WHERE environment_id = $1 AND id = $2 AND state = 'pending' RETURNING id, environment_id, device_id, request_sha256, state, created_at, settled_at, audit_source ` type SettleEnvironmentFileWriteParams struct { @@ -127,6 +131,7 @@ func (q *Queries) SettleEnvironmentFileWrite(ctx context.Context, arg SettleEnvi &i.State, &i.CreatedAt, &i.SettledAt, + &i.AuditSource, ) return i, err } diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index 555a40f80..0c64280b3 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -69,6 +69,7 @@ type EnvironmentFileWrite struct { State string `json:"state"` CreatedAt pgtype.Timestamptz `json:"created_at"` SettledAt pgtype.Timestamptz `json:"settled_at"` + AuditSource []byte `json:"audit_source"` } type EnvironmentInputReservation struct { @@ -458,3 +459,27 @@ type VaultCredential struct { Status string `json:"status"` OauthMetadata []byte `json:"oauth_metadata"` } + +type WriteAuditOperation struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + KeyID string `json:"key_id"` + KeyName string `json:"key_name"` + KeyPrefix string `json:"key_prefix"` + KeyKind string `json:"key_kind"` + Action string `json:"action"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + ParentID string `json:"parent_id"` + RequestID string `json:"request_id"` + TraceID string `json:"trace_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type WriteAuditOwner struct { + TenantID pgtype.UUID `json:"tenant_id"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + ParentID string `json:"parent_id"` + OperationID pgtype.UUID `json:"operation_id"` +} diff --git a/services/agents-api/internal/db/sqlc/project_api_keys.sql.go b/services/agents-api/internal/db/sqlc/project_api_keys.sql.go index 7fdf7db86..ee84a640e 100644 --- a/services/agents-api/internal/db/sqlc/project_api_keys.sql.go +++ b/services/agents-api/internal/db/sqlc/project_api_keys.sql.go @@ -122,23 +122,33 @@ func (q *Queries) ListProjectAPIKeys(ctx context.Context, arg ListProjectAPIKeys } const resolveProjectAPIKey = `-- name: ResolveProjectAPIKey :one -SELECT binding_digest, tenant_id, organization_id, project_id, subject_kind, subject_id +SELECT id, name, prefix, created_at, revoked_at, binding_digest, tenant_id, organization_id, project_id, subject_kind, subject_id FROM project_api_keys WHERE token_sha256 = $1 AND revoked_at IS NULL ` type ResolveProjectAPIKeyRow struct { - BindingDigest string `json:"binding_digest"` - TenantID pgtype.UUID `json:"tenant_id"` - OrganizationID string `json:"organization_id"` - ProjectID string `json:"project_id"` - SubjectKind string `json:"subject_kind"` - SubjectID string `json:"subject_id"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` + BindingDigest string `json:"binding_digest"` + TenantID pgtype.UUID `json:"tenant_id"` + OrganizationID string `json:"organization_id"` + ProjectID string `json:"project_id"` + SubjectKind string `json:"subject_kind"` + SubjectID string `json:"subject_id"` } func (q *Queries) ResolveProjectAPIKey(ctx context.Context, tokenSha256 string) (ResolveProjectAPIKeyRow, error) { row := q.db.QueryRow(ctx, resolveProjectAPIKey, tokenSha256) var i ResolveProjectAPIKeyRow err := row.Scan( + &i.ID, + &i.Name, + &i.Prefix, + &i.CreatedAt, + &i.RevokedAt, &i.BindingDigest, &i.TenantID, &i.OrganizationID, diff --git a/services/agents-api/internal/db/sqlc/write_audit.sql.go b/services/agents-api/internal/db/sqlc/write_audit.sql.go new file mode 100644 index 000000000..9254163d1 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/write_audit.sql.go @@ -0,0 +1,256 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: write_audit.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteExpiredWriteOperations = `-- name: DeleteExpiredWriteOperations :execrows +DELETE FROM write_audit_operations WHERE id IN ( + SELECT a.id FROM write_audit_operations a + WHERE a.created_at < $1 AND NOT EXISTS (SELECT 1 FROM write_audit_owners o WHERE o.operation_id = a.id) + ORDER BY a.created_at, a.id LIMIT $2 + FOR UPDATE SKIP LOCKED +) +` + +type DeleteExpiredWriteOperationsParams struct { + CreatedAt pgtype.Timestamptz `json:"created_at"` + Limit int32 `json:"limit"` +} + +func (q *Queries) DeleteExpiredWriteOperations(ctx context.Context, arg DeleteExpiredWriteOperationsParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteExpiredWriteOperations, arg.CreatedAt, arg.Limit) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const getResourceOwners = `-- name: GetResourceOwners :many +SELECT o.resource_id, a.key_id, a.key_name, a.key_prefix, a.key_kind, k.revoked_at +FROM write_audit_owners o +JOIN write_audit_operations a ON a.tenant_id = o.tenant_id AND a.id = o.operation_id +LEFT JOIN project_api_keys k ON k.tenant_id = a.tenant_id AND k.id = CASE WHEN a.key_kind = 'issued' THEN a.key_id::uuid END +WHERE o.tenant_id = $1 AND o.resource_type = $2 AND o.resource_id = ANY($3::text[]) +` + +type GetResourceOwnersParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ResourceType string `json:"resource_type"` + Column3 []string `json:"column_3"` +} + +type GetResourceOwnersRow struct { + ResourceID string `json:"resource_id"` + KeyID string `json:"key_id"` + KeyName string `json:"key_name"` + KeyPrefix string `json:"key_prefix"` + KeyKind string `json:"key_kind"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` +} + +func (q *Queries) GetResourceOwners(ctx context.Context, arg GetResourceOwnersParams) ([]GetResourceOwnersRow, error) { + rows, err := q.db.Query(ctx, getResourceOwners, arg.TenantID, arg.ResourceType, arg.Column3) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetResourceOwnersRow{} + for rows.Next() { + var i GetResourceOwnersRow + if err := rows.Scan( + &i.ResourceID, + &i.KeyID, + &i.KeyName, + &i.KeyPrefix, + &i.KeyKind, + &i.RevokedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getWriteAuditCursor = `-- name: GetWriteAuditCursor :one +SELECT created_at FROM write_audit_operations WHERE tenant_id = $1 AND id = $2 +` + +type GetWriteAuditCursorParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetWriteAuditCursor(ctx context.Context, arg GetWriteAuditCursorParams) (pgtype.Timestamptz, error) { + row := q.db.QueryRow(ctx, getWriteAuditCursor, arg.TenantID, arg.ID) + var created_at pgtype.Timestamptz + err := row.Scan(&created_at) + return created_at, err +} + +const insertWriteAuditOperation = `-- name: InsertWriteAuditOperation :one +INSERT INTO write_audit_operations (id, tenant_id, key_id, key_name, key_prefix, key_kind, action, resource_type, resource_id, parent_id, request_id, trace_id) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) +ON CONFLICT (tenant_id, request_id) DO NOTHING +RETURNING id +` + +type InsertWriteAuditOperationParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + KeyID string `json:"key_id"` + KeyName string `json:"key_name"` + KeyPrefix string `json:"key_prefix"` + KeyKind string `json:"key_kind"` + Action string `json:"action"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + ParentID string `json:"parent_id"` + RequestID string `json:"request_id"` + TraceID string `json:"trace_id"` +} + +func (q *Queries) InsertWriteAuditOperation(ctx context.Context, arg InsertWriteAuditOperationParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, insertWriteAuditOperation, + arg.ID, + arg.TenantID, + arg.KeyID, + arg.KeyName, + arg.KeyPrefix, + arg.KeyKind, + arg.Action, + arg.ResourceType, + arg.ResourceID, + arg.ParentID, + arg.RequestID, + arg.TraceID, + ) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} + +const insertWriteAuditOwner = `-- name: InsertWriteAuditOwner :exec +INSERT INTO write_audit_owners (tenant_id, resource_type, resource_id, parent_id, operation_id) +VALUES ($1,$2,$3,$4,$5) +ON CONFLICT (tenant_id, resource_type, resource_id) DO NOTHING +` + +type InsertWriteAuditOwnerParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + ParentID string `json:"parent_id"` + OperationID pgtype.UUID `json:"operation_id"` +} + +func (q *Queries) InsertWriteAuditOwner(ctx context.Context, arg InsertWriteAuditOwnerParams) error { + _, err := q.db.Exec(ctx, insertWriteAuditOwner, + arg.TenantID, + arg.ResourceType, + arg.ResourceID, + arg.ParentID, + arg.OperationID, + ) + return err +} + +const listWriteOperations = `-- name: ListWriteOperations :many +SELECT a.id, a.tenant_id, a.key_id, a.key_name, a.key_prefix, a.key_kind, a.action, a.resource_type, a.resource_id, a.parent_id, a.request_id, a.trace_id, a.created_at, k.revoked_at +FROM write_audit_operations a +LEFT JOIN project_api_keys k ON k.tenant_id = a.tenant_id AND k.id = CASE WHEN a.key_kind = 'issued' THEN a.key_id::uuid END +WHERE a.tenant_id = $1 + AND ($2::text = '' OR a.key_id = $2) + AND ($3::text = '' OR a.resource_type = $3) + AND ($4::text = '' OR a.resource_id = $4) + AND ($5::timestamptz IS NULL OR a.created_at >= $5) + AND ($6::timestamptz IS NULL OR a.created_at < $6) + AND ($7::timestamptz IS NULL OR (a.created_at, a.id) < ($7, $8::uuid)) +ORDER BY a.created_at DESC, a.id DESC +LIMIT $9 +` + +type ListWriteOperationsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + KeyID string `json:"key_id"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + CreatedAfter pgtype.Timestamptz `json:"created_after"` + CreatedBefore pgtype.Timestamptz `json:"created_before"` + AfterTime pgtype.Timestamptz `json:"after_time"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +type ListWriteOperationsRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + KeyID string `json:"key_id"` + KeyName string `json:"key_name"` + KeyPrefix string `json:"key_prefix"` + KeyKind string `json:"key_kind"` + Action string `json:"action"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + ParentID string `json:"parent_id"` + RequestID string `json:"request_id"` + TraceID string `json:"trace_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` +} + +func (q *Queries) ListWriteOperations(ctx context.Context, arg ListWriteOperationsParams) ([]ListWriteOperationsRow, error) { + rows, err := q.db.Query(ctx, listWriteOperations, + arg.TenantID, + arg.KeyID, + arg.ResourceType, + arg.ResourceID, + arg.CreatedAfter, + arg.CreatedBefore, + arg.AfterTime, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListWriteOperationsRow{} + for rows.Next() { + var i ListWriteOperationsRow + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.KeyID, + &i.KeyName, + &i.KeyPrefix, + &i.KeyKind, + &i.Action, + &i.ResourceType, + &i.ResourceID, + &i.ParentID, + &i.RequestID, + &i.TraceID, + &i.CreatedAt, + &i.RevokedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/services/agents-api/internal/execution/environment_file_write.go b/services/agents-api/internal/execution/environment_file_write.go index 78df59e15..ce64702d6 100644 --- a/services/agents-api/internal/execution/environment_file_write.go +++ b/services/agents-api/internal/execution/environment_file_write.go @@ -10,6 +10,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" "github.com/google/uuid" ) @@ -66,6 +67,10 @@ func (w *Worker) runFileWrite(owner context.Context, request fileWriteRequest) f } ctx, cancel := context.WithTimeout(context.WithoutCancel(owner), 205*time.Second) defer cancel() + // The worker owns cancellation; only safe request attribution crosses this boundary. + if source, ok := writeaudit.FromContext(request.ctx); ok { + ctx = writeaudit.WithSource(ctx, source) + } if w.CheckOwnership(ctx) != nil { return unavailable } diff --git a/services/agents-api/internal/store/agents.go b/services/agents-api/internal/store/agents.go index 34b5b75d5..e6e3a3983 100644 --- a/services/agents-api/internal/store/agents.go +++ b/services/agents-api/internal/store/agents.go @@ -68,7 +68,10 @@ func (s *Store) CreateAgent(ctx context.Context, tenantID string, input CreateAg return err } created, err = agentFromRow(row) - return err + if err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "create", "agent", created.ID, "", AuditResource{Type: "agent", ID: created.ID}) }) if err != nil { return SavedAgent{}, fmt.Errorf("create agent: %w", err) diff --git a/services/agents-api/internal/store/agents_delete.go b/services/agents-api/internal/store/agents_delete.go index 0ed066b13..bd3423352 100644 --- a/services/agents-api/internal/store/agents_delete.go +++ b/services/agents-api/internal/store/agents_delete.go @@ -20,12 +20,21 @@ func (s *Store) DeleteAgent(ctx context.Context, tenantID, agentID string) (stri if err != nil { return "", err } - deleted, err := s.queries.DeleteAgent(ctx, sqlc.DeleteAgentParams{TenantID: tenant, ID: id}) + var deletedID string + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + deleted, err := q.DeleteAgent(ctx, sqlc.DeleteAgentParams{TenantID: tenant, ID: id}) + if err != nil { + return err + } + deletedID = uuid.UUID(deleted.Bytes).String() + return recordWriteAudit(ctx, q, tenantID, "delete", "agent", deletedID, "") + }) if errors.Is(err, pgx.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", fmt.Errorf("delete agent: %w", err) } - return uuid.UUID(deleted.Bytes).String(), nil + return deletedID, nil } diff --git a/services/agents-api/internal/store/agents_update.go b/services/agents-api/internal/store/agents_update.go index 2b5c6da0f..2adca88a4 100644 --- a/services/agents-api/internal/store/agents_update.go +++ b/services/agents-api/internal/store/agents_update.go @@ -98,7 +98,10 @@ func (s *Store) UpdateAgent(ctx context.Context, tenantID, agentID string, input return err } updated, err = agentFromRow(row) - return err + if err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "update", "agent", updated.ID, "") }) if err != nil { return SavedAgent{}, fmt.Errorf("update agent: %w", err) diff --git a/services/agents-api/internal/store/environment_file_writes.go b/services/agents-api/internal/store/environment_file_writes.go index b3a305b00..5ef8c7d71 100644 --- a/services/agents-api/internal/store/environment_file_writes.go +++ b/services/agents-api/internal/store/environment_file_writes.go @@ -3,6 +3,7 @@ package store import ( "context" "encoding/hex" + "encoding/json" "errors" "time" @@ -11,6 +12,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" ) // FileWriteIdentity binds a private mutation to one dedicated local Runtime. RequestSHA256 covers the canonical destination, byte count and data digest. @@ -54,6 +56,16 @@ func (s *Store) ReserveEnvironmentFileWrite(ctx context.Context, tenant, environ if err != nil { return EnvironmentFileWrite{}, err } + var origin []byte + if source, ok := writeaudit.FromContext(ctx); ok { + if err := validateWriteAuditSource(source, tenant); err != nil { + return EnvironmentFileWrite{}, err + } + origin, err = json.Marshal(source) + if err != nil { + return EnvironmentFileWrite{}, err + } + } var result EnvironmentFileWrite err = s.withPublicSession(ctx, tenant, owned.SessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { previous, err := q.GetEnvironmentFileWrite(ctx, lookup) @@ -100,7 +112,7 @@ func (s *Store) ReserveEnvironmentFileWrite(ctx context.Context, tenant, environ } deviceID, _ := parseID(key.DeviceID) row, err := q.CreateEnvironmentFileWrite(ctx, sqlc.CreateEnvironmentFileWriteParams{ - ID: lookup.ID, EnvironmentID: lookup.EnvironmentID, DeviceID: deviceID, RequestSha256: key.RequestSHA256, + ID: lookup.ID, EnvironmentID: lookup.EnvironmentID, DeviceID: deviceID, RequestSha256: key.RequestSHA256, AuditSource: origin, }) if err == nil { result = fileWriteFromRow(row, session) @@ -158,10 +170,19 @@ func (s *Store) SettleEnvironmentFileWrite(ctx context.Context, tenant, environm return nil } row, err := q.SettleEnvironmentFileWrite(ctx, sqlc.SettleEnvironmentFileWriteParams{EnvironmentID: lookup.EnvironmentID, ID: lookup.ID, State: state}) - if err == nil { - result = fileWriteFromRow(row, session) + if err != nil { + return err } - return err + result = fileWriteFromRow(row, session) + if state == "committed" && len(row.AuditSource) > 0 { + var source writeaudit.Source + if err := json.Unmarshal(row.AuditSource, &source); err != nil { + return err + } + auditCtx := writeaudit.WithSource(ctx, source) + return recordWriteAudit(auditCtx, q, tenant, "upload_file", "environment", environment, uuid.UUID(session.Bytes).String()) + } + return nil }) if err != nil { return EnvironmentFileWrite{}, err diff --git a/services/agents-api/internal/store/environment_inputs.go b/services/agents-api/internal/store/environment_inputs.go index 027a790e5..a57423924 100644 --- a/services/agents-api/internal/store/environment_inputs.go +++ b/services/agents-api/internal/store/environment_inputs.go @@ -60,6 +60,9 @@ func (s *Store) ReserveEnvironmentInput(ctx context.Context, tenantID, sessionID } var result EnvironmentInputReservation err = s.withEnvironmentInputSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + audit := func() error { + return recordWriteAudit(ctx, q, tenantID, "send_events", "session", uuid.UUID(session.Bytes).String(), "") + } previous, err := q.FindEnvironmentInputReservation(ctx, sqlc.FindEnvironmentInputReservationParams{ SessionID: session, IdempotencyKey: key, Batch: encoded, }) @@ -68,6 +71,9 @@ func (s *Store) ReserveEnvironmentInput(ctx context.Context, tenantID, sessionID return ErrIdempotencyConflict } result, err = settleEnvironmentInput(ctx, q, tenantID, previous.EnvironmentInputReservation, EnvironmentInputExpired) + if err == nil && (result.State == EnvironmentInputPending || result.State == EnvironmentInputAdmitted) { + return audit() + } return err } if !errors.Is(err, pgx.ErrNoRows) { @@ -80,7 +86,7 @@ func (s *Store) ReserveEnvironmentInput(ctx context.Context, tenantID, sessionID if len(receipts) > 0 { // Earlier direct admission has receipts, but never had a reservation or deadline. result = EnvironmentInputReservation{SessionID: sessionID, State: EnvironmentInputAdmitted, Receipts: receipts} - return nil + return audit() } if err := checkEnvironmentFileWriteGate(ctx, q, session); err != nil { return err @@ -111,7 +117,7 @@ func (s *Store) ReserveEnvironmentInput(ctx context.Context, tenantID, sessionID } result.Receipts = append(result.Receipts, receipt) } - return nil + return audit() } else if err != nil && !errors.Is(err, pgx.ErrNoRows) { return err } @@ -122,7 +128,10 @@ func (s *Store) ReserveEnvironmentInput(ctx context.Context, tenantID, sessionID return err } result, err = environmentInputFromRow(row) - return err + if err != nil { + return err + } + return audit() }) if err != nil { return EnvironmentInputReservation{}, err diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go index 6f5afc9d4..1c512c3ab 100644 --- a/services/agents-api/internal/store/environment_templates.go +++ b/services/agents-api/internal/store/environment_templates.go @@ -104,8 +104,17 @@ func (s *Store) CreateEnvironmentTemplate(ctx context.Context, tenantID string, if err != nil { return EnvironmentTemplate{}, err } - row, err := s.queries.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, Name: name, NetworkAccess: in.NetworkAccess, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, Skills: skills, SkillContents: skillContents, Plugins: plugins, PluginContents: pluginContents, CapabilityDirectories: append([]string{}, in.Initialization.CapabilityDirectories...)}) - return templateFromRow(templateMetadataRow(row), err) + var result EnvironmentTemplate + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, Name: name, NetworkAccess: in.NetworkAccess, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, Skills: skills, SkillContents: skillContents, Plugins: plugins, PluginContents: pluginContents, CapabilityDirectories: append([]string{}, in.Initialization.CapabilityDirectories...)}) + result, err = templateFromRow(templateMetadataRow(row), err) + if err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "create", "environment_template", result.ID, "", AuditResource{Type: "environment_template", ID: result.ID}) + }) + return result, err } func (s *Store) GetEnvironmentTemplate(ctx context.Context, tenantID, templateID string) (EnvironmentTemplate, error) { @@ -152,8 +161,17 @@ func (s *Store) UpdateEnvironmentTemplate(ctx context.Context, tenantID, templat if err != nil { return EnvironmentTemplate{}, err } - row, err := s.queries.UpdateEnvironmentTemplate(ctx, sqlc.UpdateEnvironmentTemplateParams{TenantID: tenant, ID: id, Name: name, SetName: in.SetName, NetworkAccess: in.NetworkAccess, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), SetNetwork: in.SetNetwork, SetFiles: in.SetFiles, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, SetPackages: in.SetPackages, SetEnv: in.SetEnv, SetSetup: in.SetSetup, SetSkills: in.SetSkills, SetPlugins: in.SetPlugins, SetDirectories: in.SetDirectories, Skills: skills, SkillContents: skillContents, Plugins: plugins, PluginContents: pluginContents, CapabilityDirectories: append([]string{}, in.Initialization.CapabilityDirectories...)}) - return templateFromRow(templateMetadataRow(row), err) + var result EnvironmentTemplate + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.UpdateEnvironmentTemplate(ctx, sqlc.UpdateEnvironmentTemplateParams{TenantID: tenant, ID: id, Name: name, SetName: in.SetName, NetworkAccess: in.NetworkAccess, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), SetNetwork: in.SetNetwork, SetFiles: in.SetFiles, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, SetPackages: in.SetPackages, SetEnv: in.SetEnv, SetSetup: in.SetSetup, SetSkills: in.SetSkills, SetPlugins: in.SetPlugins, SetDirectories: in.SetDirectories, Skills: skills, SkillContents: skillContents, Plugins: plugins, PluginContents: pluginContents, CapabilityDirectories: append([]string{}, in.Initialization.CapabilityDirectories...)}) + result, err = templateFromRow(templateMetadataRow(row), err) + if err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "update", "environment_template", result.ID, "") + }) + return result, err } func (s *Store) DeleteEnvironmentTemplate(ctx context.Context, tenantID, templateID string) (string, error) { @@ -165,14 +183,23 @@ func (s *Store) DeleteEnvironmentTemplate(ctx context.Context, tenantID, templat if err != nil { return "", ErrNotFound } - result, err := s.queries.DeleteEnvironmentTemplate(ctx, sqlc.DeleteEnvironmentTemplateParams{TenantID: tenant, ID: id}) + var deletedID string + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + result, err := q.DeleteEnvironmentTemplate(ctx, sqlc.DeleteEnvironmentTemplateParams{TenantID: tenant, ID: id}) + if err != nil { + return err + } + deletedID = uuid.UUID(result.Bytes).String() + return recordWriteAudit(ctx, q, tenantID, "delete", "environment_template", deletedID, "") + }) if errors.Is(err, pgx.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", err } - return uuid.UUID(result.Bytes).String(), nil + return deletedID, nil } type EnvironmentTemplatePage struct { diff --git a/services/agents-api/internal/store/environment_write_audit_test.go b/services/agents-api/internal/store/environment_write_audit_test.go new file mode 100644 index 000000000..e6852c69c --- /dev/null +++ b/services/agents-api/internal/store/environment_write_audit_test.go @@ -0,0 +1,120 @@ +package store + +import ( + "bytes" + "context" + "io" + "strings" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" +) + +func TestEnvironmentUploadWriteAuditSurvivesRequestAndLeaseContext(t *testing.T) { + f := newFileWriteFixture(t) + ctx, cancel := context.WithCancel(sessionAuditContext(t, f.tenant, "a")) + source, _ := writeaudit.FromContext(ctx) + if _, err := f.writer.ReserveEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key); err != nil { + t.Fatal(err) + } + cancel() + sessionAuditCount(t, f.s, f.tenant, 0) + // Neither the settlement caller nor the newly acquired execution lease owns the request context. + if err := f.lease.Close(context.Background()); err != nil { + t.Fatal(err) + } + next := executionLease(t, f.s).Store() + var group sync.WaitGroup + for range 4 { + group.Go(func() { + if _, err := next.SettleEnvironmentFileWrite(t.Context(), f.tenant, f.env.ID, f.key, "committed"); err != nil { + t.Error(err) + } + }) + } + group.Wait() + sessionAuditCount(t, f.s, f.tenant, 1) + var key, request, trace, resource, parent, action string + if err := f.s.pool.QueryRow(t.Context(), `SELECT key_id,request_id,trace_id,resource_id,parent_id,action FROM write_audit_operations WHERE tenant_id=$1`, f.tenant).Scan(&key, &request, &trace, &resource, &parent, &action); err != nil || key != source.KeyID || request != source.RequestID || trace != source.TraceID || resource != f.env.ID || parent != f.session.ID || action != "upload_file" { + t.Fatal("durable source lost", key, request, trace, resource, parent, action, err) + } +} + +func TestEnvironmentUploadWriteAuditRollbackAndRejection(t *testing.T) { + for _, rejected := range []bool{false, true} { + t.Run(map[bool]string{false: "commit", true: "reject"}[rejected], func(t *testing.T) { + f := newFileWriteFixture(t) + ctx := sessionAuditContext(t, f.tenant, "a") + rejectSessionAudit(t, f.s, ctx) + if _, err := f.writer.ReserveEnvironmentFileWrite(ctx, f.tenant, f.env.ID, f.key); err != nil { + t.Fatal(err) + } + state := "committed" + if rejected { + state = "rejected" + } + _, err := f.writer.SettleEnvironmentFileWrite(t.Context(), f.tenant, f.env.ID, f.key, state) + if rejected && err != nil || !rejected && err == nil { + t.Fatal("unexpected settlement outcome", err) + } + got, err := f.s.GetEnvironmentFileWrite(t.Context(), f.tenant, f.env.ID, f.key.ID) + want := "pending" + if rejected { + want = "rejected" + } + if err != nil || got.State != want { + t.Fatal("settlement did not roll back", got, err) + } + sessionAuditCount(t, f.s, f.tenant, 0) + }) + } +} + +func TestArtifactDeleteWriteAuditAndRollback(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(map[bool]string{false: "commit", true: "rollback"}[fail], func(t *testing.T) { + s, _ := testStore(t) + tenant, session, env, turn := artifactTurn(t, s, "self_hosted") + archive := artifactArchive(t, map[string][]byte{"outputs/private.txt": []byte("secret-file-body")}) + if err := s.StageTurnArtifacts(t.Context(), tenant, session, turn, env, bytes.NewReader(archive)); err != nil { + t.Fatal(err) + } + transition(t, s, tenant, session, turn, TurnInProgress, TurnCompleted) + page, err := s.ListSessionArtifacts(t.Context(), tenant, session, "", "", 100, true) + if err != nil || len(page.Artifacts) != 1 { + t.Fatal(page, err) + } + artifact := page.Artifacts[0] + ctx := sessionAuditContext(t, tenant, "a") + if fail { + rejectSessionAudit(t, s, ctx) + } + err = s.DeleteSessionArtifact(ctx, tenant, session, artifact.ID) + if fail { + if err == nil { + t.Fatal("artifact deletion bypassed audit failure") + } + if err := s.ReadSessionArtifact(t.Context(), tenant, session, artifact.ID, func(_ SessionArtifact, r io.Reader) error { + body, err := io.ReadAll(r) + if string(body) != "secret-file-body" { + t.Error("large object did not roll back") + } + return err + }); err != nil { + t.Fatal(err) + } + sessionAuditCount(t, s, tenant, 0) + } else { + if err != nil { + t.Fatal(err) + } + sessionAuditCount(t, s, tenant, 1) + var resource, parent, raw string + if err := s.pool.QueryRow(t.Context(), `SELECT resource_id,parent_id,to_jsonb(o)::text FROM write_audit_operations o WHERE tenant_id=$1`, tenant).Scan(&resource, &parent, &raw); err != nil || resource != artifact.ID || parent != session || strings.Contains(raw, "secret-file-body") || strings.Contains(raw, "outputs/") { + t.Fatal("artifact audit identity or privacy", err) + } + } + }) + } +} diff --git a/services/agents-api/internal/store/project_api_keys.go b/services/agents-api/internal/store/project_api_keys.go index 64b2ed8bb..889453cbe 100644 --- a/services/agents-api/internal/store/project_api_keys.go +++ b/services/agents-api/internal/store/project_api_keys.go @@ -38,6 +38,7 @@ type IssuedProjectAPIKey struct { // ProjectAPIKeyBinding must also match a currently configured static parent before authentication. type ProjectAPIKeyBinding struct { + Key ProjectAPIKey BindingDigest string Principal identity.Principal } @@ -153,7 +154,7 @@ func (s *Store) ResolveProjectAPIKey(ctx context.Context, tokenDigest string) (P if err != nil { return ProjectAPIKeyBinding{}, err } - return ProjectAPIKeyBinding{BindingDigest: row.BindingDigest, Principal: identity.Principal{ + return ProjectAPIKeyBinding{Key: projectAPIKeyMetadata(row.ID, row.Name, row.Prefix, row.CreatedAt, row.RevokedAt), BindingDigest: row.BindingDigest, Principal: identity.Principal{ ProjectScope: identity.ProjectScope{TenantID: uuid.UUID(row.TenantID.Bytes).String(), OrganizationID: row.OrganizationID, ProjectID: row.ProjectID}, SubjectKind: row.SubjectKind, SubjectID: row.SubjectID, }}, nil diff --git a/services/agents-api/internal/store/session_artifacts.go b/services/agents-api/internal/store/session_artifacts.go index 8e9884fc8..648eb7c3b 100644 --- a/services/agents-api/internal/store/session_artifacts.go +++ b/services/agents-api/internal/store/session_artifacts.go @@ -152,6 +152,9 @@ func (s *Store) DeleteSessionArtifact(ctx context.Context, tenantID, sessionID, if err := objects.Unlink(ctx, oid.Uint32); err != nil { return err } + if err := recordWriteAudit(ctx, q, tenantID, "delete", "artifact", uuid.UUID(lookup.ID.Bytes).String(), uuid.UUID(lookup.SessionID.Bytes).String()); err != nil { + return err + } return tx.Commit(ctx) } diff --git a/services/agents-api/internal/store/session_deletion.go b/services/agents-api/internal/store/session_deletion.go index 9eee3c9f8..51565f9fe 100644 --- a/services/agents-api/internal/store/session_deletion.go +++ b/services/agents-api/internal/store/session_deletion.go @@ -5,6 +5,7 @@ import ( "errors" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -13,19 +14,19 @@ import ( // pending. Callers cancel first and delete after the Session settles. var ErrSessionNotIdle = errors.New("session must be durably idle or failed without required actions before deletion") -// errSessionAlreadyDeleted rolls back a repeated deletion without any write. -var errSessionAlreadyDeleted = errors.New("session already deleted") - // DeleteSession removes public access to a durably idle or failed Session while // retaining state needed to settle execution. The decision is taken under the // Session lock that also orders Turn and input admission, so a concurrent // admission either commits first and is rejected here, or observes the deletion. -// The owner's repeated deletion succeeds without another write; foreign and +// The owner's repeated deletion succeeds without another resource write; foreign and // missing Sessions remain not found. func (s *Store) DeleteSession(ctx context.Context, tenantID, sessionID string) error { - err := s.withLockedSession(ctx, tenantID, sessionID, true, func(ctx context.Context, q *sqlc.Queries, session sqlc.LockSessionRow) error { + return s.withLockedSession(ctx, tenantID, sessionID, true, func(ctx context.Context, q *sqlc.Queries, session sqlc.LockSessionRow) error { + audit := func() error { + return recordWriteAudit(ctx, q, tenantID, "delete", "session", uuid.UUID(session.ID.Bytes).String(), "") + } if session.DeletedAt.Valid { - return errSessionAlreadyDeleted + return audit() } if err := requireSessionSettled(ctx, q, session.ID); err != nil { return err @@ -36,12 +37,11 @@ func (s *Store) DeleteSession(ctx context.Context, tenantID, sessionID string) e if err := q.ReleaseUnallocatedRuntimePlacement(ctx, session.ID); err != nil { return err } - return q.MarkSessionDeleted(ctx, session.ID) + if err := q.MarkSessionDeleted(ctx, session.ID); err != nil { + return err + } + return audit() }) - if errors.Is(err, errSessionAlreadyDeleted) { - return nil - } - return err } // requireSessionSettled rejects a queued, in-progress or waiting Turn, which diff --git a/services/agents-api/internal/store/session_deletion_lifecycle_public_test.go b/services/agents-api/internal/store/session_deletion_lifecycle_public_test.go index f64cff5d3..d90fdf243 100644 --- a/services/agents-api/internal/store/session_deletion_lifecycle_public_test.go +++ b/services/agents-api/internal/store/session_deletion_lifecycle_public_test.go @@ -214,13 +214,32 @@ func TestSessionDeletionLifecyclePostgres(t *testing.T) { t.Fatalf("settled Session deletion: %d %s", status, first) } digest := databaseDigest(t, pool) + filter := store.WriteOperationFilter{ResourceType: "session", ResourceID: id, Limit: 100} + beforeAudit, err := s.ListWriteOperations(ctx, tenant, filter) + if err != nil { + t.Fatal(err) + } for range 2 { if status, again := client.do(owner, http.MethodDelete, sessionPath(id), "", nil); status != http.StatusOK || again != first { t.Fatalf("repeated deletion: %d %s", status, again) } } - if after := databaseDigest(t, pool); !reflect.DeepEqual(after, digest) { - t.Fatal("repeated deletion changed the database") + afterAudit, err := s.ListWriteOperations(ctx, tenant, filter) + if err != nil || len(afterAudit.Data) != len(beforeAudit.Data)+2 || !reflect.DeepEqual(afterAudit.Data[2:], beforeAudit.Data) { + t.Fatal("repeated deletion must append exactly two operation records", err) + } + for _, operation := range afterAudit.Data[:2] { + if operation.Action != "delete" || operation.APIKey.ID != "static:"+device.HashCredential(owner) { + t.Fatal("repeated deletion recorded the wrong operation or key") + } + } + // Only the new operation records may differ. Ownership, Session state, + // execution data and every public response remain unchanged. + after := databaseDigest(t, pool) + delete(after, "write_audit_operations") + delete(digest, "write_audit_operations") + if !reflect.DeepEqual(after, digest) { + t.Fatal("repeated deletion changed business data or ownership") } notFound(owner, http.MethodGet, sessionPath(id)) notFound(owner, http.MethodGet, sessionPath(id)+"/turns") diff --git a/services/agents-api/internal/store/session_initial_input.go b/services/agents-api/internal/store/session_initial_input.go index 0bcb230fd..aad6328b0 100644 --- a/services/agents-api/internal/store/session_initial_input.go +++ b/services/agents-api/internal/store/session_initial_input.go @@ -113,8 +113,18 @@ func (s *Store) createSessionResources(ctx context.Context, tenant string, param if err != nil { return err } + audit := func() error { + var created []AuditResource + if row.ID == params.ID { + created = append(created, AuditResource{Type: "session", ID: uuid.UUID(row.ID.Bytes).String()}) + if environment != nil { + created = append(created, AuditResource{Type: "environment", ID: environment.ID, ParentID: uuid.UUID(row.ID.Bytes).String()}) + } + } + return recordWriteAudit(ctx, q, tenant, "create", "session", uuid.UUID(row.ID.Bytes).String(), "", created...) + } if row.ID != params.ID || len(inputs) == 0 { - return nil + return audit() } // Creation retries use the Session request hash. Keep the internal input key // independent of caller-supplied keys at the events endpoint. @@ -137,7 +147,10 @@ func (s *Store) createSessionResources(ctx context.Context, tenant string, param } } } - return q.PruneSessionEvents(ctx, row.ID) + if err := q.PruneSessionEvents(ctx, row.ID); err != nil { + return err + } + return audit() }) return row, environment, err } diff --git a/services/agents-api/internal/store/session_metadata.go b/services/agents-api/internal/store/session_metadata.go index f4e779624..4899cbe12 100644 --- a/services/agents-api/internal/store/session_metadata.go +++ b/services/agents-api/internal/store/session_metadata.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" "github.com/jackc/pgx/v5" ) @@ -20,7 +21,16 @@ func (s *Store) UpdateSessionMetadata(ctx context.Context, tenantID, sessionID s if err != nil { return Session{}, err } - row, err := s.queries.UpdateSessionMetadata(ctx, sqlc.UpdateSessionMetadataParams{TenantID: tenant, ID: id, Metadata: encoded}) + var row sqlc.Session + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + var err error + row, err = q.UpdateSessionMetadata(ctx, sqlc.UpdateSessionMetadataParams{TenantID: tenant, ID: id, Metadata: encoded}) + if err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "update", "session", uuid.UUID(row.ID.Bytes).String(), "") + }) if errors.Is(err, pgx.ErrNoRows) { return Session{}, ErrNotFound } diff --git a/services/agents-api/internal/store/session_write_audit.go b/services/agents-api/internal/store/session_write_audit.go new file mode 100644 index 000000000..b31db8b36 --- /dev/null +++ b/services/agents-api/internal/store/session_write_audit.go @@ -0,0 +1,20 @@ +package store + +import ( + "context" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +// AuditSessionOperation records an authorized public no-op or creation replay. +// It cannot create ownership or admit execution work. +func (s *Store) AuditSessionOperation(ctx context.Context, tenantID, sessionID, action string) error { + if action != "create" && action != "send_events" { + return ErrInvalidInput + } + return s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + return recordWriteAudit(ctx, q, tenantID, action, "session", uuid.UUID(session.Bytes).String(), "") + }) +} diff --git a/services/agents-api/internal/store/session_write_audit_test.go b/services/agents-api/internal/store/session_write_audit_test.go new file mode 100644 index 000000000..a8ae2f8be --- /dev/null +++ b/services/agents-api/internal/store/session_write_audit_test.go @@ -0,0 +1,227 @@ +package store + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" + "github.com/google/uuid" +) + +func sessionAuditContext(t *testing.T, tenant, key string) context.Context { + t.Helper() + digest := strings.Repeat(key, 64) + return writeaudit.WithSource(t.Context(), writeaudit.Source{TenantID: tenant, KeyID: "static:" + digest, + Name: "safe key", Prefix: digest[:8], Kind: "static", RequestID: uuid.NewString(), TraceID: "shared-trace"}) +} + +func sessionAuditCount(t *testing.T, s *Store, tenant string, want int) { + t.Helper() + var count int + if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM write_audit_operations WHERE tenant_id=$1", tenant).Scan(&count); err != nil || count != want { + t.Fatalf("audit count = %d, want %d: %v", count, want, err) + } +} + +func rejectSessionAudit(t *testing.T, s *Store, ctx context.Context) { + t.Helper() + source, _ := writeaudit.FromContext(ctx) + name := "reject_session_audit_" + strings.ReplaceAll(uuid.NewString(), "-", "") + _, err := s.pool.Exec(t.Context(), fmt.Sprintf(`CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN IF NEW.request_id = '%s' THEN RAISE EXCEPTION 'audit insert rejected'; END IF; RETURN NEW; END $$; +CREATE TRIGGER %s BEFORE INSERT ON write_audit_operations FOR EACH ROW EXECUTE FUNCTION %s();`, name, source.RequestID, name, name)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := s.pool.Exec(context.Background(), fmt.Sprintf("DROP TRIGGER %s ON write_audit_operations; DROP FUNCTION %s();", name, name)); err != nil { + t.Error(err) + } + }) +} + +func TestSessionWriteAuditCreationReplayNoopAndDeletion(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := environmentInput("audit-create", "self_hosted", "/workspace") + created, err := s.CreateSession(sessionAuditContext(t, tenant, "a"), tenant, input) + if err != nil { + t.Fatal(err) + } + env, err := s.GetSessionEnvironment(t.Context(), tenant, created.ID) + if err != nil { + t.Fatal(err) + } + if _, err := s.CreateSession(sessionAuditContext(t, tenant, "b"), tenant, input); err != nil { + t.Fatal(err) + } + if _, err := s.GetSession(sessionAuditContext(t, tenant, "b"), tenant, created.ID); err != nil { + t.Fatal(err) + } + sessionAuditCount(t, s, tenant, 2) + for _, resource := range []string{created.ID, env.ID} { + var key string + if err := s.pool.QueryRow(t.Context(), `SELECT o.key_id FROM write_audit_owners a JOIN write_audit_operations o ON o.id=a.operation_id WHERE a.tenant_id=$1 AND a.resource_id=$2`, tenant, resource).Scan(&key); err != nil || key != "static:"+strings.Repeat("a", 64) { + t.Fatal("retry replaced creator", key, err) + } + } + for _, action := range []string{"create", "send_events"} { + if err := s.AuditSessionOperation(sessionAuditContext(t, tenant, "b"), tenant, created.ID, action); err != nil { + t.Fatal(err) + } + } + if _, err := s.UpdateSessionMetadata(sessionAuditContext(t, tenant, "b"), tenant, created.ID, map[string]string{"private": "not in audit"}); err != nil { + t.Fatal(err) + } + for range 2 { + if err := s.DeleteSession(sessionAuditContext(t, tenant, "b"), tenant, created.ID); err != nil { + t.Fatal(err) + } + } + sessionAuditCount(t, s, tenant, 7) + var history string + if err := s.pool.QueryRow(t.Context(), "SELECT jsonb_agg(to_jsonb(o))::text FROM write_audit_operations o WHERE tenant_id=$1", tenant).Scan(&history); err != nil || strings.Contains(history, "not in audit") || strings.Contains(history, "/workspace") { + t.Fatal("payload entered audit", err) + } + if err := s.AuditSessionOperation(sessionAuditContext(t, tenant, "b"), tenant, created.ID, "send_events"); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted no-op accepted", err) + } + sessionAuditCount(t, s, tenant, 7) +} + +func TestSessionWriteAuditConcurrentCreation(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := environmentInput("audit-race", "self_hosted", "/workspace") + var group sync.WaitGroup + for range 8 { + ctx := sessionAuditContext(t, tenant, "a") + group.Go(func() { + if _, err := s.CreateSession(ctx, tenant, input); err != nil { + t.Error(err) + } + }) + } + group.Wait() + sessionAuditCount(t, s, tenant, 8) + var owners, creates int + if err := s.pool.QueryRow(t.Context(), `SELECT count(*),count(DISTINCT operation_id) FROM write_audit_owners WHERE tenant_id=$1`, tenant).Scan(&owners, &creates); err != nil || owners != 2 || creates != 1 { + t.Fatal("creation race attribution", owners, creates, err) + } +} + +func TestSessionWriteAuditRollback(t *testing.T) { + for _, operation := range []string{"create", "update", "delete", "events", "noop", "reserve"} { + t.Run(operation, func(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := environmentInput("audit-rollback", "self_hosted", "/workspace") + ctx := sessionAuditContext(t, tenant, "a") + rejectSessionAudit(t, s, ctx) + if operation == "create" { + if _, err := s.CreateSession(ctx, tenant, input); err == nil { + t.Fatal("creation bypassed audit failure") + } + var count int + if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM sessions WHERE tenant_id=$1", tenant).Scan(&count); err != nil || count != 0 { + t.Fatal("creation did not roll back", count, err) + } + return + } + created, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + switch operation { + case "update": + _, err = s.UpdateSessionMetadata(ctx, tenant, created.ID, map[string]string{"new": "value"}) + case "delete": + err = s.DeleteSession(ctx, tenant, created.ID) + case "events": + _, err = s.SubmitInputs(ctx, tenant, created.ID, "events", []Input{messageInput("private")}) + case "noop": + err = s.AuditSessionOperation(ctx, tenant, created.ID, "send_events") + case "reserve": + _, err = s.ReserveEnvironmentInput(ctx, tenant, created.ID, "reserve", []Input{messageInput("private")}) + } + if err == nil { + t.Fatal("mutation bypassed audit failure") + } + got, err := s.GetSession(t.Context(), tenant, created.ID) + if err != nil || len(got.Metadata) != 0 { + t.Fatal("resource update/deletion survived rollback", got, err) + } + var inputs, reservations int + if err := s.pool.QueryRow(t.Context(), `SELECT (SELECT count(*) FROM turn_inputs WHERE session_id=$1),(SELECT count(*) FROM environment_input_reservations WHERE session_id=$1)`, created.ID).Scan(&inputs, &reservations); err != nil || inputs != 0 || reservations != 0 { + t.Fatal("input survived rollback", inputs, reservations, err) + } + sessionAuditCount(t, s, tenant, 0) + }) + } +} + +func TestEventsWriteAuditAdmissionAndReplay(t *testing.T) { + for _, prepared := range []bool{false, true} { + t.Run(fmt.Sprint(prepared), func(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := environmentInput("audit-events", "self_hosted", "/workspace") + created, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + submit := func(ctx context.Context) error { + if prepared { + _, err := s.ReserveEnvironmentInput(ctx, tenant, created.ID, "batch", []Input{messageInput("private")}) + return err + } + _, err := s.SubmitInputs(ctx, tenant, created.ID, "batch", []Input{messageInput("private")}) + return err + } + first := sessionAuditContext(t, tenant, "a") + for _, ctx := range []context.Context{first, first, sessionAuditContext(t, tenant, "b")} { + if err := submit(ctx); err != nil { + t.Fatal(err) + } + } + sessionAuditCount(t, s, tenant, 2) + var owners int + if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM write_audit_owners WHERE tenant_id=$1", tenant).Scan(&owners); err != nil || owners != 0 { + t.Fatal("events acquired historical ownership", owners, err) + } + }) + } +} + +func TestSessionWriteAuditInitialInputAndHistoricalReplay(t *testing.T) { + for _, kind := range []string{"none", "self_hosted"} { + t.Run(kind, func(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := environmentInput("audit-initial", kind, "/workspace") + input.InitialInputs = []Input{messageInput("private initial message")} + created, err := s.CreateSessionStream(sessionAuditContext(t, tenant, "a"), tenant, input) + if err != nil || !created.Created { + t.Fatal(created, err) + } + sessionAuditCount(t, s, tenant, 1) + input.IdempotencyKey = "legacy" + legacy, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + if err := s.AuditSessionOperation(sessionAuditContext(t, tenant, "b"), tenant, legacy.ID, "create"); err != nil { + t.Fatal(err) + } + var owners int + if err := s.pool.QueryRow(t.Context(), "SELECT count(*) FROM write_audit_owners WHERE tenant_id=$1 AND resource_id=$2", tenant, legacy.ID).Scan(&owners); err != nil || owners != 0 { + t.Fatal("replay attributed historical resource", owners, err) + } + sessionAuditCount(t, s, tenant, 2) + }) + } +} diff --git a/services/agents-api/internal/store/skill_versions.go b/services/agents-api/internal/store/skill_versions.go index c25555d24..cdaeeecc4 100644 --- a/services/agents-api/internal/store/skill_versions.go +++ b/services/agents-api/internal/store/skill_versions.go @@ -36,7 +36,11 @@ func (s *Store) CreateSkillVersion(ctx context.Context, tenantID, skillID string if err != nil { return err } - return q.AdvanceSkillVersion(ctx, sqlc.AdvanceSkillVersionParams{TenantID: tenant, ID: id, MakeDefault: makeDefault, Name: metadata.Name, Description: metadata.Description}) + if err := q.AdvanceSkillVersion(ctx, sqlc.AdvanceSkillVersionParams{TenantID: tenant, ID: id, MakeDefault: makeDefault, Name: metadata.Name, Description: metadata.Description}); err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "upload_version", "skill_version", result.ID, result.SkillID, + AuditResource{Type: "skill_version", ID: result.ID, ParentID: result.SkillID}) }) if errors.Is(err, pgx.ErrNoRows) { err = ErrNotFound @@ -112,8 +116,10 @@ func (s *Store) DeleteSkillVersion(ctx context.Context, tenantID, skillID, versi return ErrDefaultSkillVersion } result = skillVersionFromRow(sqlc.GetSkillVersionRow(rows[0])) - _, err = q.DeleteSkill(ctx, sqlc.DeleteSkillParams{TenantID: tenant, ID: id}) - return err + if _, err = q.DeleteSkill(ctx, sqlc.DeleteSkillParams{TenantID: tenant, ID: id}); err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "delete", "skill_version", result.ID, result.SkillID) } row, err := q.DeleteSkillVersion(ctx, sqlc.DeleteSkillVersionParams{TenantID: tenant, SkillID: id, Version: number}) if err != nil { @@ -121,9 +127,11 @@ func (s *Store) DeleteSkillVersion(ctx context.Context, tenantID, skillID, versi } result = skillVersionFromRow(sqlc.GetSkillVersionRow(row)) if owner.LatestVersion == number { - return q.RefreshLatestSkillVersion(ctx, sqlc.RefreshLatestSkillVersionParams{TenantID: tenant, ID: id}) + if err := q.RefreshLatestSkillVersion(ctx, sqlc.RefreshLatestSkillVersionParams{TenantID: tenant, ID: id}); err != nil { + return err + } } - return nil + return recordWriteAudit(ctx, q, tenantID, "delete", "skill_version", result.ID, result.SkillID) }) if errors.Is(err, pgx.ErrNoRows) { err = ErrNotFound diff --git a/services/agents-api/internal/store/skills.go b/services/agents-api/internal/store/skills.go index bc6f3608e..1507f20fd 100644 --- a/services/agents-api/internal/store/skills.go +++ b/services/agents-api/internal/store/skills.go @@ -50,11 +50,14 @@ func (s *Store) CreateSkill(ctx context.Context, tenantID string, archive []byte if err != nil { return err } - if _, err = s.saveSkillVersion(ctx, q, tenant, id, 1, metadata, archive); err != nil { + initial, err := s.saveSkillVersion(ctx, q, tenant, id, 1, metadata, archive) + if err != nil { return err } result = skillFromRow(row) - return nil + return recordWriteAudit(ctx, q, tenantID, "create", "skill", result.ID, "", + AuditResource{Type: "skill", ID: result.ID}, + AuditResource{Type: "skill_version", ID: initial.ID, ParentID: result.ID}) }) return result, err } @@ -91,8 +94,11 @@ func (s *Store) UpdateSkillDefault(ctx context.Context, tenantID, skillID, versi return err } row, err := q.SetDefaultSkillVersion(ctx, sqlc.SetDefaultSkillVersionParams{TenantID: tenant, ID: id, DefaultVersion: number, Name: version.Name, Description: version.Description}) + if err != nil { + return err + } result = skillFromRow(row) - return err + return recordWriteAudit(ctx, q, tenantID, "update_default_version", "skill", result.ID, "") }) if errors.Is(err, pgx.ErrNoRows) { err = ErrNotFound @@ -105,7 +111,13 @@ func (s *Store) DeleteSkill(ctx context.Context, tenantID, skillID string) error if err != nil { return err } - _, err = s.queries.DeleteSkill(ctx, sqlc.DeleteSkillParams{TenantID: tenant, ID: id}) + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + if _, err := q.DeleteSkill(ctx, sqlc.DeleteSkillParams{TenantID: tenant, ID: id}); err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "delete", "skill", skillID, "") + }) if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound } diff --git a/services/agents-api/internal/store/source_files.go b/services/agents-api/internal/store/source_files.go index d0269bba4..d9f2b95eb 100644 --- a/services/agents-api/internal/store/source_files.go +++ b/services/agents-api/internal/store/source_files.go @@ -81,6 +81,10 @@ func (s *Store) CreateSourceFile(ctx context.Context, tenantID string, upload fu if err != nil { return SourceFile{}, fmt.Errorf("create source file: %w", err) } + resource := sourceFileFromRow(row) + if err := recordWriteAudit(ctx, s.queries.WithTx(tx), tenantID, "create", "file", resource.ID, "", AuditResource{Type: "file", ID: resource.ID}); err != nil { + return SourceFile{}, err + } if err := tx.Commit(ctx); err != nil { return SourceFile{}, err } @@ -203,6 +207,9 @@ func (s *Store) DeleteSourceFile(ctx context.Context, tenantID, fileID string) e if err := objects.Unlink(ctx, oid.Uint32); err != nil { return err } + if err := recordWriteAudit(ctx, s.queries.WithTx(tx), tenantID, "delete", "file", fileID, ""); err != nil { + return err + } return tx.Commit(ctx) } diff --git a/services/agents-api/internal/store/turn_inputs.go b/services/agents-api/internal/store/turn_inputs.go index 241dc460b..d2c441114 100644 --- a/services/agents-api/internal/store/turn_inputs.go +++ b/services/agents-api/internal/store/turn_inputs.go @@ -72,7 +72,7 @@ func (s *Store) SubmitInputs(ctx context.Context, tenantID, sessionID, key strin } if len(previous) > 0 { receipts = previous - return nil + return recordWriteAudit(ctx, q, tenantID, "send_events", "session", uuid.UUID(session.Bytes).String(), "") } if slices.ContainsFunc(batch, func(input Input) bool { return input.Kind == "message" }) { if err := checkEnvironmentFileWriteGate(ctx, q, session); err != nil { @@ -89,7 +89,7 @@ func (s *Store) SubmitInputs(ctx context.Context, tenantID, sessionID, key strin } receipts = append(receipts, receipt) } - return nil + return recordWriteAudit(ctx, q, tenantID, "send_events", "session", uuid.UUID(session.Bytes).String(), "") }) if err != nil { return nil, fmt.Errorf("submit turn inputs: %w", err) diff --git a/services/agents-api/internal/store/vault_credentials.go b/services/agents-api/internal/store/vault_credentials.go index 05793e51a..bbdbb809a 100644 --- a/services/agents-api/internal/store/vault_credentials.go +++ b/services/agents-api/internal/store/vault_credentials.go @@ -58,9 +58,21 @@ func (s *Store) CreateStaticCredential(ctx context.Context, tenantID, vaultID st if err != nil { return Credential{}, errors.New("credential encryption failed") } - row, err := s.queries.CreateStaticCredential(ctx, sqlc.CreateStaticCredentialParams{ - ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, VaultID: vault, - Name: input.Name, McpServerUrl: input.MCPServerURL, TokenCiphertext: ciphertext, + var created Credential + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.CreateStaticCredential(ctx, sqlc.CreateStaticCredentialParams{ + ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, VaultID: vault, + Name: input.Name, McpServerUrl: input.MCPServerURL, TokenCiphertext: ciphertext, + }) + if err != nil { + return err + } + created, err = credentialFromRow(sqlc.GetCredentialRow(row)) + if err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "create", "credential", created.ID, created.VaultID, AuditResource{Type: "credential", ID: created.ID, ParentID: created.VaultID}) }) if errors.Is(err, pgx.ErrNoRows) { return Credential{}, ErrNotFound @@ -68,7 +80,7 @@ func (s *Store) CreateStaticCredential(ctx context.Context, tenantID, vaultID st if err != nil { return Credential{}, fmt.Errorf("create credential: %w", err) } - return credentialFromRow(sqlc.GetCredentialRow(row)) + return created, nil } func (s *Store) GetCredential(ctx context.Context, tenantID, vaultID, credentialID string) (Credential, error) { diff --git a/services/agents-api/internal/store/vault_credentials_delete.go b/services/agents-api/internal/store/vault_credentials_delete.go index 4ee62331a..28ff85243 100644 --- a/services/agents-api/internal/store/vault_credentials_delete.go +++ b/services/agents-api/internal/store/vault_credentials_delete.go @@ -23,12 +23,21 @@ func (s *Store) DeleteCredential(ctx context.Context, tenantID, vaultID, credent if err != nil { return "", ErrNotFound } - deleted, err := s.queries.DeleteCredential(ctx, sqlc.DeleteCredentialParams{TenantID: tenant, VaultID: vault, ID: id}) + var deletedID string + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + deleted, err := q.DeleteCredential(ctx, sqlc.DeleteCredentialParams{TenantID: tenant, VaultID: vault, ID: id}) + if err != nil { + return err + } + deletedID = uuid.UUID(deleted.Bytes).String() + return recordWriteAudit(ctx, q, tenantID, "delete", "credential", deletedID, uuid.UUID(vault.Bytes).String()) + }) if errors.Is(err, pgx.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", errors.New("credential deletion failed") } - return uuid.UUID(deleted.Bytes).String(), nil + return deletedID, nil } diff --git a/services/agents-api/internal/store/vault_credentials_delete_test.go b/services/agents-api/internal/store/vault_credentials_delete_test.go index 226cf1e56..b880ead49 100644 --- a/services/agents-api/internal/store/vault_credentials_delete_test.go +++ b/services/agents-api/internal/store/vault_credentials_delete_test.go @@ -9,7 +9,6 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" "github.com/google/uuid" - "github.com/jackc/pgx/v5" ) func TestCredentialDeletionScopeBindingAndRestart(t *testing.T) { @@ -63,14 +62,8 @@ func TestCredentialDeletionScopeBindingAndRestart(t *testing.T) { } } // An actual database write failure must leave the resource and token intact. - tx, err := pool.BeginTx(t.Context(), pgx.TxOptions{AccessMode: pgx.ReadOnly}) - if err != nil { - t.Fatal(err) - } - readOnly := *public - readOnly.queries = public.queries.WithTx(tx) + readOnly := readOnlyResourceStore(t, pool) _, deletionErr := readOnly.DeleteCredential(t.Context(), tenant, vault.ID, original.ID) - _ = tx.Rollback(t.Context()) if deletionErr == nil || deletionErr.Error() != "credential deletion failed" { t.Fatal("failed mutation was accepted or exposed") } diff --git a/services/agents-api/internal/store/vault_credentials_oauth.go b/services/agents-api/internal/store/vault_credentials_oauth.go index fa1f9149f..e5a0344d1 100644 --- a/services/agents-api/internal/store/vault_credentials_oauth.go +++ b/services/agents-api/internal/store/vault_credentials_oauth.go @@ -34,9 +34,21 @@ func (s *Store) CreateOAuthCredential(ctx context.Context, tenantID, vaultID str if err != nil { return Credential{}, err } - row, err := s.queries.CreateOAuthCredential(ctx, sqlc.CreateOAuthCredentialParams{ - ID: pgtype.UUID{Bytes: uuid.MustParse(credential.ID), Valid: true}, TenantID: tenant, VaultID: vault, - Name: input.Name, McpServerUrl: input.MCPServerURL, OauthMetadata: metadata, TokenCiphertext: ciphertext, + var created Credential + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.CreateOAuthCredential(ctx, sqlc.CreateOAuthCredentialParams{ + ID: pgtype.UUID{Bytes: uuid.MustParse(credential.ID), Valid: true}, TenantID: tenant, VaultID: vault, + Name: input.Name, McpServerUrl: input.MCPServerURL, OauthMetadata: metadata, TokenCiphertext: ciphertext, + }) + if err != nil { + return err + } + created, err = credentialFromRow(sqlc.GetCredentialRow(row)) + if err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "create", "credential", created.ID, created.VaultID, AuditResource{Type: "credential", ID: created.ID, ParentID: created.VaultID}) }) if errors.Is(err, pgx.ErrNoRows) { return Credential{}, ErrNotFound @@ -44,7 +56,7 @@ func (s *Store) CreateOAuthCredential(ctx context.Context, tenantID, vaultID str if err != nil { return Credential{}, errors.New("credential creation failed") } - return credentialFromRow(sqlc.GetCredentialRow(row)) + return created, nil } func (s *Store) UpdateOAuthCredential(ctx context.Context, tenantID, vaultID, credentialID string, input UpdateOAuthCredentialInput) (Credential, error) { @@ -92,6 +104,9 @@ func (s *Store) UpdateOAuthCredential(ctx context.Context, tenantID, vaultID, cr if err != nil { return Credential{}, err } + if err := recordWriteAudit(ctx, s.queries.WithTx(tx), tenantID, "update", "credential", updated.ID, updated.VaultID); err != nil { + return Credential{}, errors.New("credential update failed") + } if err := tx.Commit(ctx); err != nil { return Credential{}, errors.New("credential update failed") } diff --git a/services/agents-api/internal/store/vault_credentials_update.go b/services/agents-api/internal/store/vault_credentials_update.go index 731cd75d2..34bdb4284 100644 --- a/services/agents-api/internal/store/vault_credentials_update.go +++ b/services/agents-api/internal/store/vault_credentials_update.go @@ -47,8 +47,20 @@ func (s *Store) UpdateStaticCredential(ctx context.Context, tenantID, vaultID, c if err != nil { return Credential{}, errors.New("credential encryption failed") } - row, err := s.queries.UpdateStaticCredential(ctx, sqlc.UpdateStaticCredentialParams{ - TenantID: tenant, VaultID: vault, ID: id, McpServerUrl: current.MCPServerURL, TokenCiphertext: ciphertext, + var updated Credential + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.UpdateStaticCredential(ctx, sqlc.UpdateStaticCredentialParams{ + TenantID: tenant, VaultID: vault, ID: id, McpServerUrl: current.MCPServerURL, TokenCiphertext: ciphertext, + }) + if err != nil { + return err + } + updated, err = credentialFromRow(sqlc.GetCredentialRow(row)) + if err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "update", "credential", updated.ID, updated.VaultID) }) if errors.Is(err, pgx.ErrNoRows) { return Credential{}, ErrNotFound @@ -56,5 +68,5 @@ func (s *Store) UpdateStaticCredential(ctx context.Context, tenantID, vaultID, c if err != nil { return Credential{}, errors.New("credential update failed") } - return credentialFromRow(sqlc.GetCredentialRow(row)) + return updated, nil } diff --git a/services/agents-api/internal/store/vault_credentials_update_test.go b/services/agents-api/internal/store/vault_credentials_update_test.go index 37ddbd459..ff07bb215 100644 --- a/services/agents-api/internal/store/vault_credentials_update_test.go +++ b/services/agents-api/internal/store/vault_credentials_update_test.go @@ -121,14 +121,9 @@ func TestStaticCredentialUpdatePreservesBindingsAndReplacesCurrentSecret(t *test assertUnchanged() } // A real PostgreSQL mutation failure must preserve both ciphertext and time. - tx, err := pool.BeginTx(t.Context(), pgx.TxOptions{AccessMode: pgx.ReadOnly}) - if err != nil { - t.Fatal(err) - } - readOnly := *s - readOnly.queries = s.queries.WithTx(tx) + readOnly := readOnlyResourceStore(t, pool) + readOnly.credentialCipher = cipher _, updateErr := readOnly.UpdateStaticCredential(t.Context(), tenant, original.VaultID, original.ID, UpdateStaticCredentialInput{Token: "rejected"}) - _ = tx.Rollback(t.Context()) if updateErr == nil || updateErr.Error() != "credential update failed" { t.Fatal("database write failure was accepted or exposed") } diff --git a/services/agents-api/internal/store/vaults.go b/services/agents-api/internal/store/vaults.go index 63ce5f862..388b3eb64 100644 --- a/services/agents-api/internal/store/vaults.go +++ b/services/agents-api/internal/store/vaults.go @@ -47,14 +47,26 @@ func (s *Store) CreateVault(ctx context.Context, tenantID string, input CreateVa if err != nil { return Vault{}, err } - row, err := s.queries.CreateVault(ctx, sqlc.CreateVaultParams{ - ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, - Name: name, Metadata: metadata, + var created Vault + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.CreateVault(ctx, sqlc.CreateVaultParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, + Name: name, Metadata: metadata, + }) + if err != nil { + return err + } + created, err = vaultFromRow(row) + if err != nil { + return err + } + return recordWriteAudit(ctx, q, tenantID, "create", "vault", created.ID, "", AuditResource{Type: "vault", ID: created.ID, ParentID: ""}) }) if err != nil { return Vault{}, fmt.Errorf("create vault: %w", err) } - return vaultFromRow(row) + return created, nil } func validVaultName(name string) bool { diff --git a/services/agents-api/internal/store/vaults_delete.go b/services/agents-api/internal/store/vaults_delete.go index b960cf862..9b92a7cf7 100644 --- a/services/agents-api/internal/store/vaults_delete.go +++ b/services/agents-api/internal/store/vaults_delete.go @@ -19,12 +19,21 @@ func (s *Store) DeleteVault(ctx context.Context, tenantID, vaultID string) (stri if err != nil { return "", ErrNotFound } - deleted, err := s.queries.DeleteVault(ctx, sqlc.DeleteVaultParams{TenantID: tenant, ID: id}) + var deletedID string + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + deleted, err := q.DeleteVault(ctx, sqlc.DeleteVaultParams{TenantID: tenant, ID: id}) + if err != nil { + return err + } + deletedID = uuid.UUID(deleted.Bytes).String() + return recordWriteAudit(ctx, q, tenantID, "delete", "vault", deletedID, "") + }) if errors.Is(err, pgx.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", errors.New("vault deletion failed") } - return uuid.UUID(deleted.Bytes).String(), nil + return deletedID, nil } diff --git a/services/agents-api/internal/store/vaults_delete_test.go b/services/agents-api/internal/store/vaults_delete_test.go index 830066a6c..b4b213f65 100644 --- a/services/agents-api/internal/store/vaults_delete_test.go +++ b/services/agents-api/internal/store/vaults_delete_test.go @@ -8,8 +8,8 @@ import ( "testing" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/google/uuid" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) @@ -64,24 +64,20 @@ func TestVaultDeletionCascadeBindingAndRestart(t *testing.T) { t.Fatal("foreign or invalid deletion was accepted", err) } } - tx, err := pool.BeginTx(t.Context(), pgx.TxOptions{AccessMode: pgx.ReadOnly}) - if err != nil { - t.Fatal(err) - } - transactional := *public - transactional.queries = public.queries.WithTx(tx) - _, deletionErr := transactional.DeleteVault(t.Context(), tenant, vault.ID) - _ = tx.Rollback(t.Context()) + readOnly := readOnlyResourceStore(t, pool) + _, deletionErr := readOnly.DeleteVault(t.Context(), tenant, vault.ID) if deletionErr == nil || deletionErr.Error() != "vault deletion failed" { t.Fatal("failed mutation was accepted or exposed") } - tx, err = pool.Begin(t.Context()) + tx, err := pool.Begin(t.Context()) if err != nil { t.Fatal(err) } defer func() { _ = tx.Rollback(t.Context()) }() - transactional.queries = public.queries.WithTx(tx) - if _, err := transactional.DeleteVault(t.Context(), tenant, vault.ID); err != nil { + // Verify the database cascade independently inside an explicit transaction. + tenantID, _ := parseID(tenant) + vaultID, _ := parseID(vault.ID) + if _, err := public.queries.WithTx(tx).DeleteVault(t.Context(), sqlc.DeleteVaultParams{TenantID: tenantID, ID: vaultID}); err != nil { t.Fatal(err) } var count int diff --git a/services/agents-api/internal/store/write_audit.go b/services/agents-api/internal/store/write_audit.go new file mode 100644 index 000000000..c3aeb7d53 --- /dev/null +++ b/services/agents-api/internal/store/write_audit.go @@ -0,0 +1,97 @@ +package store + +import ( + "context" + "errors" + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// AuditResource identifies a resource genuinely created by the current transaction. +type AuditResource struct{ Type, ID, ParentID string } + +// ValidAuditResourceType is shared by the write recorder and administrator queries. +func ValidAuditResourceType(value string) bool { + switch value { + case "agent", "session", "environment", "environment_template", "skill", "skill_version", "file", "vault", "credential", "artifact": + return true + } + return false +} + +func auditText(value string, max int, required bool) bool { + return (!required || value != "") && utf8.ValidString(value) && utf8.RuneCountInString(value) <= max && !strings.ContainsFunc(value, unicode.IsControl) +} + +func validateWriteAuditSource(source writeaudit.Source, tenant string) error { + actual, err := parseID(source.TenantID) + expected, expectedErr := parseID(tenant) + valid := err == nil && expectedErr == nil && actual == expected && + auditText(source.Name, 80, false) && auditText(source.RequestID, 128, true) && auditText(source.TraceID, 128, true) + switch source.Kind { + case "static", "console": + digest := strings.TrimPrefix(source.KeyID, "static:") + valid = valid && strings.HasPrefix(source.KeyID, "static:") && validProjectKeyDigest(digest) && source.Prefix == digest[:min(len(digest), 8)] + case "issued": + _, idErr := parseID(source.KeyID) + valid = valid && idErr == nil && len(source.Prefix) == 11 && strings.HasPrefix(source.Prefix, "pc_") + for _, c := range strings.TrimPrefix(source.Prefix, "pc_") { + valid = valid && (c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '_' || c == '-') + } + default: + valid = false + } + if !valid { + return fmt.Errorf("%w: invalid write audit source", ErrInvalidInput) + } + return nil +} + +// recordWriteAudit must use the caller's business transaction. Internal callers +// without a source stay unattributed; a malformed supplied source fails closed. +func recordWriteAudit(ctx context.Context, q *sqlc.Queries, tenant, action, resourceType, resourceID, parentID string, created ...AuditResource) error { + source, ok := writeaudit.FromContext(ctx) + if !ok { + return nil + } + if err := validateWriteAuditSource(source, tenant); err != nil { + return err + } + switch action { + case "create", "update", "delete", "send_events", "upload_file", "upload_version", "update_default_version": + default: + return fmt.Errorf("%w: invalid write audit action", ErrInvalidInput) + } + resources := append([]AuditResource{{Type: resourceType, ID: resourceID, ParentID: parentID}}, created...) + for _, resource := range resources { + if !ValidAuditResourceType(resource.Type) || !auditText(resource.ID, 256, true) || !auditText(resource.ParentID, 256, false) { + return fmt.Errorf("%w: invalid write audit resource", ErrInvalidInput) + } + } + tenantID, _ := parseID(tenant) + id, err := q.InsertWriteAuditOperation(ctx, sqlc.InsertWriteAuditOperationParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenantID, + KeyID: source.KeyID, KeyName: source.Name, KeyPrefix: source.Prefix, KeyKind: source.Kind, + Action: action, ResourceType: resourceType, ResourceID: resourceID, ParentID: parentID, RequestID: source.RequestID, TraceID: source.TraceID, + }) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + for _, resource := range created { + if err := q.InsertWriteAuditOwner(ctx, sqlc.InsertWriteAuditOwnerParams{TenantID: tenantID, ResourceType: resource.Type, ResourceID: resource.ID, ParentID: resource.ParentID, OperationID: id}); err != nil { + return err + } + } + return nil +} diff --git a/services/agents-api/internal/store/write_audit_queries.go b/services/agents-api/internal/store/write_audit_queries.go new file mode 100644 index 000000000..2f410c92d --- /dev/null +++ b/services/agents-api/internal/store/write_audit_queries.go @@ -0,0 +1,183 @@ +package store + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type AuditAPIKey struct { + ID string `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + Kind string `json:"kind"` + RevokedAt *time.Time `json:"revoked_at"` +} + +type ResourceOwner struct { + ResourceID string `json:"resource_id"` + APIKey *AuditAPIKey `json:"api_key"` +} + +type WriteOperation struct { + ID string `json:"id"` + Action string `json:"action"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + ParentID string `json:"parent_id"` + RequestID string `json:"request_id"` + TraceID string `json:"trace_id"` + APIKey AuditAPIKey `json:"api_key"` + CreatedAt time.Time `json:"created_at"` +} + +type WriteOperationFilter struct { + KeyID, ResourceType, ResourceID, After string + CreatedAfter, CreatedBefore *time.Time + Limit int +} + +type WriteOperationPage struct { + Data []WriteOperation `json:"data"` + HasMore bool `json:"has_more"` + NextCursor string `json:"next_cursor"` +} + +func auditAPIKey(id, name, prefix, kind string, revoked pgtype.Timestamptz) AuditAPIKey { + key := AuditAPIKey{ID: id, Name: name, Prefix: prefix, Kind: kind} + if revoked.Valid { + value := revoked.Time + key.RevokedAt = &value + } + return key +} + +func (s *Store) GetResourceOwners(ctx context.Context, tenantID, resourceType string, resourceIDs []string) ([]ResourceOwner, error) { + tenant, err := parseID(tenantID) + if err != nil { + return nil, err + } + if !ValidAuditResourceType(resourceType) || len(resourceIDs) < 1 || len(resourceIDs) > 100 { + return nil, fmt.Errorf("%w: invalid owner lookup", ErrInvalidInput) + } + for _, id := range resourceIDs { + if !auditText(id, 256, true) { + return nil, fmt.Errorf("%w: invalid resource ID", ErrInvalidInput) + } + } + rows, err := s.queries.GetResourceOwners(ctx, sqlc.GetResourceOwnersParams{TenantID: tenant, ResourceType: resourceType, Column3: resourceIDs}) + if err != nil { + return nil, err + } + keys := make(map[string]AuditAPIKey, len(rows)) + for _, row := range rows { + keys[row.ResourceID] = auditAPIKey(row.KeyID, row.KeyName, row.KeyPrefix, row.KeyKind, row.RevokedAt) + } + result := make([]ResourceOwner, 0, len(resourceIDs)) + for _, id := range resourceIDs { + owner := ResourceOwner{ResourceID: id} + if key, ok := keys[id]; ok { + owner.APIKey = &key + } + result = append(result, owner) + } + return result, nil +} + +type writeAuditCursor struct{ ID, Scope string } + +func auditCursorScope(tenant string, filter WriteOperationFilter) string { + filter.After = "" + filter.Limit = 0 + if filter.CreatedAfter != nil { + value := filter.CreatedAfter.UTC() + filter.CreatedAfter = &value + } + if filter.CreatedBefore != nil { + value := filter.CreatedBefore.UTC() + filter.CreatedBefore = &value + } + encoded, _ := json.Marshal(struct { + Tenant string + Filter WriteOperationFilter + }{tenant, filter}) + hash := sha256.Sum256(encoded) + return hex.EncodeToString(hash[:]) +} + +func auditTimestamp(value *time.Time) pgtype.Timestamptz { + if value == nil { + return pgtype.Timestamptz{} + } + return pgtype.Timestamptz{Time: *value, Valid: true} +} + +func (s *Store) ListWriteOperations(ctx context.Context, tenantID string, filter WriteOperationFilter) (WriteOperationPage, error) { + empty := WriteOperationPage{} + tenant, err := parseID(tenantID) + if err != nil { + return empty, err + } + if filter.Limit == 0 { + filter.Limit = 20 + } + if filter.Limit < 1 || filter.Limit > 100 || filter.ResourceType != "" && !ValidAuditResourceType(filter.ResourceType) || !auditText(filter.KeyID, 128, false) || !auditText(filter.ResourceID, 256, false) || filter.CreatedAfter != nil && filter.CreatedBefore != nil && !filter.CreatedAfter.Before(*filter.CreatedBefore) { + return empty, fmt.Errorf("%w: invalid write operation filter", ErrInvalidInput) + } + scope := auditCursorScope(uuid.UUID(tenant.Bytes).String(), filter) + params := sqlc.ListWriteOperationsParams{TenantID: tenant, KeyID: filter.KeyID, ResourceType: filter.ResourceType, ResourceID: filter.ResourceID, CreatedAfter: auditTimestamp(filter.CreatedAfter), CreatedBefore: auditTimestamp(filter.CreatedBefore), PageLimit: int32(filter.Limit + 1), AfterID: pgtype.UUID{Valid: true}} + if filter.After != "" { + encoded, decodeErr := base64.RawURLEncoding.DecodeString(filter.After) + var cursor writeAuditCursor + if len(filter.After) > 1024 || decodeErr != nil || json.Unmarshal(encoded, &cursor) != nil || cursor.Scope != scope { + return empty, fmt.Errorf("%w: invalid write operation cursor", ErrInvalidInput) + } + id, parseErr := parseID(cursor.ID) + if parseErr != nil { + return empty, fmt.Errorf("%w: invalid write operation cursor", ErrInvalidInput) + } + params.AfterTime, err = s.queries.GetWriteAuditCursor(ctx, sqlc.GetWriteAuditCursorParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return empty, fmt.Errorf("%w: invalid write operation cursor", ErrInvalidInput) + } + if err != nil { + return empty, err + } + params.AfterID = id + } + rows, err := s.queries.ListWriteOperations(ctx, params) + if err != nil { + return empty, err + } + page := WriteOperationPage{Data: make([]WriteOperation, 0, min(len(rows), filter.Limit)), HasMore: len(rows) > filter.Limit} + if page.HasMore { + rows = rows[:filter.Limit] + } + for _, row := range rows { + page.Data = append(page.Data, WriteOperation{ID: uuid.UUID(row.ID.Bytes).String(), Action: row.Action, ResourceType: row.ResourceType, ResourceID: row.ResourceID, ParentID: row.ParentID, RequestID: row.RequestID, TraceID: row.TraceID, APIKey: auditAPIKey(row.KeyID, row.KeyName, row.KeyPrefix, row.KeyKind, row.RevokedAt), CreatedAt: row.CreatedAt.Time}) + } + if page.HasMore { + encoded, _ := json.Marshal(writeAuditCursor{ID: page.Data[len(page.Data)-1].ID, Scope: scope}) + page.NextCursor = base64.RawURLEncoding.EncodeToString(encoded) + } + return page, nil +} + +// DeleteExpiredWriteOperations bounds each retention transaction and never removes +// an operation referenced by a genuine creation anchor, even after resource deletion. +func (s *Store) DeleteExpiredWriteOperations(ctx context.Context, olderThan time.Time, limit int) (int64, error) { + if olderThan.IsZero() || limit < 1 || limit > 1000 { + return 0, fmt.Errorf("%w: invalid audit retention batch", ErrInvalidInput) + } + return s.queries.DeleteExpiredWriteOperations(ctx, sqlc.DeleteExpiredWriteOperationsParams{CreatedAt: pgtype.Timestamptz{Time: olderThan, Valid: true}, Limit: int32(limit)}) +} diff --git a/services/agents-api/internal/store/write_audit_resources_test.go b/services/agents-api/internal/store/write_audit_resources_test.go new file mode 100644 index 000000000..f91587f17 --- /dev/null +++ b/services/agents-api/internal/store/write_audit_resources_test.go @@ -0,0 +1,297 @@ +package store + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type resourceAuditMutation struct { + action, kind, parent string + owners int + run func(context.Context) (string, error) +} + +func resourceAuditContext(ctx context.Context, tenant, request string) context.Context { + return writeaudit.WithSource(ctx, writeaudit.Source{ + KeyID: "static:" + strings.Repeat("a", 64), Name: "resource audit fixture", Prefix: "aaaaaaaa", + Kind: "static", TenantID: tenant, RequestID: request, TraceID: "resource-audit-trace", + }) +} + +// A database trigger fails the final audit insertion after each real business +// mutation. Comparing complete tenant rows proves rollback of secret ciphertext, +// version counters, timestamps, cascades, and the source-file large object. +func TestWriteAuditStandaloneResourceTransactions(t *testing.T) { + _, pool := newManagedTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{91}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + ctx := t.Context() + _, err = pool.Exec(ctx, `CREATE FUNCTION reject_resource_audit_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW.request_id = 'reject-resource-audit' THEN RAISE EXCEPTION 'forced audit insertion failure'; END IF; RETURN NEW; END $$; + CREATE TRIGGER reject_resource_audit_fixture BEFORE INSERT ON write_audit_operations FOR EACH ROW EXECUTE FUNCTION reject_resource_audit_fixture()`) + if err != nil { + t.Fatal(err) + } + archive := skillArchive(t, "audit-private-archive") + for _, name := range []string{ + "agent_create", "agent_update", "agent_delete", "template_create", "template_update", "template_delete", + "skill_create", "skill_upload_version", "skill_update_default", "skill_delete", "version_delete", "version_delete_last", + "file_create", "file_delete", "vault_create", "vault_delete", "credential_create", "credential_update", "credential_delete", + "oauth_create", "oauth_update", "oauth_delete", + } { + t.Run(name, func(t *testing.T) { + tenant := uuid.NewString() + mutation := prepareResourceAuditMutation(t, s, tenant, name, archive) + snapshot := func() map[string]string { + result := make(map[string]string) + for _, table := range []string{"agents", "environment_templates", "skills", "skill_versions", "source_files", "vaults", "vault_credentials", "write_audit_operations", "write_audit_owners"} { + query := "SELECT COALESCE(jsonb_agg(to_jsonb(r) ORDER BY to_jsonb(r)::text)::text, '[]') FROM " + pgx.Identifier{table}.Sanitize() + " r WHERE tenant_id=$1" + if table == "vault_credentials" { + query = "SELECT COALESCE(jsonb_agg(to_jsonb(r) ORDER BY r.id)::text, '[]') FROM vault_credentials r JOIN vaults v ON v.id=r.vault_id WHERE v.tenant_id=$1" + } + var value string + if err := pool.QueryRow(ctx, query, tenant).Scan(&value); err != nil { + t.Fatalf("snapshot %s: %v", table, err) + } + result[table] = value + } + return result + } + before, objects := snapshot(), sourceObjectCount(t, pool) + if _, err := mutation.run(resourceAuditContext(ctx, tenant, "reject-resource-audit")); err == nil { + t.Fatal("audit failure was accepted") + } + if !reflect.DeepEqual(before, snapshot()) || objects != sourceObjectCount(t, pool) { + t.Fatal("audit failure left business or audit changes") + } + request := uuid.NewString() + id, err := mutation.run(resourceAuditContext(ctx, tenant, request)) + if err != nil { + t.Fatal(err) + } + var action, kind, gotID string + var parent *string + if err := pool.QueryRow(ctx, `SELECT action,resource_type,resource_id,parent_id FROM write_audit_operations WHERE tenant_id=$1 AND request_id=$2`, tenant, request).Scan(&action, &kind, &gotID, &parent); err != nil { + t.Fatal(err) + } + gotParent := "" + if parent != nil { + gotParent = *parent + } + if action != mutation.action || kind != mutation.kind || gotID != id || gotParent != mutation.parent { + t.Fatalf("wrong operation identity: %s %s %s %s", action, kind, gotID, gotParent) + } + var owners int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM write_audit_owners WHERE tenant_id=$1`, tenant).Scan(&owners); err != nil || owners != mutation.owners { + t.Fatalf("ownership count %d, want %d: %v", owners, mutation.owners, err) + } + rows := snapshot() + for _, table := range []string{"write_audit_operations", "write_audit_owners"} { + for _, secret := range []string{"audit-private-archive", "audit-private-token", "audit-private-replacement"} { + if strings.Contains(rows[table], secret) { + t.Fatal("audit contains secret") + } + } + } + }) + } +} + +func prepareResourceAuditMutation(t *testing.T, s *Store, tenant, name string, archive []byte) resourceAuditMutation { + t.Helper() + ctx := t.Context() + must := func(err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } + } + if strings.HasPrefix(name, "agent_") { + input := CreateAgentInput{Configuration: json.RawMessage(`{"model":"fixture"}`)} + if name == "agent_create" { + return resourceAuditMutation{action: "create", kind: "agent", owners: 1, run: func(ctx context.Context) (string, error) { v, e := s.CreateAgent(ctx, tenant, input); return v.ID, e }} + } + a, err := s.CreateAgent(ctx, tenant, input) + must(err) + if name == "agent_update" { + return resourceAuditMutation{action: "update", kind: "agent", run: func(ctx context.Context) (string, error) { + v, e := s.UpdateAgent(ctx, tenant, a.ID, UpdateAgentInput{Configuration: json.RawMessage(`{"model":"replacement"}`)}) + return v.ID, e + }} + } + return resourceAuditMutation{action: "delete", kind: "agent", run: func(ctx context.Context) (string, error) { return s.DeleteAgent(ctx, tenant, a.ID) }} + } + if strings.HasPrefix(name, "template_") { + if name == "template_create" { + return resourceAuditMutation{action: "create", kind: "environment_template", owners: 1, run: func(ctx context.Context) (string, error) { + v, e := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{}) + return v.ID, e + }} + } + v, err := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{}) + must(err) + if name == "template_update" { + return resourceAuditMutation{action: "update", kind: "environment_template", run: func(ctx context.Context) (string, error) { + label := "replacement" + v, e := s.UpdateEnvironmentTemplate(ctx, tenant, v.ID, EnvironmentTemplateInput{SetName: true, Name: &label}) + return v.ID, e + }} + } + return resourceAuditMutation{action: "delete", kind: "environment_template", run: func(ctx context.Context) (string, error) { return s.DeleteEnvironmentTemplate(ctx, tenant, v.ID) }} + } + if strings.HasPrefix(name, "skill_") || strings.HasPrefix(name, "version_") { + if name == "skill_create" { + return resourceAuditMutation{action: "create", kind: "skill", owners: 2, run: func(ctx context.Context) (string, error) { v, e := s.CreateSkill(ctx, tenant, archive); return v.ID, e }} + } + v, err := s.CreateSkill(ctx, tenant, archive) + must(err) + if name == "skill_upload_version" { + return resourceAuditMutation{action: "upload_version", kind: "skill_version", parent: v.ID, owners: 1, run: func(ctx context.Context) (string, error) { + v, e := s.CreateSkillVersion(ctx, tenant, v.ID, archive, true) + return v.ID, e + }} + } + if name == "skill_delete" { + return resourceAuditMutation{action: "delete", kind: "skill", run: func(ctx context.Context) (string, error) { return v.ID, s.DeleteSkill(ctx, tenant, v.ID) }} + } + if name == "version_delete_last" { + return resourceAuditMutation{action: "delete", kind: "skill_version", parent: v.ID, run: func(ctx context.Context) (string, error) { + v, e := s.DeleteSkillVersion(ctx, tenant, v.ID, "1") + return v.ID, e + }} + } + _, err = s.CreateSkillVersion(ctx, tenant, v.ID, archive, false) + must(err) + if name == "skill_update_default" { + return resourceAuditMutation{action: "update_default_version", kind: "skill", run: func(ctx context.Context) (string, error) { + v, e := s.UpdateSkillDefault(ctx, tenant, v.ID, "2") + return v.ID, e + }} + } + return resourceAuditMutation{action: "delete", kind: "skill_version", parent: v.ID, run: func(ctx context.Context) (string, error) { + v, e := s.DeleteSkillVersion(ctx, tenant, v.ID, "2") + return v.ID, e + }} + } + if strings.HasPrefix(name, "file_") { + if name == "file_create" { + return resourceAuditMutation{action: "create", kind: "file", owners: 1, run: func(ctx context.Context) (string, error) { + v, e := s.CreateSourceFile(ctx, tenant, uploadSource([]byte("audit-private-token"))) + return v.ID, e + }} + } + v, err := s.CreateSourceFile(ctx, tenant, uploadSource([]byte("audit-private-token"))) + must(err) + return resourceAuditMutation{action: "delete", kind: "file", run: func(ctx context.Context) (string, error) { return v.ID, s.DeleteSourceFile(ctx, tenant, v.ID) }} + } + if name == "vault_create" { + return resourceAuditMutation{action: "create", kind: "vault", owners: 1, run: func(ctx context.Context) (string, error) { + v, e := s.CreateVault(ctx, tenant, CreateVaultInput{}) + return v.ID, e + }} + } + vault, err := s.CreateVault(ctx, tenant, CreateVaultInput{}) + must(err) + static := CreateStaticCredentialInput{Name: "fixture", MCPServerURL: "https://mcp.example/", Token: "audit-private-token"} + if name == "credential_create" { + return resourceAuditMutation{action: "create", kind: "credential", parent: vault.ID, owners: 1, run: func(ctx context.Context) (string, error) { + v, e := s.CreateStaticCredential(ctx, tenant, vault.ID, static) + return v.ID, e + }} + } + if strings.HasPrefix(name, "oauth_") { + input := CreateOAuthCredentialInput{Name: "fixture", MCPServerURL: static.MCPServerURL, AccessToken: static.Token} + if name == "oauth_create" { + return resourceAuditMutation{action: "create", kind: "credential", parent: vault.ID, owners: 1, run: func(ctx context.Context) (string, error) { + v, e := s.CreateOAuthCredential(ctx, tenant, vault.ID, input) + return v.ID, e + }} + } + v, err := s.CreateOAuthCredential(ctx, tenant, vault.ID, input) + must(err) + if name == "oauth_update" { + return resourceAuditMutation{action: "update", kind: "credential", parent: vault.ID, run: func(ctx context.Context) (string, error) { + token := "audit-private-replacement" + v, e := s.UpdateOAuthCredential(ctx, tenant, vault.ID, v.ID, UpdateOAuthCredentialInput{AccessToken: &token}) + return v.ID, e + }} + } + return resourceAuditMutation{action: "delete", kind: "credential", parent: vault.ID, run: func(ctx context.Context) (string, error) { return s.DeleteCredential(ctx, tenant, vault.ID, v.ID) }} + } + v, err := s.CreateStaticCredential(ctx, tenant, vault.ID, static) + must(err) + if name == "vault_delete" { + return resourceAuditMutation{action: "delete", kind: "vault", run: func(ctx context.Context) (string, error) { return s.DeleteVault(ctx, tenant, vault.ID) }} + } + if name == "credential_update" { + return resourceAuditMutation{action: "update", kind: "credential", parent: vault.ID, run: func(ctx context.Context) (string, error) { + v, e := s.UpdateStaticCredential(ctx, tenant, vault.ID, v.ID, UpdateStaticCredentialInput{Token: "audit-private-replacement"}) + return v.ID, e + }} + } + return resourceAuditMutation{action: "delete", kind: "credential", parent: vault.ID, run: func(ctx context.Context) (string, error) { return s.DeleteCredential(ctx, tenant, vault.ID, v.ID) }} +} + +func TestWriteAuditResourceReadsFailuresAndStableOwnership(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + first, err := s.CreateAgent(resourceAuditContext(t.Context(), tenant, uuid.NewString()), tenant, CreateAgentInput{Configuration: json.RawMessage(`{"model":"fixture"}`)}) + if err != nil { + t.Fatal(err) + } + var before string + if err := pool.QueryRow(t.Context(), `SELECT to_jsonb(o)::text FROM write_audit_owners o WHERE tenant_id=$1 AND resource_id=$2`, tenant, first.ID).Scan(&before); err != nil { + t.Fatal(err) + } + readCtx := resourceAuditContext(t.Context(), tenant, uuid.NewString()) + if _, err := s.GetAgent(readCtx, tenant, first.ID); err != nil { + t.Fatal(err) + } + if _, err := s.UpdateAgent(readCtx, tenant, uuid.NewString(), UpdateAgentInput{}); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + other, _ := writeaudit.FromContext(resourceAuditContext(t.Context(), tenant, uuid.NewString())) + other.KeyID, other.Prefix = "static:"+strings.Repeat("b", 64), "bbbbbbbb" + if _, err := s.UpdateAgent(writeaudit.WithSource(t.Context(), other), tenant, first.ID, UpdateAgentInput{}); err != nil { + t.Fatal(err) + } + if _, err := s.DeleteAgent(resourceAuditContext(t.Context(), tenant, uuid.NewString()), tenant, first.ID); err != nil { + t.Fatal(err) + } + var after string + var count int + if err := pool.QueryRow(t.Context(), `SELECT to_jsonb(o)::text FROM write_audit_owners o WHERE tenant_id=$1 AND resource_id=$2`, tenant, first.ID).Scan(&after); err != nil || after != before { + t.Fatal("creator changed or disappeared", err) + } + if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM write_audit_operations WHERE tenant_id=$1`, tenant).Scan(&count); err != nil || count != 3 { + t.Fatal("read or failure audit", count, err) + } +} + +// A public mutation owns its transaction, so inject database failures through +// the connection configuration rather than replacing a Store query wrapper. +func readOnlyResourceStore(t *testing.T, pool *pgxpool.Pool) *Store { + t.Helper() + config := pool.Config().Copy() + config.ConnConfig.RuntimeParams["default_transaction_read_only"] = "on" + readOnly, err := pgxpool.NewWithConfig(t.Context(), config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(readOnly.Close) + return New(readOnly) +} diff --git a/services/agents-api/internal/store/write_audit_test.go b/services/agents-api/internal/store/write_audit_test.go new file mode 100644 index 000000000..92bb3a702 --- /dev/null +++ b/services/agents-api/internal/store/write_audit_test.go @@ -0,0 +1,257 @@ +package store + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func auditTestSource(tenant string) writeaudit.Source { + digest := projectKeyDigest(uuid.NewString()) + return writeaudit.Source{KeyID: "static:" + digest, Prefix: digest[:8], Name: "test key", Kind: "static", TenantID: tenant, RequestID: uuid.NewString(), TraceID: uuid.NewString()} +} + +func auditTestRecord(t *testing.T, s *Store, source writeaudit.Source, action, kind, id string, created ...AuditResource) { + t.Helper() + ctx := writeaudit.WithSource(t.Context(), source) + if err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + return recordWriteAudit(ctx, s.queries.WithTx(tx), source.TenantID, action, kind, id, "", created...) + }); err != nil { + t.Fatal(err) + } +} + +func TestWriteAuditAtomicCommitAndRollback(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + source := auditTestSource(tenant) + for _, failure := range []string{"", "after_audit", "invalid_source", "database_audit_failure"} { + t.Run(failure, func(t *testing.T) { + id := uuid.NewString() + source.RequestID = uuid.NewString() + if failure == "invalid_source" { + source.TraceID = "" + } else { + source.TraceID = uuid.NewString() + } + ctx := writeaudit.WithSource(t.Context(), source) + err := pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + tenantUUID, _ := parseID(tenant) + _, err := q.CreateAgent(ctx, sqlc.CreateAgentParams{ID: pgtype.UUID{Bytes: uuid.MustParse(id), Valid: true}, TenantID: tenantUUID, Metadata: []byte("{}"), Configuration: []byte("{}")}) + if err != nil { + return err + } + if failure == "database_audit_failure" { + // This transaction-local constraint deliberately rejects the audit insert. + if _, err := tx.Exec(ctx, "ALTER TABLE write_audit_operations ADD CONSTRAINT audit_test_failure CHECK (false) NOT VALID"); err != nil { + return err + } + } + if err := recordWriteAudit(ctx, q, tenant, "create", "agent", id, "", AuditResource{Type: "agent", ID: id}); err != nil { + return err + } + if failure == "after_audit" { + return errors.New("business failure after audit") + } + return nil + }) + if (err != nil) != (failure != "") { + t.Fatalf("commit result: %v", err) + } + _, agentErr := s.GetAgent(t.Context(), tenant, id) + if failure == "" && agentErr != nil || failure != "" && !errors.Is(agentErr, ErrNotFound) { + t.Fatalf("business rollback: %v", agentErr) + } + page, err := s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{ResourceID: id}) + if err != nil { + t.Fatal(err) + } + want := 0 + if failure == "" { + want = 1 + } + if len(page.Data) != want { + t.Fatalf("audit count %d want %d", len(page.Data), want) + } + owners, err := s.GetResourceOwners(t.Context(), tenant, "agent", []string{id}) + if err != nil || (owners[0].APIKey != nil) != (failure == "") { + t.Fatalf("ownership rollback: %+v %v", owners, err) + } + }) + } + // A context without authenticated provenance is an intentional internal write. + if err := recordWriteAudit(context.Background(), nil, tenant, "create", "agent", uuid.NewString(), ""); err != nil { + t.Fatal(err) + } +} + +func TestWriteAuditOwnersIdentityReplayAndRevocation(t *testing.T) { + s, _ := testStore(t) + principal := projectKeyPrincipal() + tenant := principal.TenantID + a := auditTestSource(tenant) + id, implicit := uuid.NewString(), uuid.NewString() + auditTestRecord(t, s, a, "create", "session", id, AuditResource{Type: "session", ID: id}, AuditResource{Type: "environment", ID: implicit, ParentID: id}) + // Same request may reach a commit receipt twice but cannot create another owner. + replayID := uuid.NewString() + auditTestRecord(t, s, a, "create", "session", id, AuditResource{Type: "session", ID: replayID}) + b := auditTestSource(tenant) + b.Kind = "console" + auditTestRecord(t, s, b, "update", "session", id) + owners, err := s.GetResourceOwners(t.Context(), tenant, "session", []string{replayID, id, id, "historical"}) + if err != nil || len(owners) != 4 || owners[0].APIKey != nil || owners[1].APIKey.ID != a.KeyID || owners[2].APIKey.ID != a.KeyID || owners[3].APIKey != nil { + t.Fatalf("owners %+v: %v", owners, err) + } + implicitOwners, err := s.GetResourceOwners(t.Context(), tenant, "environment", []string{implicit}) + if err != nil || implicitOwners[0].APIKey.ID != a.KeyID { + t.Fatalf("implicit owner: %+v %v", implicitOwners, err) + } + foreign, err := s.GetResourceOwners(t.Context(), uuid.NewString(), "session", []string{id}) + if err != nil || foreign[0].APIKey != nil { + t.Fatalf("foreign owner: %+v %v", foreign, err) + } + page, err := s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{ResourceID: id}) + if err != nil || len(page.Data) != 2 || page.Data[0].APIKey.Kind != "console" || page.Data[1].APIKey.ID != a.KeyID { + t.Fatalf("request dedup or key identity: %+v %v", page, err) + } + binding := projectKeyDigest("audit issuer") + issued, err := s.CreateProjectAPIKey(t.Context(), binding, principal, uuid.NewString(), "issued key") + if err != nil { + t.Fatal(err) + } + c := auditTestSource(tenant) + c.KeyID, c.Name, c.Prefix, c.Kind = issued.ID, issued.Name, issued.Prefix, "issued" + fileID := "file_" + uuid.NewString() + auditTestRecord(t, s, c, "create", "file", fileID, AuditResource{Type: "file", ID: fileID}) + if err := s.RevokeProjectAPIKey(t.Context(), binding, principal, issued.ID); err != nil { + t.Fatal(err) + } + revoked, err := s.GetResourceOwners(t.Context(), tenant, "file", []string{fileID}) + if err != nil || revoked[0].APIKey.RevokedAt == nil || revoked[0].APIKey.Name != issued.Name { + t.Fatalf("revocation metadata: %+v %v", revoked, err) + } + page, err = s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{KeyID: c.KeyID}) + if err != nil || len(page.Data) != 1 || page.Data[0].APIKey.RevokedAt == nil { + t.Fatalf("history revocation: %+v %v", page, err) + } +} + +func TestWriteAuditCursorFiltersAndRetention(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + source := auditTestSource(tenant) + agent, err := s.CreateAgent(t.Context(), tenant, CreateAgentInput{Configuration: []byte("{}")}) + if err != nil { + t.Fatal(err) + } + id := agent.ID + auditTestRecord(t, s, source, "create", "agent", id, AuditResource{Type: "agent", ID: id}) + for i := 0; i < 4; i++ { + source.RequestID = uuid.NewString() + auditTestRecord(t, s, source, "update", "agent", id) + } + // Equal timestamps exercise the ID tie breaker, independent of insertion order. + stamp := time.Date(1800, 1, 1, 0, 0, 0, 0, time.UTC) + if _, err := pool.Exec(t.Context(), "UPDATE write_audit_operations SET created_at=$1 WHERE tenant_id=$2", stamp, tenant); err != nil { + t.Fatal(err) + } + first, err := s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{Limit: 2}) + if err != nil || len(first.Data) != 2 || !first.HasMore || first.NextCursor == "" { + t.Fatalf("first %+v %v", first, err) + } + seen := map[string]bool{} + page := first + for { + for _, row := range page.Data { + if seen[row.ID] { + t.Fatal("duplicate cursor item") + } + seen[row.ID] = true + } + if !page.HasMore { + break + } + page, err = s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{Limit: 2, After: page.NextCursor}) + if err != nil { + t.Fatal(err) + } + } + if len(seen) != 5 { + t.Fatalf("lost rows %d", len(seen)) + } + for _, filter := range []WriteOperationFilter{{Limit: 2, After: first.NextCursor, KeyID: "different"}, {After: "malformed"}} { + if _, err := s.ListWriteOperations(t.Context(), tenant, filter); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("cursor filter: %v", err) + } + } + if _, err := s.ListWriteOperations(t.Context(), uuid.NewString(), WriteOperationFilter{After: first.NextCursor}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("cross tenant cursor: %v", err) + } + for _, filter := range []WriteOperationFilter{{CreatedBefore: &stamp}, {KeyID: "missing"}, {ResourceType: "file"}, {ResourceID: "missing"}} { + page, err := s.ListWriteOperations(t.Context(), tenant, filter) + if err != nil || len(page.Data) != 0 { + t.Fatalf("filter %+v: %+v %v", filter, page, err) + } + } + inclusive, err := s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{CreatedAfter: &stamp}) + if err != nil || len(inclusive.Data) != 5 { + t.Fatalf("inclusive lower bound: %+v %v", inclusive, err) + } + cutoff := stamp.Add(time.Hour) + n, err := s.DeleteExpiredWriteOperations(t.Context(), cutoff, 2) + if err != nil || n != 2 { + t.Fatalf("bounded retention %d %v", n, err) + } + n, err = s.DeleteExpiredWriteOperations(t.Context(), cutoff, 1000) + if err != nil || n != 2 { + t.Fatalf("remaining retention %d %v", n, err) + } + page, err = s.ListWriteOperations(t.Context(), tenant, WriteOperationFilter{}) + if err != nil || len(page.Data) != 1 || page.Data[0].Action != "create" { + t.Fatalf("creator retention %+v %v", page, err) + } + // Deleting the business resource cannot cascade through provenance. + if _, err := pool.Exec(t.Context(), "DELETE FROM agents WHERE tenant_id=$1 AND id=$2", tenant, id); err != nil { + t.Fatal(err) + } + owners, err := s.GetResourceOwners(t.Context(), tenant, "agent", []string{id}) + if err != nil || owners[0].APIKey == nil { + t.Fatalf("deleted creator %+v %v", owners, err) + } +} + +func TestWriteAuditSourceValidation(t *testing.T) { + valid := auditTestSource(uuid.NewString()) + for _, field := range []string{"tenant", "key", "prefix", "kind", "request", "trace", "name"} { + source := valid + switch field { + case "tenant": + source.TenantID = uuid.NewString() + case "key": + source.KeyID = "static:abcd" + case "prefix": + source.Prefix = "bad" + case "kind": + source.Kind = "unknown" + case "request": + source.RequestID = "" + case "trace": + source.TraceID = "" + case "name": + source.Name = strings.Repeat("x", 81) + } + ctx := writeaudit.WithSource(t.Context(), source) + if err := recordWriteAudit(ctx, nil, valid.TenantID, "create", "agent", "resource", ""); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("%s accepted: %v", field, err) + } + } +} diff --git a/services/agents-api/internal/writeaudit/context.go b/services/agents-api/internal/writeaudit/context.go new file mode 100644 index 000000000..58d1fa648 --- /dev/null +++ b/services/agents-api/internal/writeaudit/context.go @@ -0,0 +1,28 @@ +// Package writeaudit carries authenticated, non-secret request provenance to +// business transactions. It does not authorize requests or replace principals. +package writeaudit + +import "context" + +// Source contains no bearer token, payload, file path or native credentials. +// RequestID is server-generated; TraceID may be shared by related requests. +type Source struct { + KeyID string `json:"key_id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + Kind string `json:"kind"` + TenantID string `json:"tenant_id"` + RequestID string `json:"request_id"` + TraceID string `json:"trace_id"` +} + +type sourceKey struct{} + +func WithSource(ctx context.Context, source Source) context.Context { + return context.WithValue(ctx, sourceKey{}, source) +} + +func FromContext(ctx context.Context) (Source, bool) { + source, ok := ctx.Value(sourceKey{}).(Source) + return source, ok +} diff --git a/services/agents-api/migrations/000064_write_audit.sql b/services/agents-api/migrations/000064_write_audit.sql new file mode 100644 index 000000000..9456502f3 --- /dev/null +++ b/services/agents-api/migrations/000064_write_audit.sql @@ -0,0 +1,37 @@ +-- +goose Up +CREATE TABLE write_audit_operations ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + key_id text NOT NULL, + key_name text NOT NULL, + key_prefix text NOT NULL, + key_kind text NOT NULL CHECK (key_kind IN ('static', 'issued', 'console')), + action text NOT NULL, + resource_type text NOT NULL, + resource_id text NOT NULL, + parent_id text NOT NULL DEFAULT '', + request_id text NOT NULL, + trace_id text NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (tenant_id, request_id), + UNIQUE (tenant_id, id) +); +CREATE INDEX write_audit_operations_history ON write_audit_operations (tenant_id, created_at DESC, id DESC); +CREATE INDEX write_audit_operations_key_history ON write_audit_operations (tenant_id, key_id, created_at DESC, id DESC); +CREATE INDEX write_audit_operations_resource_history ON write_audit_operations (tenant_id, resource_type, resource_id, created_at DESC, id DESC); +CREATE INDEX write_audit_operations_expiry ON write_audit_operations (created_at, id); + +CREATE TABLE write_audit_owners ( + tenant_id uuid NOT NULL, + resource_type text NOT NULL, + resource_id text NOT NULL, + parent_id text NOT NULL DEFAULT '', + operation_id uuid NOT NULL, + PRIMARY KEY (tenant_id, resource_type, resource_id), + FOREIGN KEY (tenant_id, operation_id) REFERENCES write_audit_operations (tenant_id, id) +); +CREATE INDEX write_audit_owners_operation ON write_audit_owners (operation_id); + +-- +goose Down +DROP TABLE write_audit_owners; +DROP TABLE write_audit_operations; diff --git a/services/agents-api/migrations/000065_environment_file_write_audit.sql b/services/agents-api/migrations/000065_environment_file_write_audit.sql new file mode 100644 index 000000000..3f355099d --- /dev/null +++ b/services/agents-api/migrations/000065_environment_file_write_audit.sql @@ -0,0 +1,8 @@ +-- +goose Up +-- Safe request identity survives native execution and request detachment. +ALTER TABLE environment_file_writes ADD COLUMN audit_source jsonb; +ALTER TABLE environment_file_writes ADD CONSTRAINT environment_file_write_audit_source_object + CHECK (audit_source IS NULL OR jsonb_typeof(audit_source) = 'object'); + +-- +goose Down +ALTER TABLE environment_file_writes DROP COLUMN audit_source;