From 83ed7d62c567eed8a6c353494a69dbc32ebb5792 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:22:00 -0300 Subject: [PATCH] feat(api): admin agent instances, CORS and authz coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifteenth and last layer of the agent remote-configuration stack (split from #465): GET /api/admin/agents/{id}/instances lists an agent's reporting instances with derived status, sync status and per-status counts, and GET …/instances/{instanceId} returns one with its redacted base/effective config; CORS allows If-Match/If-None-Match and exposes ETag for the UI; the server recovers handler panics as 500s; builtin/Cedar authz matrices over every admin agent-config route, and the Cedar docs. The tree now matches #465. Co-Authored-By: Claude Opus 5.5 --- docs/authz-oss-cedar.md | 16 +- docs/docs.go | 375 ++++++++++++++++++ docs/swagger.json | 375 ++++++++++++++++++ docs/swagger.yaml | 255 ++++++++++++ internal/api/handler/agent_config.go | 218 ++++++++++ .../agent_config_admin_integration_test.go | 245 ++++++++++++ internal/api/server.go | 5 +- 7 files changed, 1485 insertions(+), 4 deletions(-) diff --git a/docs/authz-oss-cedar.md b/docs/authz-oss-cedar.md index 626c8471..8f5e4ada 100644 --- a/docs/authz-oss-cedar.md +++ b/docs/authz-oss-cedar.md @@ -13,7 +13,7 @@ class). Attribute-rich (ABAC/ReBAC) policy is an Enterprise / bring-your-own-PDP | Driver | What it is | | --------- | ---------------------------------------------------------------------- | -| `builtin` | **Default.** Pre-authz rules: authenticated = allowed, admin via SSO groups. Zero behavior change. | +| `builtin` | **Default.** Pre-authz rules: authenticated = allowed, admin via SSO groups. The `agent` resource (agent reads and remote configuration) also requires the admin check for users; agent service accounts may only register, ingest and sync. | | `cedar` | Embedded Cedar RBAC against the bundled role policies (this document). | | `authzen` | Delegate every decision to a remote AuthZen-compliant PDP (bring your own). | @@ -28,7 +28,7 @@ CCF_AUTHZ_CEDAR_POLICY_DIR=/etc/ccf/policies # optional operator .cedar files ## Bundled roles -Four fixed global roles plus the agent service role, defined in the manifest's `roles:` block +Four fixed global roles plus the agent service role (the manifest lists every role, e.g. `ssp-subscriber`), defined in the manifest's `roles:` block (`internal/authz/manifest.yaml`) and compiled to Cedar policies at startup: | Role | Grants | @@ -37,7 +37,17 @@ Four fixed global roles plus the agent service role, defined in the manifest's ` | `contributor` | Author content (OSCAL docs, risk/poam register, workflows, dashboards, evidence); read everything; no admin. | | `auditor` | Read everything; record evidence; maintain the risk/poam register. | | `viewer` | Read everything; no writes. | -| `agent` | Service accounts: ingest evidence/heartbeats, register. | +| `agent` | Service accounts: ingest evidence/heartbeats, register, sync their remote configuration. | + +viewer, auditor and contributor read agent configurations through `"*": [read]`. This is +intended; narrow it with an operator `forbid` policy if needed. The instance reports +(`base`/`effective`) are redacted. `GET …/config` and `GET …/config/revisions/{rev}` return +the **overlay** verbatim only to callers that also hold `agent:configure` (editors), so it +can be edited; every other `agent:read` holder gets it redacted with the report rules +(secret-like keys and values become `••••`). If the configure check cannot be evaluated, +the overlay is redacted. Still, prefer `${env:NAME}` placeholders over literal secrets in +an overlay: editors and every stored revision keep the literal. Deleting an agent deletes +its revisions. Previewing an overlay (`POST …/config/preview`) needs `agent:configure`. Cedar is **deny-by-default**: a subject with no assigned role is denied every request. diff --git a/docs/docs.go b/docs/docs.go index 8fe36db0..e29e6b63 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -526,6 +526,129 @@ const docTemplate = `{ ] } }, + "/admin/agents/{id}/instances": { + "get": { + "description": "Summaries of the instances that reported or heartbeated with a config digest, with the derived status (pending and unknown are server-derived), sync status, staleness and counts. Base/effective configs are on the instance detail route.", + "produces": [ + "application/json" + ], + "tags": [ + "Agent Configuration" + ], + "summary": "List an agent's instances", + "parameters": [ + { + "type": "string", + "description": "Agent ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.agentInstanceListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/agents/{id}/instances/{instanceId}": { + "get": { + "description": "The instance's summary plus its redacted base and effective configs (snake_case). instanceId is the agent-side instance UUID.", + "produces": [ + "application/json" + ], + "tags": [ + "Agent Configuration" + ], + "summary": "Get one agent instance", + "parameters": [ + { + "type": "string", + "description": "Agent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Instance ID", + "name": "instanceId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_agentInstanceDetail" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/ai-diagnostics/runs": { "get": { "description": "Lists dashboard suggestion runs across all SSPs, newest first, with optional status and SSP filters.", @@ -37365,6 +37488,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-handler_agentInstanceDetail": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/handler.agentInstanceDetail" + } + ] + } + } + }, "handler.GenericDataResponse-handler_bulkControlLinkResponse": { "type": "object", "properties": { @@ -39429,6 +39565,245 @@ const docTemplate = `{ } } }, + "handler.agentInstanceCounts": { + "type": "object", + "properties": { + "failed": { + "type": "integer" + }, + "fresh": { + "type": "integer" + }, + "in-sync": { + "type": "integer" + }, + "out-of-sync": { + "type": "integer" + }, + "pending": { + "type": "integer" + }, + "rejected": { + "type": "integer" + }, + "stale": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, + "handler.agentInstanceDetail": { + "type": "object", + "properties": { + "agent-version": { + "type": "string" + }, + "applied-revision": { + "type": "integer" + }, + "attempted-revision": { + "type": "integer" + }, + "base": { + "type": "object" + }, + "daemon": { + "type": "boolean" + }, + "effective": { + "type": "object" + }, + "effective-digest": { + "type": "string" + }, + "error": { + "type": "string" + }, + "first-seen-at": { + "type": "string" + }, + "heartbeat-config-revision": { + "type": "integer" + }, + "hostname": { + "type": "string" + }, + "instance-id": { + "type": "string" + }, + "last-seen-at": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "plugins": { + "description": "Plugins are the reported plugins and the agent library each was built with (R76).\nEmpty until an agent that reports them does.", + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.PluginReport" + } + }, + "reason": { + "type": "string" + }, + "remote-config": { + "type": "object" + }, + "report-stale": { + "description": "heartbeat digest != reported digest", + "type": "boolean" + }, + "reported-at": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "status": { + "description": "applied|rejected|failed|not-applicable|pending|unknown", + "type": "string" + }, + "sync-status": { + "description": "in-sync|out-of-sync|not-applicable|unknown", + "type": "string" + }, + "truncated": { + "type": "boolean" + }, + "unsafe": { + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.Change" + } + }, + "warnings": { + "description": "R41", + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.FieldError" + } + } + } + }, + "handler.agentInstanceListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.agentInstanceSummary" + } + }, + "meta": { + "$ref": "#/definitions/handler.agentInstancesMeta" + } + } + }, + "handler.agentInstanceSummary": { + "type": "object", + "properties": { + "agent-version": { + "type": "string" + }, + "applied-revision": { + "type": "integer" + }, + "attempted-revision": { + "type": "integer" + }, + "daemon": { + "type": "boolean" + }, + "effective-digest": { + "type": "string" + }, + "error": { + "type": "string" + }, + "first-seen-at": { + "type": "string" + }, + "heartbeat-config-revision": { + "type": "integer" + }, + "hostname": { + "type": "string" + }, + "instance-id": { + "type": "string" + }, + "last-seen-at": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "plugins": { + "description": "Plugins are the reported plugins and the agent library each was built with (R76).\nEmpty until an agent that reports them does.", + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.PluginReport" + } + }, + "reason": { + "type": "string" + }, + "remote-config": { + "type": "object" + }, + "report-stale": { + "description": "heartbeat digest != reported digest", + "type": "boolean" + }, + "reported-at": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "status": { + "description": "applied|rejected|failed|not-applicable|pending|unknown", + "type": "string" + }, + "sync-status": { + "description": "in-sync|out-of-sync|not-applicable|unknown", + "type": "string" + }, + "truncated": { + "type": "boolean" + }, + "unsafe": { + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.Change" + } + }, + "warnings": { + "description": "R41", + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.FieldError" + } + } + } + }, + "handler.agentInstancesMeta": { + "type": "object", + "properties": { + "counts": { + "$ref": "#/definitions/handler.agentInstanceCounts" + }, + "desired-revision": { + "type": "integer" + } + } + }, "handler.attachFilterResponsibilityRequest": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index 10fd8bd8..0ff7d8cf 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -520,6 +520,129 @@ ] } }, + "/admin/agents/{id}/instances": { + "get": { + "description": "Summaries of the instances that reported or heartbeated with a config digest, with the derived status (pending and unknown are server-derived), sync status, staleness and counts. Base/effective configs are on the instance detail route.", + "produces": [ + "application/json" + ], + "tags": [ + "Agent Configuration" + ], + "summary": "List an agent's instances", + "parameters": [ + { + "type": "string", + "description": "Agent ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.agentInstanceListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/agents/{id}/instances/{instanceId}": { + "get": { + "description": "The instance's summary plus its redacted base and effective configs (snake_case). instanceId is the agent-side instance UUID.", + "produces": [ + "application/json" + ], + "tags": [ + "Agent Configuration" + ], + "summary": "Get one agent instance", + "parameters": [ + { + "type": "string", + "description": "Agent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Instance ID", + "name": "instanceId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_agentInstanceDetail" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/ai-diagnostics/runs": { "get": { "description": "Lists dashboard suggestion runs across all SSPs, newest first, with optional status and SSP filters.", @@ -37359,6 +37482,19 @@ } } }, + "handler.GenericDataResponse-handler_agentInstanceDetail": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/handler.agentInstanceDetail" + } + ] + } + } + }, "handler.GenericDataResponse-handler_bulkControlLinkResponse": { "type": "object", "properties": { @@ -39423,6 +39559,245 @@ } } }, + "handler.agentInstanceCounts": { + "type": "object", + "properties": { + "failed": { + "type": "integer" + }, + "fresh": { + "type": "integer" + }, + "in-sync": { + "type": "integer" + }, + "out-of-sync": { + "type": "integer" + }, + "pending": { + "type": "integer" + }, + "rejected": { + "type": "integer" + }, + "stale": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, + "handler.agentInstanceDetail": { + "type": "object", + "properties": { + "agent-version": { + "type": "string" + }, + "applied-revision": { + "type": "integer" + }, + "attempted-revision": { + "type": "integer" + }, + "base": { + "type": "object" + }, + "daemon": { + "type": "boolean" + }, + "effective": { + "type": "object" + }, + "effective-digest": { + "type": "string" + }, + "error": { + "type": "string" + }, + "first-seen-at": { + "type": "string" + }, + "heartbeat-config-revision": { + "type": "integer" + }, + "hostname": { + "type": "string" + }, + "instance-id": { + "type": "string" + }, + "last-seen-at": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "plugins": { + "description": "Plugins are the reported plugins and the agent library each was built with (R76).\nEmpty until an agent that reports them does.", + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.PluginReport" + } + }, + "reason": { + "type": "string" + }, + "remote-config": { + "type": "object" + }, + "report-stale": { + "description": "heartbeat digest != reported digest", + "type": "boolean" + }, + "reported-at": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "status": { + "description": "applied|rejected|failed|not-applicable|pending|unknown", + "type": "string" + }, + "sync-status": { + "description": "in-sync|out-of-sync|not-applicable|unknown", + "type": "string" + }, + "truncated": { + "type": "boolean" + }, + "unsafe": { + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.Change" + } + }, + "warnings": { + "description": "R41", + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.FieldError" + } + } + } + }, + "handler.agentInstanceListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.agentInstanceSummary" + } + }, + "meta": { + "$ref": "#/definitions/handler.agentInstancesMeta" + } + } + }, + "handler.agentInstanceSummary": { + "type": "object", + "properties": { + "agent-version": { + "type": "string" + }, + "applied-revision": { + "type": "integer" + }, + "attempted-revision": { + "type": "integer" + }, + "daemon": { + "type": "boolean" + }, + "effective-digest": { + "type": "string" + }, + "error": { + "type": "string" + }, + "first-seen-at": { + "type": "string" + }, + "heartbeat-config-revision": { + "type": "integer" + }, + "hostname": { + "type": "string" + }, + "instance-id": { + "type": "string" + }, + "last-seen-at": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "plugins": { + "description": "Plugins are the reported plugins and the agent library each was built with (R76).\nEmpty until an agent that reports them does.", + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.PluginReport" + } + }, + "reason": { + "type": "string" + }, + "remote-config": { + "type": "object" + }, + "report-stale": { + "description": "heartbeat digest != reported digest", + "type": "boolean" + }, + "reported-at": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "status": { + "description": "applied|rejected|failed|not-applicable|pending|unknown", + "type": "string" + }, + "sync-status": { + "description": "in-sync|out-of-sync|not-applicable|unknown", + "type": "string" + }, + "truncated": { + "type": "boolean" + }, + "unsafe": { + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.Change" + } + }, + "warnings": { + "description": "R41", + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.FieldError" + } + } + } + }, + "handler.agentInstancesMeta": { + "type": "object", + "properties": { + "counts": { + "$ref": "#/definitions/handler.agentInstanceCounts" + }, + "desired-revision": { + "type": "integer" + } + } + }, "handler.attachFilterResponsibilityRequest": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 5f75190e..ea2a4f0b 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1669,6 +1669,13 @@ definitions: - $ref: '#/definitions/handler.agentConfigRevisionResponse' description: Wrapped response data type: object + handler.GenericDataResponse-handler_agentInstanceDetail: + properties: + data: + allOf: + - $ref: '#/definitions/handler.agentInstanceDetail' + description: Wrapped response data + type: object handler.GenericDataResponse-handler_bulkControlLinkResponse: properties: data: @@ -2889,6 +2896,171 @@ definitions: revision: type: integer type: object + handler.agentInstanceCounts: + properties: + failed: + type: integer + fresh: + type: integer + in-sync: + type: integer + out-of-sync: + type: integer + pending: + type: integer + rejected: + type: integer + stale: + type: integer + total: + type: integer + unknown: + type: integer + type: object + handler.agentInstanceDetail: + properties: + agent-version: + type: string + applied-revision: + type: integer + attempted-revision: + type: integer + base: + type: object + daemon: + type: boolean + effective: + type: object + effective-digest: + type: string + error: + type: string + first-seen-at: + type: string + heartbeat-config-revision: + type: integer + hostname: + type: string + instance-id: + type: string + last-seen-at: + type: string + mode: + type: string + plugins: + description: |- + Plugins are the reported plugins and the agent library each was built with (R76). + Empty until an agent that reports them does. + items: + $ref: '#/definitions/agentconfig.PluginReport' + type: array + reason: + type: string + remote-config: + type: object + report-stale: + description: heartbeat digest != reported digest + type: boolean + reported-at: + type: string + stale: + type: boolean + status: + description: applied|rejected|failed|not-applicable|pending|unknown + type: string + sync-status: + description: in-sync|out-of-sync|not-applicable|unknown + type: string + truncated: + type: boolean + unsafe: + items: + $ref: '#/definitions/agentconfig.Change' + type: array + warnings: + description: R41 + items: + $ref: '#/definitions/agentconfig.FieldError' + type: array + type: object + handler.agentInstanceListResponse: + properties: + data: + items: + $ref: '#/definitions/handler.agentInstanceSummary' + type: array + meta: + $ref: '#/definitions/handler.agentInstancesMeta' + type: object + handler.agentInstanceSummary: + properties: + agent-version: + type: string + applied-revision: + type: integer + attempted-revision: + type: integer + daemon: + type: boolean + effective-digest: + type: string + error: + type: string + first-seen-at: + type: string + heartbeat-config-revision: + type: integer + hostname: + type: string + instance-id: + type: string + last-seen-at: + type: string + mode: + type: string + plugins: + description: |- + Plugins are the reported plugins and the agent library each was built with (R76). + Empty until an agent that reports them does. + items: + $ref: '#/definitions/agentconfig.PluginReport' + type: array + reason: + type: string + remote-config: + type: object + report-stale: + description: heartbeat digest != reported digest + type: boolean + reported-at: + type: string + stale: + type: boolean + status: + description: applied|rejected|failed|not-applicable|pending|unknown + type: string + sync-status: + description: in-sync|out-of-sync|not-applicable|unknown + type: string + truncated: + type: boolean + unsafe: + items: + $ref: '#/definitions/agentconfig.Change' + type: array + warnings: + description: R41 + items: + $ref: '#/definitions/agentconfig.FieldError' + type: array + type: object + handler.agentInstancesMeta: + properties: + counts: + $ref: '#/definitions/handler.agentInstanceCounts' + desired-revision: + type: integer + type: object handler.attachFilterResponsibilityRequest: properties: controlId: @@ -13079,6 +13251,89 @@ paths: summary: Revert an agent's configuration to an earlier revision tags: - Agent Configuration + /admin/agents/{id}/instances: + get: + description: Summaries of the instances that reported or heartbeated with a + config digest, with the derived status (pending and unknown are server-derived), + sync status, staleness and counts. Base/effective configs are on the instance + detail route. + parameters: + - description: Agent ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.agentInstanceListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List an agent's instances + tags: + - Agent Configuration + /admin/agents/{id}/instances/{instanceId}: + get: + description: The instance's summary plus its redacted base and effective configs + (snake_case). instanceId is the agent-side instance UUID. + parameters: + - description: Agent ID + in: path + name: id + required: true + type: string + - description: Instance ID + in: path + name: instanceId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_agentInstanceDetail' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Get one agent instance + tags: + - Agent Configuration /admin/ai-diagnostics/runs: get: description: Lists dashboard suggestion runs across all SSPs, newest first, diff --git a/internal/api/handler/agent_config.go b/internal/api/handler/agent_config.go index 1ee6c1c1..ce39b2a0 100644 --- a/internal/api/handler/agent_config.go +++ b/internal/api/handler/agent_config.go @@ -63,6 +63,8 @@ func (h *AgentConfigHandler) Register(g *echo.Group, guard middleware.ResourceGu g.GET("/:id/config/revisions", h.ListRevisions, guard.Read()) g.GET("/:id/config/revisions/:rev", h.GetRevision, guard.Read()) g.POST("/:id/config/revisions/:rev/revert", h.Revert, write, echomiddleware.BodyLimit(agentConfigBodyLimitStr)) + g.GET("/:id/instances", h.ListInstances, guard.Read()) + g.GET("/:id/instances/:instanceId", h.GetInstance, guard.Read()) } // ---- DTOs (A4.4) ---- @@ -78,6 +80,62 @@ type agentConfigRevisionResponse struct { RevertOf *int64 `json:"revert-of"` } +type agentInstanceSummary struct { + InstanceID string `json:"instance-id"` + Hostname *string `json:"hostname"` + AgentVersion *string `json:"agent-version"` + Mode string `json:"mode"` + Daemon *bool `json:"daemon"` + FirstSeenAt time.Time `json:"first-seen-at"` + LastSeenAt time.Time `json:"last-seen-at"` + ReportedAt *time.Time `json:"reported-at"` + Stale bool `json:"stale"` + AppliedRevision *int64 `json:"applied-revision"` + AttemptedRevision *int64 `json:"attempted-revision"` + Status string `json:"status"` // applied|rejected|failed|not-applicable|pending|unknown + Reason *string `json:"reason"` + Error *string `json:"error"` + Truncated bool `json:"truncated"` + SyncStatus string `json:"sync-status"` // in-sync|out-of-sync|not-applicable|unknown + EffectiveDigest *string `json:"effective-digest"` + HeartbeatConfigRevision *int64 `json:"heartbeat-config-revision"` + ReportStale bool `json:"report-stale"` // heartbeat digest != reported digest + RemoteConfig json.RawMessage `json:"remote-config,omitempty" swaggertype:"object"` + Unsafe []agentconfig.Change `json:"unsafe"` + Warnings []agentconfig.FieldError `json:"warnings"` // R41 + // Plugins are the reported plugins and the agent library each was built with (R76). + // Empty until an agent that reports them does. + Plugins []agentconfig.PluginReport `json:"plugins"` +} + +type agentInstanceDetail struct { + agentInstanceSummary + Base json.RawMessage `json:"base" swaggertype:"object"` + Effective json.RawMessage `json:"effective" swaggertype:"object"` +} + +type agentInstanceCounts struct { + Total int `json:"total"` + Fresh int `json:"fresh"` + Stale int `json:"stale"` + InSync int `json:"in-sync"` + OutOfSync int `json:"out-of-sync"` + Pending int `json:"pending"` + Rejected int `json:"rejected"` + Failed int `json:"failed"` + Unknown int `json:"unknown"` +} + +type agentInstancesMeta struct { + DesiredRevision int64 `json:"desired-revision"` + Counts agentInstanceCounts `json:"counts"` +} + +type agentInstanceListResponse struct { + Data []agentInstanceSummary `json:"data"` + Meta agentInstancesMeta `json:"meta"` +} + type configPreviewResponse struct { DesiredRevision int64 `json:"desired-revision"` Standalone bool `json:"standalone"` @@ -624,8 +682,161 @@ func (h *AgentConfigHandler) GetRevision(ctx echo.Context) error { return ctx.JSON(http.StatusOK, GenericDataResponse[agentConfigRevisionResponse]{Data: resp}) } +// ListInstances godoc +// +// @Summary List an agent's instances +// @Description Summaries of the instances that reported or heartbeated with a config digest, with the derived status (pending and unknown are server-derived), sync status, staleness and counts. Base/effective configs are on the instance detail route. +// @Tags Agent Configuration +// @Produce json +// @Param id path string true "Agent ID" +// @Success 200 {object} handler.agentInstanceListResponse +// @Failure 400 {object} api.Error +// @Failure 403 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/agents/{id}/instances [get] +func (h *AgentConfigHandler) ListInstances(ctx echo.Context) error { + agent, errResp := h.resolveAgent(ctx) + if agent == nil { + return errResp + } + reqCtx := ctx.Request().Context() + desired, err := h.svc.CurrentRevisionNumber(reqCtx, *agent.ID) + if err != nil { + return h.internalError(ctx, "load agent configuration", err) + } + instances, err := h.svc.ListInstances(reqCtx, *agent.ID) + if err != nil { + return h.internalError(ctx, "list instances", err) + } + now := h.svc.Now() + resp := agentInstanceListResponse{ + Data: make([]agentInstanceSummary, 0, len(instances)), + Meta: agentInstancesMeta{DesiredRevision: desired}, + } + counts := &resp.Meta.Counts + for _, inst := range instances { + s := h.instanceSummary(inst, desired, now) + resp.Data = append(resp.Data, s) + counts.Total++ + if s.Stale { + counts.Stale++ + } else { + counts.Fresh++ + } + switch s.SyncStatus { + case agentcfg.SyncInSync: + counts.InSync++ + case agentcfg.SyncOutOfSync: + counts.OutOfSync++ + } + switch s.Status { + case agentconfig.StatusPending: + counts.Pending++ + case agentconfig.StatusRejected: + counts.Rejected++ + case agentconfig.StatusFailed: + counts.Failed++ + case agentconfig.StatusUnknown: + counts.Unknown++ + } + } + return ctx.JSON(http.StatusOK, resp) +} + +// GetInstance godoc +// +// @Summary Get one agent instance +// @Description The instance's summary plus its redacted base and effective configs (snake_case). instanceId is the agent-side instance UUID. +// @Tags Agent Configuration +// @Produce json +// @Param id path string true "Agent ID" +// @Param instanceId path string true "Instance ID" +// @Success 200 {object} handler.GenericDataResponse[handler.agentInstanceDetail] +// @Failure 400 {object} api.Error +// @Failure 403 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/agents/{id}/instances/{instanceId} [get] +func (h *AgentConfigHandler) GetInstance(ctx echo.Context) error { + agent, errResp := h.resolveAgent(ctx) + if agent == nil { + return errResp + } + instanceID, err := uuid.Parse(ctx.Param("instanceId")) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.InvalidUUID()) + } + reqCtx := ctx.Request().Context() + inst, err := h.svc.GetInstance(reqCtx, *agent.ID, instanceID) + if errors.Is(err, agentcfg.ErrNotFound) { + return ctx.JSON(http.StatusNotFound, api.NotFoundCustomMsg("instance not found")) + } + if err != nil { + return h.internalError(ctx, "load instance", err) + } + desired, err := h.svc.CurrentRevisionNumber(reqCtx, *agent.ID) + if err != nil { + return h.internalError(ctx, "load agent configuration", err) + } + detail := agentInstanceDetail{ + agentInstanceSummary: h.instanceSummary(*inst, desired, h.svc.Now()), + Base: rawOrNull(inst.BaseConfig), + Effective: rawOrNull(inst.EffectiveConfig), + } + return ctx.JSON(http.StatusOK, GenericDataResponse[agentInstanceDetail]{Data: detail}) +} + // ---- helpers ---- +func (h *AgentConfigHandler) instanceSummary(inst relational.AgentInstance, desired int64, now time.Time) agentInstanceSummary { + s := agentInstanceSummary{ + InstanceID: inst.InstanceID.String(), + Hostname: inst.Hostname, + AgentVersion: inst.AgentVersion, + Mode: inst.Mode, + Daemon: inst.Daemon, + FirstSeenAt: inst.FirstSeenAt.UTC(), + LastSeenAt: inst.LastSeenAt.UTC(), + ReportedAt: inst.ReportedAt, + Stale: agentcfg.IsStale(inst, now, h.svc.Settings()), + AppliedRevision: inst.AppliedRevision, + AttemptedRevision: inst.AttemptedRevision, + Status: agentcfg.DeriveStatus(inst, desired), + Reason: inst.ApplyReason, + Error: inst.ApplyError, + Truncated: inst.Truncated, + SyncStatus: agentcfg.DeriveSyncStatus(inst, desired), + EffectiveDigest: inst.EffectiveDigest, + HeartbeatConfigRevision: inst.HeartbeatConfigRevision, + ReportStale: inst.HeartbeatConfigDigest != nil && inst.EffectiveDigest != nil && + *inst.HeartbeatConfigDigest != *inst.EffectiveDigest, + Unsafe: []agentconfig.Change{}, + Warnings: []agentconfig.FieldError{}, + Plugins: []agentconfig.PluginReport{}, + } + if len(inst.RemoteConfig) > 0 && string(inst.RemoteConfig) != "null" { + s.RemoteConfig = json.RawMessage(inst.RemoteConfig) + } + h.decodeColumn(&inst, "unsafe_changes", inst.UnsafeChanges, &s.Unsafe) + h.decodeColumn(&inst, "warnings", inst.Warnings, &s.Warnings) + h.decodeColumn(&inst, "plugins", inst.Plugins, &s.Plugins) + return s +} + +// decodeColumn decodes a stored JSON column into dst, leaving dst untouched when the column +// is empty or does not decode (logged). +func (h *AgentConfigHandler) decodeColumn(inst *relational.AgentInstance, column string, raw []byte, dst any) { + if len(raw) == 0 || string(raw) == "null" { + return + } + if err := json.Unmarshal(raw, dst); err != nil { + h.sugar.Warnw("Failed to decode agent instance column", "instanceID", inst.InstanceID, "column", column, "error", err) + } +} + // resolveAgent loads :id. On failure it returns nil and the already-written error response. func (h *AgentConfigHandler) resolveAgent(ctx echo.Context) (*relational.Agent, error) { id, err := uuid.Parse(ctx.Param("id")) @@ -782,6 +993,13 @@ func compactJSON(raw json.RawMessage) (json.RawMessage, error) { return buf.Bytes(), nil } +func rawOrNull(raw []byte) json.RawMessage { + if len(raw) == 0 { + return json.RawMessage("null") + } + return json.RawMessage(raw) +} + // nonNil returns an empty (non-nil) slice for nil, so JSON renders [] rather than null. func nonNil[T any](s []T) []T { if s == nil { diff --git a/internal/api/handler/agent_config_admin_integration_test.go b/internal/api/handler/agent_config_admin_integration_test.go index 57db4818..56d9247e 100644 --- a/internal/api/handler/agent_config_admin_integration_test.go +++ b/internal/api/handler/agent_config_admin_integration_test.go @@ -17,8 +17,10 @@ import ( "github.com/compliance-framework/api/internal/api/middleware" "github.com/compliance-framework/api/internal/authn" "github.com/compliance-framework/api/internal/authz" + "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/service/relational" "github.com/compliance-framework/api/internal/service/relational/agentcfg" + "github.com/compliance-framework/api/internal/service/sso" "github.com/compliance-framework/api/internal/tests" "github.com/compliance-framework/api/pkg/agentconfig" "github.com/google/uuid" @@ -211,6 +213,8 @@ func acaData[T any](s *AgentConfigAdminIntegrationSuite, rec *httptest.ResponseR // ---- instance helpers ---- +func acaI64(v int64) *int64 { return &v } + // acaBase returns a reported base (redacted as the agent does: Redact clears the client secret) in the given mode with the vendor ssh plugin // plus any extra plugins. func acaBase(mode string, extra map[string]*agentconfig.Plugin) json.RawMessage { @@ -801,6 +805,132 @@ func (s *AgentConfigAdminIntegrationSuite) TestFileOriginErrorsDoNotBlock() { // ---- Instances ---- +func (s *AgentConfigAdminIntegrationSuite) TestInstances() { + ctx := context.Background() + agentID := *s.agent.ID + + // Desired revision 1. + s.save(`"0"`, `{"verbosity":1}`, 1) + + inSync := s.report(agentID, agentconfig.ModeApplySafe, func(r *agentconfig.Report) { + r.AppliedRevision = acaI64(1) + r.Plugins = []agentconfig.PluginReport{{Name: "ssh", Source: acaVendorPlugin, LibVersion: "v0.7.1"}, {Name: "local"}} + }) + pending := s.report(agentID, agentconfig.ModeApplySafe, nil) // applied nil, attempted nil + rejected := s.report(agentID, agentconfig.ModeApplyAll, func(r *agentconfig.Report) { + r.AttemptedRevision = acaI64(1) + r.Status = agentconfig.StatusRejected + r.Reason = agentconfig.ReasonDownloadFailed + }) + s.makeStale(rejected, time.Hour) + reportOnly := s.report(agentID, agentconfig.ModeReport, nil) + heartbeatOnly := uuid.New() + digest := acaDigest + s.Require().NoError(s.svc.TouchFromHeartbeat(ctx, agentID, nil, heartbeatOnly, acaI64(0), &digest)) + // A newer heartbeat digest than the reported one marks the report stale. + other := acaOtherDigest + s.Require().NoError(s.svc.TouchFromHeartbeat(ctx, agentID, nil, inSync, acaI64(1), &other)) + + // Another agent's instance never shows up. + otherAgent, err := s.CreateAgent("other-agent") + s.Require().NoError(err) + foreign := s.report(*otherAgent.ID, agentconfig.ModeApplySafe, nil) + + rec := s.call(http.MethodGet, s.path("/instances"), nil) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + var list agentInstanceListResponse + s.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &list)) + s.Equal(int64(1), list.Meta.DesiredRevision) + s.Equal(agentInstanceCounts{ + Total: 5, Fresh: 4, Stale: 1, + InSync: 1, OutOfSync: 2, + Pending: 1, Rejected: 1, Failed: 0, Unknown: 1, + }, list.Meta.Counts) + + byID := map[string]agentInstanceSummary{} + for _, inst := range list.Data { + byID[inst.InstanceID] = inst + } + s.NotContains(byID, foreign.String()) + + st := byID[inSync.String()] + s.Equal(agentconfig.StatusApplied, st.Status) + s.Equal(agentcfg.SyncInSync, st.SyncStatus) + s.True(st.ReportStale) + s.Equal([]agentconfig.PluginReport{{Name: "ssh", Source: acaVendorPlugin, LibVersion: "v0.7.1"}, {Name: "local"}}, st.Plugins, "R76: listed with the summary") + s.Require().NotNil(st.HeartbeatConfigRevision) + s.Equal(int64(1), *st.HeartbeatConfigRevision) + s.NotEmpty(st.RemoteConfig) + + st = byID[pending.String()] + s.Equal([]agentconfig.PluginReport{}, st.Plugins, "an agent that does not report plugins") + s.Equal(agentconfig.StatusPending, st.Status) + s.Equal(agentcfg.SyncOutOfSync, st.SyncStatus) + s.False(st.Stale) + + st = byID[rejected.String()] + s.Equal(agentconfig.StatusRejected, st.Status) + s.True(st.Stale) + s.Require().NotNil(st.Reason) + s.Equal(agentconfig.ReasonDownloadFailed, *st.Reason) + + st = byID[reportOnly.String()] + s.Equal(agentconfig.StatusNotApplicable, st.Status) + s.Equal(agentcfg.SyncNotApplicable, st.SyncStatus) + + st = byID[heartbeatOnly.String()] + s.Equal(agentconfig.StatusUnknown, st.Status) + s.Equal(agentcfg.SyncUnknown, st.SyncStatus) + s.Nil(st.ReportedAt) + + // Summaries carry [] for list fields and no base/effective. + var rawList struct { + Data []map[string]json.RawMessage `json:"data"` + } + s.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &rawList)) + for _, item := range rawList.Data { + s.NotContains(item, "base") + s.NotContains(item, "effective") + for _, k := range []string{"unsafe", "warnings"} { + s.JSONEq(`[]`, string(item[k]), k) + } + s.NotContains(item, "policy-errors") + s.Contains(item, "plugins") + } + + // Detail: base and effective. + rec = s.call(http.MethodGet, s.path("/instances/"+inSync.String()), nil) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + detail := acaData[agentInstanceDetail](s, rec) + s.Equal(inSync.String(), detail.InstanceID) + s.Contains(string(detail.Base), acaVendorPlugin) + s.Contains(string(detail.Effective), acaVendorPolicy) + s.Equal([]agentconfig.PluginReport{{Name: "ssh", Source: acaVendorPlugin, LibVersion: "v0.7.1"}, {Name: "local"}}, detail.Plugins, "R76") + + // A heartbeat-only instance has null configs and [] plugins. + rec = s.call(http.MethodGet, s.path("/instances/"+heartbeatOnly.String()), nil) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + var rawDetail struct { + Data map[string]json.RawMessage `json:"data"` + } + s.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &rawDetail)) + s.JSONEq(`null`, string(rawDetail.Data["base"])) + s.NotContains(rawDetail.Data, "policy-bundles") + s.JSONEq(`[]`, string(rawDetail.Data["plugins"])) + + // Another agent's instance => 404; a bad instance id => 400. + rec = s.call(http.MethodGet, s.path("/instances/"+foreign.String()), nil) + s.Equal(http.StatusNotFound, rec.Code, rec.Body.String()) + rec = s.call(http.MethodGet, s.path("/instances/not-a-uuid"), nil) + s.Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) +} + +func (s *AgentConfigAdminIntegrationSuite) TestInstancesEmpty() { + rec := s.call(http.MethodGet, s.path("/instances"), nil) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + s.JSONEq(`{"data":[],"meta":{"desired-revision":0,"counts":{"total":0,"fresh":0,"stale":0,"in-sync":0,"out-of-sync":0,"pending":0,"rejected":0,"failed":0,"unknown":0}}}`, rec.Body.String()) +} + // ---- Agent deletion ---- // Deleting an agent removes its instances and its revisions (the purge path for an overlay @@ -831,8 +961,100 @@ func (s *AgentConfigAdminIntegrationSuite) TestDeleteAgentRemovesInstancesAndRev // ---- Builtin authz (R39) ---- +func (s *AgentConfigAdminIntegrationSuite) TestBuiltinSSOUserNeedsAdminGroup() { + original := s.Config.SSO + s.Config.SSO = &config.SSOConfig{ + Enabled: true, + Providers: map[string]config.SSOProviderConfig{ + "test": {Name: "test", RequiredAdminGroups: []string{"ccf-admins"}}, + }, + } + defer func() { s.Config.SSO = original }() + + ssoToken := func(email string, groups []string) string { + user, token := s.userToken(email, "sso", "") + s.Require().NoError(s.DB.Create(&relational.SSOUserLink{ + UserID: user.ID.String(), + Provider: "test", + ExternalID: email, + Email: email, + Groups: sso.SerializeStringArray(groups), + LastSync: time.Now(), + }).Error) + return token + } + member := ssoToken("sso-member@example.com", []string{"developers"}) + admin := ssoToken("sso-admin@example.com", []string{"ccf-admins"}) + + denied := []struct { + method, path string + body []byte + headers []string + }{ + {http.MethodGet, "/api/admin/agents", nil, nil}, + {http.MethodGet, s.path(""), nil, nil}, + {http.MethodGet, s.path("/config"), nil, nil}, + {http.MethodPut, s.path("/config"), acaPutBody(`{"verbosity":1}`), []string{"If-Match", `"0"`}}, + {http.MethodPost, s.path("/config/preview"), acaPutBody(`{"verbosity":1}`), nil}, + {http.MethodGet, s.path("/config/revisions"), nil, nil}, + {http.MethodGet, s.path("/instances"), nil, nil}, + } + for _, tc := range denied { + rec := s.send(s.server, member, tc.method, tc.path, tc.body, tc.headers...) + s.Equal(http.StatusForbidden, rec.Code, "%s %s: %s", tc.method, tc.path, rec.Body.String()) + } + + // An SSO user in the admin group and the password user are allowed. + for _, token := range []string{admin, s.token} { + rec := s.send(s.server, token, http.MethodGet, "/api/admin/agents", nil) + s.Equal(http.StatusOK, rec.Code, rec.Body.String()) + rec = s.send(s.server, token, http.MethodGet, s.path("/config"), nil) + s.Equal(http.StatusOK, rec.Code, rec.Body.String()) + } + rec := s.send(s.server, admin, http.MethodPut, s.path("/config"), acaPutBody(`{"verbosity":1}`), "If-Match", `"0"`) + s.Equal(http.StatusCreated, rec.Code, rec.Body.String()) + s.Equal(int64(1), s.revisionCount(*s.agent.ID)) +} + // ---- Cedar authz (R40) ---- +func (s *AgentConfigAdminIntegrationSuite) TestCedarViewer() { + _, viewer := s.userToken("viewer@example.com", "", "viewer") + srv := s.cedarServer() + + allowed := []struct{ method, path string }{ + {http.MethodGet, "/api/admin/agents"}, + {http.MethodGet, s.path("")}, + {http.MethodGet, s.path("/config")}, + {http.MethodGet, s.path("/config/revisions")}, + {http.MethodGet, s.path("/instances")}, + } + for _, tc := range allowed { + rec := s.send(srv, viewer, tc.method, tc.path, nil) + s.Equal(http.StatusOK, rec.Code, "%s %s: %s", tc.method, tc.path, rec.Body.String()) + } + + denied := []struct { + method, path string + body []byte + headers []string + }{ + {http.MethodPost, s.path("/config/preview"), acaPutBody(`{"verbosity":1}`), nil}, + {http.MethodPut, s.path("/config"), acaPutBody(`{"verbosity":1}`), []string{"If-Match", `"0"`}}, + {http.MethodPost, s.path("/config/revisions/1/revert"), nil, []string{"If-Match", `"0"`}}, + {http.MethodPost, "/api/admin/agents", []byte(`{"name":"viewer-agent"}`), nil}, + {http.MethodPut, s.path(""), []byte(`{"name":"renamed"}`), nil}, + {http.MethodDelete, s.path(""), nil, nil}, + {http.MethodGet, s.path("/keys"), nil, nil}, + {http.MethodPost, s.path("/keys"), []byte(`{"never-expires":true}`), nil}, + } + for _, tc := range denied { + rec := s.send(srv, viewer, tc.method, tc.path, tc.body, tc.headers...) + s.Equal(http.StatusForbidden, rec.Code, "%s %s: %s", tc.method, tc.path, rec.Body.String()) + } + s.Equal(int64(0), s.revisionCount(*s.agent.ID)) +} + // Overlays are verbatim for agent:configure holders and redacted for read-only callers. func (s *AgentConfigAdminIntegrationSuite) TestCedarOverlayRedactedForReaders() { overlay := `{"plugins":{"ssh":{"config":{"password":"hunter2","host":"db","pass_ref":"${env:SSH_PASS}"},"policy_data":{"api_token":"t0k3n","threshold":3}}}}` @@ -911,3 +1133,26 @@ func (s *AgentConfigAdminIntegrationSuite) TestCedarUserWithoutRoleDenied() { } // ---- CORS (R13) ---- + +func (s *AgentConfigAdminIntegrationSuite) TestCORSAllowsIfMatchAndExposesETag() { + const origin = "http://ui.example.com" + original := s.Config.APIAllowedOrigins + s.Config.APIAllowedOrigins = []string{origin} + defer func() { s.Config.APIAllowedOrigins = original }() + srv := s.newServer(nil) + + rec := s.send(srv, "", http.MethodOptions, s.path("/config"), nil, + echo.HeaderOrigin, origin, + echo.HeaderAccessControlRequestMethod, http.MethodPut, + echo.HeaderAccessControlRequestHeaders, "if-match", + ) + s.Require().Equal(http.StatusNoContent, rec.Code, rec.Body.String()) + s.Equal(origin, rec.Header().Get(echo.HeaderAccessControlAllowOrigin)) + s.Contains(strings.ToLower(rec.Header().Get(echo.HeaderAccessControlAllowHeaders)), "if-match") + + rec = s.send(srv, s.token, http.MethodGet, s.path("/config"), nil, echo.HeaderOrigin, origin) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + s.Equal(origin, rec.Header().Get(echo.HeaderAccessControlAllowOrigin)) + s.Contains(rec.Header().Get(echo.HeaderAccessControlExposeHeaders), "ETag") + s.Equal(`"0"`, rec.Header().Get("ETag")) +} diff --git a/internal/api/server.go b/internal/api/server.go index 82b6d27b..267afc52 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -48,9 +48,12 @@ func NewServer(ctx context.Context, s *zap.SugaredLogger, config *config.Config, return nil }, })) + // A handler panic becomes a 500 (logged above) instead of a dropped connection. + e.Use(middleware.Recover()) e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: config.APIAllowedOrigins, - AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization}, + AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization, "If-Match", "If-None-Match"}, + ExposeHeaders: []string{"ETag"}, AllowCredentials: true, })) e.Use(echoprometheus.NewMiddlewareWithConfig(echoprometheus.MiddlewareConfig{