feat: add GET /api/v2/agent-firewall/sessions/{id}/logs endpoint (#24816)

Add a `GET /api/v2/agent-firewall/sessions/{id}/logs` endpoint that
returns agent firewall audit logs for a given session, sorted by
sequence number ascending.

The endpoint supports `seq_after` and `seq_before` (exclusive bounds)
and `limit` query parameters. This enables the frontend to fetch exactly
the firewall events that fall between two AI Bridge interceptions within
a thread, as described in FR 4 of the Boundary/Bridge correlation RFC.

Authorization reuses the `boundary_log` RBAC resource (owner and auditor
can read; members cannot). Returns 404 for unauthorized users to avoid
leaking existence information.

The endpoint is enterprise-only, gated behind `FeatureBoundary`
entitlement, matching the session endpoint from #24814.

Depends on #24814

> [!NOTE]
> This PR was authored by Coder Agents.
This commit is contained in:
Sas Swart
2026-06-22 13:56:29 +02:00
committed by GitHub
parent c0b8fa9418
commit 335d6bda1b
13 changed files with 829 additions and 8 deletions
+94
View File
@@ -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
}
+292
View File
@@ -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)
})
}
+1
View File
@@ -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) {