diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a76b7e1c59..a56092c1b4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1327,6 +1327,59 @@ const docTemplate = `{ ] } }, + "/api/v2/agent-firewall/sessions/{id}/logs": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get agent firewall session logs", + "operationId": "get-agent-firewall-session-logs", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Agent firewall session ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Inclusive lower bound on sequence number", + "name": "seq_after", + "in": "query" + }, + { + "type": "integer", + "description": "Exclusive upper bound on sequence number", + "name": "seq_before", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of logs to return (default 100)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.AgentFirewallSessionLogsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/ai/providers": { "get": { "produces": [ @@ -15943,6 +15996,45 @@ const docTemplate = `{ "AgentDisplayModeAlwaysCollapsed" ] }, + "codersdk.AgentFirewallLog": { + "type": "object", + "properties": { + "allowed": { + "type": "boolean" + }, + "captured_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "detail": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "matched_rule": { + "type": "string" + }, + "method": { + "type": "string" + }, + "proto": { + "type": "string" + }, + "sequence_number": { + "type": "integer" + }, + "session_id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.AgentFirewallSession": { "type": "object", "properties": { @@ -15967,6 +16059,17 @@ const docTemplate = `{ } } }, + "codersdk.AgentFirewallSessionLogsResponse": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentFirewallLog" + } + } + } + }, "codersdk.AgentScriptTiming": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7af7ee8340..4bde3bc0c4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1174,6 +1174,55 @@ ] } }, + "/api/v2/agent-firewall/sessions/{id}/logs": { + "get": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get agent firewall session logs", + "operationId": "get-agent-firewall-session-logs", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Agent firewall session ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Inclusive lower bound on sequence number", + "name": "seq_after", + "in": "query" + }, + { + "type": "integer", + "description": "Exclusive upper bound on sequence number", + "name": "seq_before", + "in": "query" + }, + { + "type": "integer", + "description": "Maximum number of logs to return (default 100)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.AgentFirewallSessionLogsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/ai/providers": { "get": { "produces": ["application/json"], @@ -14278,6 +14327,45 @@ "AgentDisplayModeAlwaysCollapsed" ] }, + "codersdk.AgentFirewallLog": { + "type": "object", + "properties": { + "allowed": { + "type": "boolean" + }, + "captured_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "detail": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "matched_rule": { + "type": "string" + }, + "method": { + "type": "string" + }, + "proto": { + "type": "string" + }, + "sequence_number": { + "type": "integer" + }, + "session_id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.AgentFirewallSession": { "type": "object", "properties": { @@ -14302,6 +14390,17 @@ } } }, + "codersdk.AgentFirewallSessionLogsResponse": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentFirewallLog" + } + } + } + }, "codersdk.AgentScriptTiming": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index e45055aab1..c9a76cbab7 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1471,6 +1471,7 @@ func New(options *Options) *API { r.Get("/", api.auditLogs) r.Post("/testgenerate", api.generateFakeAuditLog) }) + r.Route("/files", func(r chi.Router) { r.Use( apiKeyMiddleware, diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 47fbbb717a..eb934e3a92 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1146,8 +1146,8 @@ type sqlcQuerier interface { ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeUserPrompt, error) ListAIGatewayKeys(ctx context.Context) ([]ListAIGatewayKeysRow, error) // Lists boundary logs for a session, sorted by sequence number ascending. - // Supports optional exclusive sequence number bounds (seq_after, seq_before) - // for fetching events between two known interceptions. + // Supports an inclusive lower bound (seq_after) and an exclusive upper bound + // (seq_before) for fetching events between two known interceptions. ListBoundaryLogsBySessionID(ctx context.Context, arg ListBoundaryLogsBySessionIDParams) ([]BoundaryLog, error) // Lists a chat's pinned context resources, ordered deterministically by // source. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 242a601fe5..f8e6589576 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3892,7 +3892,7 @@ FROM boundary_logs WHERE session_id = $1 AND CASE - WHEN $2::int IS NOT NULL THEN sequence_number > $2 + WHEN $2::int IS NOT NULL THEN sequence_number >= $2 ELSE true END AND CASE @@ -3911,8 +3911,8 @@ type ListBoundaryLogsBySessionIDParams struct { } // Lists boundary logs for a session, sorted by sequence number ascending. -// Supports optional exclusive sequence number bounds (seq_after, seq_before) -// for fetching events between two known interceptions. +// Supports an inclusive lower bound (seq_after) and an exclusive upper bound +// (seq_before) for fetching events between two known interceptions. func (q *sqlQuerier) ListBoundaryLogsBySessionID(ctx context.Context, arg ListBoundaryLogsBySessionIDParams) ([]BoundaryLog, error) { rows, err := q.db.QueryContext(ctx, listBoundaryLogsBySessionID, arg.SessionID, diff --git a/coderd/database/queries/boundarylogs.sql b/coderd/database/queries/boundarylogs.sql index c99158d3d0..1bca24744e 100644 --- a/coderd/database/queries/boundarylogs.sql +++ b/coderd/database/queries/boundarylogs.sql @@ -64,14 +64,14 @@ SELECT * FROM boundary_logs WHERE id = @id; -- name: ListBoundaryLogsBySessionID :many -- Lists boundary logs for a session, sorted by sequence number ascending. --- Supports optional exclusive sequence number bounds (seq_after, seq_before) --- for fetching events between two known interceptions. +-- Supports an inclusive lower bound (seq_after) and an exclusive upper bound +-- (seq_before) for fetching events between two known interceptions. SELECT * FROM boundary_logs WHERE session_id = @session_id AND CASE - WHEN sqlc.narg('seq_after')::int IS NOT NULL THEN sequence_number > sqlc.narg('seq_after') + WHEN sqlc.narg('seq_after')::int IS NOT NULL THEN sequence_number >= sqlc.narg('seq_after') ELSE true END AND CASE diff --git a/codersdk/agentfirewall.go b/codersdk/agentfirewall.go index d1900a368a..b8ef4a75a9 100644 --- a/codersdk/agentfirewall.go +++ b/codersdk/agentfirewall.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strconv" "time" "github.com/google/uuid" @@ -32,3 +33,73 @@ func (c *Client) AgentFirewallSessionByID(ctx context.Context, id uuid.UUID) (Ag var session AgentFirewallSession return session, json.NewDecoder(res.Body).Decode(&session) } + +// AgentFirewallLog represents a single audit event from an agent firewall proxy. +type AgentFirewallLog struct { + ID uuid.UUID `json:"id" format:"uuid"` + SessionID uuid.UUID `json:"session_id" format:"uuid"` + SequenceNumber int32 `json:"sequence_number"` + Allowed bool `json:"allowed"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + Proto string `json:"proto"` + Method string `json:"method"` + Detail string `json:"detail"` + MatchedRule *string `json:"matched_rule"` + CapturedAt *time.Time `json:"captured_at,omitempty" format:"date-time"` +} + +// AgentFirewallSessionLogsResponse is the response for +// GET /api/v2/agent-firewall/sessions/{id}/logs. +type AgentFirewallSessionLogsResponse struct { + Results []AgentFirewallLog `json:"results"` +} + +// AgentFirewallSessionLogsParams are query parameters for listing +// agent firewall session logs. +type AgentFirewallSessionLogsParams struct { + // SeqAfter is an inclusive lower bound on sequence_number. + // Only logs with sequence_number >= SeqAfter are returned. + SeqAfter *int64 `json:"seq_after,omitempty"` + // SeqBefore is an exclusive upper bound on sequence_number. + // Only logs with sequence_number < SeqBefore are returned. + SeqBefore *int64 `json:"seq_before,omitempty"` + // Limit caps the number of returned rows. Defaults to 100. + Limit *int32 `json:"limit,omitempty"` +} + +func (p AgentFirewallSessionLogsParams) asRequestOption() RequestOption { + return func(r *http.Request) { + q := r.URL.Query() + if p.SeqAfter != nil { + q.Set("seq_after", strconv.FormatInt(*p.SeqAfter, 10)) + } + if p.SeqBefore != nil { + q.Set("seq_before", strconv.FormatInt(*p.SeqBefore, 10)) + } + if p.Limit != nil { + q.Set("limit", strconv.FormatInt(int64(*p.Limit), 10)) + } + r.URL.RawQuery = q.Encode() + } +} + +// AgentFirewallSessionLogs returns agent firewall audit logs for the +// given session, sorted by sequence number ascending. +func (c *Client) AgentFirewallSessionLogs(ctx context.Context, sessionID uuid.UUID, params AgentFirewallSessionLogsParams) (AgentFirewallSessionLogsResponse, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/agent-firewall/sessions/%s/logs", sessionID), + nil, + params.asRequestOption(), + ) + if err != nil { + return AgentFirewallSessionLogsResponse{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return AgentFirewallSessionLogsResponse{}, ReadBodyAsError(res) + } + + var resp AgentFirewallSessionLogsResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index d2bdf7a811..9fa7e5fb0a 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -125,6 +125,59 @@ curl -X GET http://coder-server:8080/api/v2/agent-firewall/sessions/{id} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get agent firewall session logs + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/agent-firewall/sessions/{id}/logs \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/v2/agent-firewall/sessions/{id}/logs` + +### Parameters + +| Name | In | Type | Required | Description | +|--------------|-------|--------------|----------|------------------------------------------------| +| `id` | path | string(uuid) | true | Agent firewall session ID | +| `seq_after` | query | integer | false | Inclusive lower bound on sequence number | +| `seq_before` | query | integer | false | Exclusive upper bound on sequence number | +| `limit` | query | integer | false | Maximum number of logs to return (default 100) | + +### Example responses + +> 200 Response + +```json +{ + "results": [ + { + "allowed": true, + "captured_at": "2019-08-24T14:15:22Z", + "created_at": "2019-08-24T14:15:22Z", + "detail": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "matched_rule": "string", + "method": "string", + "proto": "string", + "sequence_number": 0, + "session_id": "1ffd059c-17ea-40a8-8aef-70fd0307db82" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.AgentFirewallSessionLogsResponse](schemas.md#codersdkagentfirewallsessionlogsresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## List AI Gateway keys ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 595d110747..044fdf99a7 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1263,6 +1263,38 @@ None |-----------------------------------------------| | `always_collapsed`, `always_expanded`, `auto` | +## codersdk.AgentFirewallLog + +```json +{ + "allowed": true, + "captured_at": "2019-08-24T14:15:22Z", + "created_at": "2019-08-24T14:15:22Z", + "detail": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "matched_rule": "string", + "method": "string", + "proto": "string", + "sequence_number": 0, + "session_id": "1ffd059c-17ea-40a8-8aef-70fd0307db82" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------------|---------|----------|--------------|-------------| +| `allowed` | boolean | false | | | +| `captured_at` | string | false | | | +| `created_at` | string | false | | | +| `detail` | string | false | | | +| `id` | string | false | | | +| `matched_rule` | string | false | | | +| `method` | string | false | | | +| `proto` | string | false | | | +| `sequence_number` | integer | false | | | +| `session_id` | string | false | | | + ## codersdk.AgentFirewallSession ```json @@ -1285,6 +1317,33 @@ None | `started_at` | string | false | | | | `workspace_id` | string | false | | | +## codersdk.AgentFirewallSessionLogsResponse + +```json +{ + "results": [ + { + "allowed": true, + "captured_at": "2019-08-24T14:15:22Z", + "created_at": "2019-08-24T14:15:22Z", + "detail": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "matched_rule": "string", + "method": "string", + "proto": "string", + "sequence_number": 0, + "session_id": "1ffd059c-17ea-40a8-8aef-70fd0307db82" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|-----------------------------------------------------------------|----------|--------------|-------------| +| `results` | array of [codersdk.AgentFirewallLog](#codersdkagentfirewalllog) | false | | | + ## codersdk.AgentScriptTiming ```json diff --git a/enterprise/coderd/agentfirewall.go b/enterprise/coderd/agentfirewall.go index e19bf1caa0..928a99e0d9 100644 --- a/enterprise/coderd/agentfirewall.go +++ b/enterprise/coderd/agentfirewall.go @@ -1,10 +1,14 @@ package coderd import ( + "database/sql" "net/http" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" ) @@ -42,3 +46,93 @@ func (api *API) agentFirewallSessionByID(rw http.ResponseWriter, r *http.Request StartedAt: session.StartedAt, }) } + +// @Summary Get agent firewall session logs +// @ID get-agent-firewall-session-logs +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param id path string true "Agent firewall session ID" format(uuid) +// @Param seq_after query int false "Inclusive lower bound on sequence number" +// @Param seq_before query int false "Exclusive upper bound on sequence number" +// @Param limit query int false "Maximum number of logs to return (default 100)" +// @Success 200 {object} codersdk.AgentFirewallSessionLogsResponse +// @Router /api/v2/agent-firewall/sessions/{id}/logs [get] +func (api *API) agentFirewallSessionLogs(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if !api.Authorize(r, policy.ActionRead, rbac.ResourceBoundaryLog) { + httpapi.ResourceNotFound(rw) + return + } + + sessionID, ok := httpmw.ParseUUIDParam(rw, r, "id") + if !ok { + return + } + + qp := r.URL.Query() + p := httpapi.NewQueryParamParser() + seqAfter := p.Int(qp, 0, "seq_after") + seqBefore := p.Int(qp, 0, "seq_before") + limitOpt := p.PositiveInt32(qp, 0, "limit") + p.ErrorExcessParams(qp) + if len(p.Errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid query parameters.", + Validations: p.Errors, + }) + return + } + + params := database.ListBoundaryLogsBySessionIDParams{ + SessionID: sessionID, + SeqAfter: sql.NullInt32{}, + SeqBefore: sql.NullInt32{}, + LimitOpt: limitOpt, + } + if qp.Has("seq_after") { + params.SeqAfter = sql.NullInt32{Int32: int32(seqAfter), Valid: true} // #nosec G115 - Fits int32 for valid sequence numbers. + } + if qp.Has("seq_before") { + params.SeqBefore = sql.NullInt32{Int32: int32(seqBefore), Valid: true} // #nosec G115 - Fits int32 for valid sequence numbers. + } + + dbLogs, err := api.Database.ListBoundaryLogsBySessionID(ctx, params) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.AgentFirewallSessionLogsResponse{ + Results: agentFirewallLogsFromDB(dbLogs), + }) +} + +// agentFirewallLogsFromDB converts database boundary logs to SDK +// representation. Allowed is derived from MatchedRule being non-NULL. +func agentFirewallLogsFromDB(dbLogs []database.BoundaryLog) []codersdk.AgentFirewallLog { + results := make([]codersdk.AgentFirewallLog, 0, len(dbLogs)) + for _, l := range dbLogs { + bl := codersdk.AgentFirewallLog{ + ID: l.ID, + SessionID: l.SessionID, + SequenceNumber: l.SequenceNumber, + Allowed: l.MatchedRule.Valid, + CreatedAt: l.CreatedAt, + Proto: l.Proto, + Method: l.Method, + Detail: l.Detail, + CapturedAt: &l.CapturedAt, + } + if l.MatchedRule.Valid { + bl.MatchedRule = &l.MatchedRule.String + } + results = append(results, bl) + } + return results +} diff --git a/enterprise/coderd/agentfirewall_test.go b/enterprise/coderd/agentfirewall_test.go index 70d3ed1b32..3bb4a32606 100644 --- a/enterprise/coderd/agentfirewall_test.go +++ b/enterprise/coderd/agentfirewall_test.go @@ -17,7 +17,9 @@ import ( "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" @@ -263,3 +265,293 @@ func TestInsertBoundaryLogs_AgentAuth(t *testing.T) { }) require.Error(t, err, "agent must not insert boundary logs for a different owner") } + +func TestAgentFirewallSessionLogs(t *testing.T) { + t.Parallel() + + type logOpt struct { + SeqNum int32 + Proto string + Method string + Detail string + // Rule is the matched rule. Non-empty means the request was allowed. + Rule string + } + + // Creates a boundary session and returns a helper to insert logs. + setupSession := func(t *testing.T, db database.Store, ownerID, orgID uuid.UUID) (database.BoundarySession, func(opts ...logOpt)) { + t.Helper() + + resp := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: ownerID, + OrganizationID: orgID, + }).WithAgent().Do() + require.NotEmpty(t, resp.Agents, "expected at least one agent") + + session := dbgen.BoundarySession(t, db, database.BoundarySession{ + WorkspaceAgentID: resp.Agents[0].ID, + OwnerID: uuid.NullUUID{UUID: ownerID, Valid: true}, + ConfinedProcessName: "claude-code", + }) + + insertLogs := func(opts ...logOpt) { + t.Helper() + //nolint:gocritic // Test seeding requires system context. + sysCtx := dbauthz.AsSystemRestricted(t.Context()) + + ids := make([]uuid.UUID, len(opts)) + seqNums := make([]int32, len(opts)) + capturedAts := make([]time.Time, len(opts)) + createdAts := make([]time.Time, len(opts)) + protos := make([]string, len(opts)) + methods := make([]string, len(opts)) + details := make([]string, len(opts)) + matchedRules := make([]string, len(opts)) + + now := dbtime.Now() + for i, o := range opts { + ids[i] = uuid.New() + seqNums[i] = o.SeqNum + capturedAts[i] = now + createdAts[i] = now + protos[i] = o.Proto + methods[i] = o.Method + details[i] = o.Detail + matchedRules[i] = o.Rule + } + + _, err := db.InsertBoundaryLogs(sysCtx, database.InsertBoundaryLogsParams{ + ID: ids, + SessionID: session.ID, + OwnerID: ownerID, + SequenceNumber: seqNums, + CapturedAt: capturedAts, + CreatedAt: createdAts, + Proto: protos, + Method: methods, + Detail: details, + MatchedRule: matchedRules, + }) + require.NoError(t, err, "insert boundary logs") + } + return session, insertLogs + } + + // Creates an enterprise client with FeatureBoundary enabled. + newEntClient := func(t *testing.T) (*codersdk.Client, database.Store, codersdk.CreateFirstUserResponse) { + t.Helper() + db, pubsub := dbtestutil.NewDB(t) + client, _, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureBoundary: 1, + }, + }, + }) + return client, db, firstUser + } + + t.Run("OK", func(t *testing.T) { + t.Parallel() + client, db, owner := newEntClient(t) + + session, insertLogs := setupSession(t, db, owner.UserID, owner.OrganizationID) + insertLogs( + logOpt{SeqNum: 0, Proto: "http", Method: "GET", Detail: "https://github.com/coder/coder", Rule: "domain=github.com"}, + logOpt{SeqNum: 1, Proto: "http", Method: "POST", Detail: "https://evil.com/exfil"}, + logOpt{SeqNum: 2, Proto: "http", Method: "POST", Detail: "https://api.anthropic.com/v1/messages", Rule: "domain=api.anthropic.com"}, + ) + + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Testing owner role. + resp, err := client.AgentFirewallSessionLogs(ctx, session.ID, codersdk.AgentFirewallSessionLogsParams{}) + require.NoError(t, err) + require.Len(t, resp.Results, 3) + + require.Equal(t, int32(0), resp.Results[0].SequenceNumber) + require.Equal(t, int32(1), resp.Results[1].SequenceNumber) + require.Equal(t, int32(2), resp.Results[2].SequenceNumber) + + // Allowed request: MatchedRule is non-NULL. + require.True(t, resp.Results[0].Allowed) + require.Equal(t, "GET", resp.Results[0].Method) + require.Equal(t, "https://github.com/coder/coder", resp.Results[0].Detail) + require.NotNil(t, resp.Results[0].MatchedRule) + require.Equal(t, "domain=github.com", *resp.Results[0].MatchedRule) + + // Denied request: no matched rule. + require.False(t, resp.Results[1].Allowed) + require.Equal(t, "POST", resp.Results[1].Method) + require.Equal(t, "https://evil.com/exfil", resp.Results[1].Detail) + require.Nil(t, resp.Results[1].MatchedRule) + + // Second allowed request. + require.True(t, resp.Results[2].Allowed) + require.Equal(t, "POST", resp.Results[2].Method) + require.Equal(t, "https://api.anthropic.com/v1/messages", resp.Results[2].Detail) + require.NotNil(t, resp.Results[2].MatchedRule) + require.Equal(t, "domain=api.anthropic.com", *resp.Results[2].MatchedRule) + }) + + // Table-driven tests for sequence number filtering and limit. + filterTests := []struct { + name string + params codersdk.AgentFirewallSessionLogsParams + wantSeqs []int32 + }{ + { + name: "SeqAfterIncludesBound", + params: codersdk.AgentFirewallSessionLogsParams{SeqAfter: ptr.Ref(int64(0))}, + wantSeqs: []int32{0, 1, 2}, + }, + { + name: "SeqBeforeExcludesBound", + params: codersdk.AgentFirewallSessionLogsParams{SeqBefore: ptr.Ref(int64(2))}, + wantSeqs: []int32{0, 1}, + }, + { + name: "BetweenBoundsInclusiveExclusive", + params: codersdk.AgentFirewallSessionLogsParams{SeqAfter: ptr.Ref(int64(0)), SeqBefore: ptr.Ref(int64(2))}, + wantSeqs: []int32{0, 1}, + }, + { + name: "LimitCapsResults", + params: codersdk.AgentFirewallSessionLogsParams{Limit: ptr.Ref(int32(2))}, + wantSeqs: []int32{0, 1}, + }, + } + for _, tc := range filterTests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + client, db, owner := newEntClient(t) + + session, insertLogs := setupSession(t, db, owner.UserID, owner.OrganizationID) + // Insert in reverse order to prove the endpoint sorts by + // sequence_number regardless of DB insertion order. + insertLogs( + logOpt{SeqNum: 2, Proto: "http", Method: "GET", Detail: "https://c.com", Rule: "domain=c.com"}, + logOpt{SeqNum: 1, Proto: "http", Method: "GET", Detail: "https://b.com", Rule: "domain=b.com"}, + logOpt{SeqNum: 0, Proto: "http", Method: "GET", Detail: "https://a.com", Rule: "domain=a.com"}, + ) + + ctx := testutil.Context(t, testutil.WaitLong) + //nolint:gocritic // Testing owner role. + resp, err := client.AgentFirewallSessionLogs(ctx, session.ID, tc.params) + require.NoError(t, err) + require.Len(t, resp.Results, len(tc.wantSeqs)) + for i, wantSeq := range tc.wantSeqs { + require.Equal(t, wantSeq, resp.Results[i].SequenceNumber) + } + }) + } + + t.Run("BetweenTwoInterceptions", func(t *testing.T) { + t.Parallel() + client, db, owner := newEntClient(t) + + session, insertLogs := setupSession(t, db, owner.UserID, owner.OrganizationID) + insertLogs( + logOpt{SeqNum: 5, Proto: "http", Method: "POST", Detail: "https://api.anthropic.com/v1/messages", Rule: "domain=api.anthropic.com"}, + logOpt{SeqNum: 6, Proto: "http", Method: "GET", Detail: "https://github.com/coder/coder/pulls", Rule: "domain=github.com"}, + logOpt{SeqNum: 7, Proto: "http", Method: "GET", Detail: "https://evil.com/exfil"}, + logOpt{SeqNum: 11, Proto: "http", Method: "POST", Detail: "https://api.anthropic.com/v1/messages", Rule: "domain=api.anthropic.com"}, + logOpt{SeqNum: 12, Proto: "http", Method: "POST", Detail: "https://api.anthropic.com/v1/messages", Rule: "domain=api.anthropic.com"}, + ) + + ctx := testutil.Context(t, testutil.WaitLong) + //nolint:gocritic // Testing owner role. + resp, err := client.AgentFirewallSessionLogs(ctx, session.ID, codersdk.AgentFirewallSessionLogsParams{ + SeqAfter: ptr.Ref(int64(5)), + SeqBefore: ptr.Ref(int64(12)), + }) + require.NoError(t, err) + require.Len(t, resp.Results, 4, "should return events at seq 5, 6, 7, 11") + require.Equal(t, int32(5), resp.Results[0].SequenceNumber) + require.Equal(t, int32(6), resp.Results[1].SequenceNumber) + require.Equal(t, int32(7), resp.Results[2].SequenceNumber) + require.Equal(t, int32(11), resp.Results[3].SequenceNumber) + }) + + t.Run("EmptySession", func(t *testing.T) { + t.Parallel() + client, db, owner := newEntClient(t) + + session, _ := setupSession(t, db, owner.UserID, owner.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + //nolint:gocritic // Testing owner role. + resp, err := client.AgentFirewallSessionLogs(ctx, session.ID, codersdk.AgentFirewallSessionLogsParams{}) + require.NoError(t, err) + require.Empty(t, resp.Results) + }) + + t.Run("NonexistentSession", func(t *testing.T) { + t.Parallel() + client, _, _ := newEntClient(t) + + ctx := testutil.Context(t, testutil.WaitLong) + //nolint:gocritic // Testing owner role. + resp, err := client.AgentFirewallSessionLogs(ctx, uuid.New(), codersdk.AgentFirewallSessionLogsParams{}) + require.NoError(t, err) + require.Empty(t, resp.Results) + }) + + t.Run("MemberDenied", func(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + ownerClient, _, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureBoundary: 1, + }, + }, + }) + + memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + _, err := memberClient.AgentFirewallSessionLogs(ctx, uuid.New(), codersdk.AgentFirewallSessionLogsParams{}) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("AuditorAllowed", func(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + ownerClient, _, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureBoundary: 1, + }, + }, + }) + + auditorClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleAuditor()) + + session, insertLogs := setupSession(t, db, owner.UserID, owner.OrganizationID) + insertLogs( + logOpt{SeqNum: 0, Proto: "http", Method: "GET", Detail: "https://a.com", Rule: "domain=a.com"}, + ) + + ctx := testutil.Context(t, testutil.WaitLong) + resp, err := auditorClient.AgentFirewallSessionLogs(ctx, session.ID, codersdk.AgentFirewallSessionLogsParams{}) + require.NoError(t, err) + require.Len(t, resp.Results, 1) + }) +} diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 360718dc0a..29d8c39e84 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -336,6 +336,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { ) r.Route("/sessions/{id}", func(r chi.Router) { r.Get("/", api.agentFirewallSessionByID) + r.Get("/logs", api.agentFirewallSessionLogs) }) }) r.Route("/licenses", func(r chi.Router) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4bbf63eef0..78769ed7d2 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1024,6 +1024,23 @@ export const AgentDisplayModes: AgentDisplayMode[] = [ "auto", ]; +// From codersdk/agentfirewall.go +/** + * AgentFirewallLog represents a single audit event from an agent firewall proxy. + */ +export interface AgentFirewallLog { + readonly id: string; + readonly session_id: string; + readonly sequence_number: number; + readonly allowed: boolean; + readonly created_at: string; + readonly proto: string; + readonly method: string; + readonly detail: string; + readonly matched_rule: string | null; + readonly captured_at?: string; +} + // From codersdk/agentfirewall.go /** * AgentFirewallSession represents a firewall session for a workspace agent. @@ -1036,6 +1053,37 @@ export interface AgentFirewallSession { readonly started_at: string; } +// From codersdk/agentfirewall.go +/** + * AgentFirewallSessionLogsParams are query parameters for listing + * agent firewall session logs. + */ +export interface AgentFirewallSessionLogsParams { + /** + * SeqAfter is an inclusive lower bound on sequence_number. + * Only logs with sequence_number >= SeqAfter are returned. + */ + readonly seq_after?: number; + /** + * SeqBefore is an exclusive upper bound on sequence_number. + * Only logs with sequence_number < SeqBefore are returned. + */ + readonly seq_before?: number; + /** + * Limit caps the number of returned rows. Defaults to 100. + */ + readonly limit?: number; +} + +// From codersdk/agentfirewall.go +/** + * AgentFirewallSessionLogsResponse is the response for + * GET /api/v2/agent-firewall/sessions/{id}/logs. + */ +export interface AgentFirewallSessionLogsResponse { + readonly results: readonly AgentFirewallLog[]; +} + // From codersdk/workspacebuilds.go export interface AgentScriptTiming { readonly started_at: string;