feat: GET /api/v2/agent-firewall/sessions/{id} (#24814)

Add a GET endpoint at `/api/v2/agent-firewall/sessions/{id}` that
returns agent firewall session metadata (`id`, `workspace_id`,
`owner_id`, `confined_process`, `started_at`). The handler authorizes
against the `boundary_log` resource with `ActionRead` via dbauthz.

The endpoint is enterprise-only, gated behind the `FeatureBoundary`
entitlement.

The `GetBoundarySessionByID` SQL query JOINs through `workspace_agents`
→ `workspace_resources` → `workspace_builds` → `workspaces` to return
`workspace_id` and `workspace_owner_id` directly, avoiding a separate
query.

Also adds an `owner_id` column to the `boundary_logs` table (migration
000526) with a FK to `users(id)` and a backfill from
`boundary_sessions`. This enables user-scoped RBAC authorization for
`InsertBoundaryLogs` via `.WithOwner()`, ensuring workspace agents can
only insert logs for their own owner.

Depends on #24810

**RBAC behaviour:**

| Role    | Result |
|---------|--------|
| Owner   | read   |
| Auditor | read   |
| Member  | 404    |

> [!NOTE]
> This PR was authored by Coder Agents.
This commit is contained in:
Sas Swart
2026-06-18 20:50:17 +02:00
committed by GitHub
parent 20ed45cac7
commit 491a75294e
24 changed files with 649 additions and 20 deletions
+4 -3
View File
@@ -2963,9 +2963,9 @@ func (q *querier) GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (databas
return q.db.GetBoundaryLogByID(ctx, id)
}
func (q *querier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (database.BoundarySession, error) {
func (q *querier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (database.GetBoundarySessionByIDRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceBoundaryLog); err != nil {
return database.BoundarySession{}, err
return database.GetBoundarySessionByIDRow{}, err
}
return q.db.GetBoundarySessionByID(ctx, id)
}
@@ -5810,7 +5810,8 @@ func (q *querier) InsertAuditLog(ctx context.Context, arg database.InsertAuditLo
}
func (q *querier) InsertBoundaryLogs(ctx context.Context, arg database.InsertBoundaryLogsParams) ([]database.BoundaryLog, error) {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceBoundaryLog); err != nil {
if err := q.authorizeContext(ctx, policy.ActionCreate,
rbac.ResourceBoundaryLog.WithOwner(arg.OwnerID.String())); err != nil {
return nil, err
}
return q.db.InsertBoundaryLogs(ctx, arg)
+6 -2
View File
@@ -457,16 +457,20 @@ func (s *MethodTestSuite) TestBoundaryLogs() {
)
}))
s.Run("GetBoundarySessionByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetBoundarySessionByID(gomock.Any(), uuid.Nil).Return(database.BoundarySession{}, nil).AnyTimes()
dbm.EXPECT().GetBoundarySessionByID(gomock.Any(), uuid.Nil).Return(database.GetBoundarySessionByIDRow{}, nil).AnyTimes()
check.Args(uuid.Nil).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead)
}))
s.Run("InsertBoundaryLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
ownerID := uuid.New()
arg := database.InsertBoundaryLogsParams{
SessionID: uuid.New(),
OwnerID: ownerID,
ID: []uuid.UUID{uuid.New(), uuid.New()},
}
dbm.EXPECT().InsertBoundaryLogs(gomock.Any(), arg).Return([]database.BoundaryLog{}, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceBoundaryLog, policy.ActionCreate)
check.Args(arg).Asserts(
rbac.ResourceBoundaryLog.WithOwner(ownerID.String()), policy.ActionCreate,
)
}))
s.Run("GetBoundaryLogByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetBoundaryLogByID(gomock.Any(), uuid.Nil).Return(database.BoundaryLog{}, nil).AnyTimes()
+2
View File
@@ -481,6 +481,7 @@ func BoundarySession(t testing.TB, db database.Store, seed database.BoundarySess
func BoundaryLogs(t testing.TB, db database.Store, seed []database.BoundaryLog) []database.BoundaryLog {
ids := make([]uuid.UUID, 0, len(seed))
sessionID := seed[0].SessionID
ownerID := seed[0].OwnerID.UUID
sequenceNumbers := make([]int32, 0, len(seed))
capturedAt := make([]time.Time, 0, len(seed))
createdAt := make([]time.Time, 0, len(seed))
@@ -502,6 +503,7 @@ func BoundaryLogs(t testing.TB, db database.Store, seed []database.BoundaryLog)
logs, err := db.InsertBoundaryLogs(genCtx, database.InsertBoundaryLogsParams{
ID: ids,
SessionID: sessionID,
OwnerID: ownerID,
SequenceNumber: sequenceNumbers,
CapturedAt: capturedAt,
CreatedAt: createdAt,
+1 -1
View File
@@ -1378,7 +1378,7 @@ func (m queryMetricsStore) GetBoundaryLogByID(ctx context.Context, id uuid.UUID)
return r0, r1
}
func (m queryMetricsStore) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (database.BoundarySession, error) {
func (m queryMetricsStore) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (database.GetBoundarySessionByIDRow, error) {
start := time.Now()
r0, r1 := m.s.GetBoundarySessionByID(ctx, id)
m.queryLatencies.WithLabelValues("GetBoundarySessionByID").Observe(time.Since(start).Seconds())
+2 -2
View File
@@ -2533,10 +2533,10 @@ func (mr *MockStoreMockRecorder) GetBoundaryLogByID(ctx, id any) *gomock.Call {
}
// GetBoundarySessionByID mocks base method.
func (m *MockStore) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (database.BoundarySession, error) {
func (m *MockStore) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (database.GetBoundarySessionByIDRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetBoundarySessionByID", ctx, id)
ret0, _ := ret[0].(database.BoundarySession)
ret0, _ := ret[0].(database.GetBoundarySessionByIDRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
+3
View File
@@ -1753,6 +1753,7 @@ func TestDeleteOldBoundaryLogs(t *testing.T) {
// Create old boundary log.
oldLogs := dbgen.BoundaryLogs(t, db, []database.BoundaryLog{{
SessionID: session.ID,
OwnerID: uuid.NullUUID{UUID: user.ID, Valid: true},
SequenceNumber: 0,
CapturedAt: tc.oldLogTime,
CreatedAt: tc.oldLogTime,
@@ -1764,6 +1765,7 @@ func TestDeleteOldBoundaryLogs(t *testing.T) {
if tc.recentLogTime != nil {
recentLogs := dbgen.BoundaryLogs(t, db, []database.BoundaryLog{{
SessionID: session.ID,
OwnerID: uuid.NullUUID{UUID: user.ID, Valid: true},
SequenceNumber: 1,
CapturedAt: *tc.recentLogTime,
CreatedAt: *tc.recentLogTime,
@@ -1905,6 +1907,7 @@ func TestDeleteOldBoundarySessions(t *testing.T) {
if tc.logTime != nil {
dbgen.BoundaryLogs(t, db, []database.BoundaryLog{{
SessionID: session.ID,
OwnerID: uuid.NullUUID{UUID: user.ID, Valid: true},
SequenceNumber: 0,
CapturedAt: *tc.logTime,
CreatedAt: *tc.logTime,
+6
View File
@@ -1678,6 +1678,7 @@ CREATE TABLE boundary_logs (
method text DEFAULT ''::text NOT NULL,
detail text DEFAULT ''::text NOT NULL,
matched_rule text,
owner_id uuid,
CONSTRAINT boundary_logs_sequence_number_check CHECK ((sequence_number >= 0))
);
@@ -1699,6 +1700,8 @@ COMMENT ON COLUMN boundary_logs.detail IS 'Protocol-specific detail. e.g. the fu
COMMENT ON COLUMN boundary_logs.matched_rule IS 'The allow-list rule that matched. NULL when the request was denied; non-NULL implies the request was allowed.';
COMMENT ON COLUMN boundary_logs.owner_id IS 'The ID of the user who owns the workspace. NULL for logs inserted before this column existed or if the user was deleted.';
CREATE TABLE boundary_sessions (
id uuid NOT NULL,
workspace_agent_id uuid NOT NULL,
@@ -4935,6 +4938,9 @@ ALTER TABLE ONLY aibridge_interceptions
ALTER TABLE ONLY api_keys
ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY boundary_logs
ADD CONSTRAINT boundary_logs_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE ONLY boundary_sessions
ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL;
+1
View File
@@ -12,6 +12,7 @@ const (
ForeignKeyAISeatStateUserID ForeignKeyConstraint = "ai_seat_state_user_id_fkey" // ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id);
ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyBoundaryLogsOwnerID ForeignKeyConstraint = "boundary_logs_owner_id_fkey" // ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL;
ForeignKeyBoundarySessionsOwnerID ForeignKeyConstraint = "boundary_sessions_owner_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL;
ForeignKeyBoundarySessionsWorkspaceAgentID ForeignKeyConstraint = "boundary_sessions_workspace_agent_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id);
ForeignKeyChatContextResourcesChatID ForeignKeyConstraint = "chat_context_resources_chat_id_fkey" // ALTER TABLE ONLY chat_context_resources ADD CONSTRAINT chat_context_resources_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
@@ -0,0 +1,2 @@
ALTER TABLE boundary_logs DROP CONSTRAINT IF EXISTS boundary_logs_owner_id_fkey;
ALTER TABLE boundary_logs DROP COLUMN IF EXISTS owner_id;
@@ -0,0 +1,14 @@
ALTER TABLE boundary_logs ADD COLUMN owner_id UUID;
COMMENT ON COLUMN boundary_logs.owner_id IS 'The ID of the user who owns the workspace. NULL for logs inserted before this column existed or if the user was deleted.';
-- Backfill from sessions where possible.
UPDATE boundary_logs bl
SET owner_id = bs.owner_id
FROM boundary_sessions bs
WHERE bl.session_id = bs.id
AND bs.owner_id IS NOT NULL;
ALTER TABLE boundary_logs
ADD CONSTRAINT boundary_logs_owner_id_fkey
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL;
+2
View File
@@ -4726,6 +4726,8 @@ type BoundaryLog struct {
Detail string `db:"detail" json:"detail"`
// The allow-list rule that matched. NULL when the request was denied; non-NULL implies the request was allowed.
MatchedRule sql.NullString `db:"matched_rule" json:"matched_rule"`
// The ID of the user who owns the workspace. NULL for logs inserted before this column existed or if the user was deleted.
OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"`
}
// Boundary session metadata. Each row represents a single invocation of a Boundary process wrapping a confined agent.
+1 -1
View File
@@ -367,7 +367,7 @@ type sqlcQuerier interface {
// limits roots, not total family members.
GetAutoArchiveInactiveChatCandidates(ctx context.Context, arg GetAutoArchiveInactiveChatCandidatesParams) ([]GetAutoArchiveInactiveChatCandidatesRow, error)
GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (BoundaryLog, error)
GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (BoundarySession, error)
GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (GetBoundarySessionByIDRow, error)
GetChatACLByID(ctx context.Context, id uuid.UUID) (GetChatACLByIDRow, error)
// GetChatAdvisorConfig returns the deployment-wide runtime configuration
// for the experimental chat advisor as a JSON blob. Callers unmarshal the
+45 -10
View File
@@ -3686,7 +3686,7 @@ func (q *sqlQuerier) DeleteOldBoundarySessions(ctx context.Context, arg DeleteOl
}
const getBoundaryLogByID = `-- name: GetBoundaryLogByID :one
SELECT id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule FROM boundary_logs WHERE id = $1
SELECT id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule, owner_id FROM boundary_logs WHERE id = $1
`
func (q *sqlQuerier) GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (BoundaryLog, error) {
@@ -3702,17 +3702,44 @@ func (q *sqlQuerier) GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (Boun
&i.Method,
&i.Detail,
&i.MatchedRule,
&i.OwnerID,
)
return i, err
}
const getBoundarySessionByID = `-- name: GetBoundarySessionByID :one
SELECT id, workspace_agent_id, confined_process_name, started_at, updated_at, owner_id FROM boundary_sessions WHERE id = $1
SELECT
bs.id, bs.workspace_agent_id, bs.confined_process_name, bs.started_at, bs.updated_at, bs.owner_id,
w.id AS workspace_id,
w.owner_id AS workspace_owner_id
FROM
boundary_sessions bs
JOIN
workspace_agents wa ON wa.id = bs.workspace_agent_id
JOIN
workspace_resources wr ON wr.id = wa.resource_id
JOIN
workspace_builds wb ON wb.job_id = wr.job_id
JOIN
workspaces w ON w.id = wb.workspace_id
WHERE
bs.id = $1
`
func (q *sqlQuerier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (BoundarySession, error) {
type GetBoundarySessionByIDRow struct {
ID uuid.UUID `db:"id" json:"id"`
WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"`
ConfinedProcessName string `db:"confined_process_name" json:"confined_process_name"`
StartedAt time.Time `db:"started_at" json:"started_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"`
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
WorkspaceOwnerID uuid.UUID `db:"workspace_owner_id" json:"workspace_owner_id"`
}
func (q *sqlQuerier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (GetBoundarySessionByIDRow, error) {
row := q.db.QueryRowContext(ctx, getBoundarySessionByID, id)
var i BoundarySession
var i GetBoundarySessionByIDRow
err := row.Scan(
&i.ID,
&i.WorkspaceAgentID,
@@ -3720,6 +3747,8 @@ func (q *sqlQuerier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (
&i.StartedAt,
&i.UpdatedAt,
&i.OwnerID,
&i.WorkspaceID,
&i.WorkspaceOwnerID,
)
return i, err
}
@@ -3728,6 +3757,7 @@ const insertBoundaryLogs = `-- name: InsertBoundaryLogs :many
INSERT INTO boundary_logs (
id,
session_id,
owner_id,
sequence_number,
captured_at,
created_at,
@@ -3739,19 +3769,21 @@ INSERT INTO boundary_logs (
SELECT
unnest($1 :: uuid[]),
$2 :: uuid,
unnest($3 :: int[]),
unnest($4 :: timestamptz[]),
$3 :: uuid,
unnest($4 :: int[]),
unnest($5 :: timestamptz[]),
unnest($6 :: text[]),
unnest($6 :: timestamptz[]),
unnest($7 :: text[]),
unnest($8 :: text[]),
NULLIF(unnest($9 :: text[]), '')
RETURNING id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule
unnest($9 :: text[]),
NULLIF(unnest($10 :: text[]), '')
RETURNING id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule, owner_id
`
type InsertBoundaryLogsParams struct {
ID []uuid.UUID `db:"id" json:"id"`
SessionID uuid.UUID `db:"session_id" json:"session_id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
SequenceNumber []int32 `db:"sequence_number" json:"sequence_number"`
CapturedAt []time.Time `db:"captured_at" json:"captured_at"`
CreatedAt []time.Time `db:"created_at" json:"created_at"`
@@ -3765,6 +3797,7 @@ func (q *sqlQuerier) InsertBoundaryLogs(ctx context.Context, arg InsertBoundaryL
rows, err := q.db.QueryContext(ctx, insertBoundaryLogs,
pq.Array(arg.ID),
arg.SessionID,
arg.OwnerID,
pq.Array(arg.SequenceNumber),
pq.Array(arg.CapturedAt),
pq.Array(arg.CreatedAt),
@@ -3790,6 +3823,7 @@ func (q *sqlQuerier) InsertBoundaryLogs(ctx context.Context, arg InsertBoundaryL
&i.Method,
&i.Detail,
&i.MatchedRule,
&i.OwnerID,
); err != nil {
return nil, err
}
@@ -3853,7 +3887,7 @@ func (q *sqlQuerier) InsertBoundarySession(ctx context.Context, arg InsertBounda
}
const listBoundaryLogsBySessionID = `-- name: ListBoundaryLogsBySessionID :many
SELECT id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule
SELECT id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule, owner_id
FROM boundary_logs
WHERE
session_id = $1
@@ -3903,6 +3937,7 @@ func (q *sqlQuerier) ListBoundaryLogsBySessionID(ctx context.Context, arg ListBo
&i.Method,
&i.Detail,
&i.MatchedRule,
&i.OwnerID,
); err != nil {
return nil, err
}
+18 -1
View File
@@ -16,12 +16,28 @@ INSERT INTO boundary_sessions (
) RETURNING *;
-- name: GetBoundarySessionByID :one
SELECT * FROM boundary_sessions WHERE id = @id;
SELECT
bs.*,
w.id AS workspace_id,
w.owner_id AS workspace_owner_id
FROM
boundary_sessions bs
JOIN
workspace_agents wa ON wa.id = bs.workspace_agent_id
JOIN
workspace_resources wr ON wr.id = wa.resource_id
JOIN
workspace_builds wb ON wb.job_id = wr.job_id
JOIN
workspaces w ON w.id = wb.workspace_id
WHERE
bs.id = @id;
-- name: InsertBoundaryLogs :many
INSERT INTO boundary_logs (
id,
session_id,
owner_id,
sequence_number,
captured_at,
created_at,
@@ -33,6 +49,7 @@ INSERT INTO boundary_logs (
SELECT
unnest(@id :: uuid[]),
@session_id :: uuid,
@owner_id :: uuid,
unnest(@sequence_number :: int[]),
unnest(@captured_at :: timestamptz[]),
unnest(@created_at :: timestamptz[]),