diff --git a/coderd/agentapi/boundary_logs.go b/coderd/agentapi/boundary_logs.go index 41ad5daf1f..16703c8384 100644 --- a/coderd/agentapi/boundary_logs.go +++ b/coderd/agentapi/boundary_logs.go @@ -96,6 +96,7 @@ func (a *BoundaryLogsAPI) ReportBoundaryLogs(ctx context.Context, req *agentprot // Collect batch insert params while iterating. batch := database.InsertBoundaryLogsParams{ SessionID: sessionID, + OwnerID: a.OwnerID, ID: nil, SequenceNumber: nil, CapturedAt: nil, diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 24eaec0941..fd369b9097 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1292,6 +1292,41 @@ const docTemplate = `{ } } }, + "/api/v2/agent-firewall/sessions/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get agent firewall session by ID", + "operationId": "get-agent-firewall-session-by-id", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Agent firewall session ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.AgentFirewallSession" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/ai/providers": { "get": { "produces": [ @@ -15908,6 +15943,30 @@ const docTemplate = `{ "AgentDisplayModeAlwaysCollapsed" ] }, + "codersdk.AgentFirewallSession": { + "type": "object", + "properties": { + "confined_process": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "owner_id": { + "type": "string", + "format": "uuid" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.AgentScriptTiming": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 26c4aff908..32bfbd4754 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1143,6 +1143,37 @@ } } }, + "/api/v2/agent-firewall/sessions/{id}": { + "get": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get agent firewall session by ID", + "operationId": "get-agent-firewall-session-by-id", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Agent firewall session ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.AgentFirewallSession" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/ai/providers": { "get": { "produces": ["application/json"], @@ -14247,6 +14278,30 @@ "AgentDisplayModeAlwaysCollapsed" ] }, + "codersdk.AgentFirewallSession": { + "type": "object", + "properties": { + "confined_process": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "owner_id": { + "type": "string", + "format": "uuid" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.AgentScriptTiming": { "type": "object", "properties": { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 854f25bdb8..216ccae482 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 477c32a6c7..aed9a9580b 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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() diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 3ccad638aa..43804ae84c 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -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, diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index e6561186b0..a600a6371b 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -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()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 4898f560bd..75ea063c0d 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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 } diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 18d7866cac..6d8024c497 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -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, diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 95c7e11660..74ccc1890f 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -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; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index f3c9920a52..3eac59552c 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -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; diff --git a/coderd/database/migrations/000526_boundary_log_owner.down.sql b/coderd/database/migrations/000526_boundary_log_owner.down.sql new file mode 100644 index 0000000000..1cab8fbb02 --- /dev/null +++ b/coderd/database/migrations/000526_boundary_log_owner.down.sql @@ -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; diff --git a/coderd/database/migrations/000526_boundary_log_owner.up.sql b/coderd/database/migrations/000526_boundary_log_owner.up.sql new file mode 100644 index 0000000000..a1e0ba1e97 --- /dev/null +++ b/coderd/database/migrations/000526_boundary_log_owner.up.sql @@ -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; diff --git a/coderd/database/models.go b/coderd/database/models.go index 349bed3a75..1f2965caa4 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -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. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4a45417246..47fbbb717a 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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 diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index cea0c61e0c..242a601fe5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 } diff --git a/coderd/database/queries/boundarylogs.sql b/coderd/database/queries/boundarylogs.sql index 7169f15ac4..c99158d3d0 100644 --- a/coderd/database/queries/boundarylogs.sql +++ b/coderd/database/queries/boundarylogs.sql @@ -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[]), diff --git a/codersdk/agentfirewall.go b/codersdk/agentfirewall.go new file mode 100644 index 0000000000..d1900a368a --- /dev/null +++ b/codersdk/agentfirewall.go @@ -0,0 +1,34 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" +) + +// AgentFirewallSession represents a firewall session for a workspace agent. +type AgentFirewallSession struct { + ID uuid.UUID `json:"id" format:"uuid"` + WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"` + OwnerID uuid.UUID `json:"owner_id" format:"uuid"` + ConfinedProcess string `json:"confined_process"` + StartedAt time.Time `json:"started_at" format:"date-time"` +} + +// AgentFirewallSessionByID returns an agent firewall session by its ID. +func (c *Client) AgentFirewallSessionByID(ctx context.Context, id uuid.UUID) (AgentFirewallSession, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/agent-firewall/sessions/%s", id), nil) + if err != nil { + return AgentFirewallSession{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return AgentFirewallSession{}, ReadBodyAsError(res) + } + var session AgentFirewallSession + return session, json.NewDecoder(res.Body).Decode(&session) +} diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index c2d193aa32..d2bdf7a811 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -84,6 +84,47 @@ curl -X GET http://coder-server:8080/.well-known/oauth-protected-resource \ |--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------------------------| | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OAuth2ProtectedResourceMetadata](schemas.md#codersdkoauth2protectedresourcemetadata) | +## Get agent firewall session by ID + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/agent-firewall/sessions/{id} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/v2/agent-firewall/sessions/{id}` + +### Parameters + +| Name | In | Type | Required | Description | +|------|------|--------------|----------|---------------------------| +| `id` | path | string(uuid) | true | Agent firewall session ID | + +### Example responses + +> 200 Response + +```json +{ + "confined_process": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", + "started_at": "2019-08-24T14:15:22Z", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.AgentFirewallSession](schemas.md#codersdkagentfirewallsession) | + +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 8998babc87..595d110747 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1263,6 +1263,28 @@ None |-----------------------------------------------| | `always_collapsed`, `always_expanded`, `auto` | +## codersdk.AgentFirewallSession + +```json +{ + "confined_process": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", + "started_at": "2019-08-24T14:15:22Z", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|--------|----------|--------------|-------------| +| `confined_process` | string | false | | | +| `id` | string | false | | | +| `owner_id` | string | false | | | +| `started_at` | string | false | | | +| `workspace_id` | string | false | | | + ## codersdk.AgentScriptTiming ```json diff --git a/enterprise/coderd/agentfirewall.go b/enterprise/coderd/agentfirewall.go new file mode 100644 index 0000000000..e19bf1caa0 --- /dev/null +++ b/enterprise/coderd/agentfirewall.go @@ -0,0 +1,44 @@ +package coderd + +import ( + "net/http" + + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" +) + +// @Summary Get agent firewall session by ID +// @ID get-agent-firewall-session-by-id +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param id path string true "Agent firewall session ID" format(uuid) +// @Success 200 {object} codersdk.AgentFirewallSession +// @Router /api/v2/agent-firewall/sessions/{id} [get] +func (api *API) agentFirewallSessionByID(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id, ok := httpmw.ParseUUIDParam(rw, r, "id") + if !ok { + return + } + + session, err := api.Database.GetBoundarySessionByID(ctx, id) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.AgentFirewallSession{ + ID: session.ID, + WorkspaceID: session.WorkspaceID, + OwnerID: session.WorkspaceOwnerID, + ConfinedProcess: session.ConfinedProcessName, + StartedAt: session.StartedAt, + }) +} diff --git a/enterprise/coderd/agentfirewall_test.go b/enterprise/coderd/agentfirewall_test.go new file mode 100644 index 0000000000..70d3ed1b32 --- /dev/null +++ b/enterprise/coderd/agentfirewall_test.go @@ -0,0 +1,265 @@ +package coderd_test + +import ( + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "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/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestAgentFirewallSessionByID(t *testing.T) { + t.Parallel() + + // seedBoundarySession inserts a boundary session linked to a workspace agent. + // Uses the raw DB store to avoid dbauthz permission and ordering constraints during setup. + seedBoundarySession := func(t *testing.T, rawDB database.Store, ownerID, orgID uuid.UUID) (database.BoundarySession, database.WorkspaceTable) { + t.Helper() + + resp := dbfake.WorkspaceBuild(t, rawDB, database.WorkspaceTable{ + OwnerID: ownerID, + OrganizationID: orgID, + }).WithAgent().Do() + + require.NotEmpty(t, resp.Agents, "expected at least one agent") + + session := dbgen.BoundarySession(t, rawDB, database.BoundarySession{ + WorkspaceAgentID: resp.Agents[0].ID, + OwnerID: uuid.NullUUID{UUID: ownerID, Valid: true}, + ConfinedProcessName: "claude-code", + }) + return session, resp.Workspace + } + + t.Run("Owner", 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, + }, + }, + }) + ctx := testutil.Context(t, testutil.WaitLong) + + session, ws := seedBoundarySession(t, db, owner.UserID, owner.OrganizationID) + + //nolint:gocritic // Testing owner role. + got, err := ownerClient.AgentFirewallSessionByID(ctx, session.ID) + require.NoError(t, err) + require.Equal(t, session.ID, got.ID) + require.Equal(t, ws.OwnerID, got.OwnerID) + require.Equal(t, session.ConfinedProcessName, got.ConfinedProcess) + require.Equal(t, ws.ID, got.WorkspaceID) + }) + + t.Run("Auditor", 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, + }, + }, + }) + ctx := testutil.Context(t, testutil.WaitLong) + + session, _ := seedBoundarySession(t, db, owner.UserID, owner.OrganizationID) + + auditorClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleAuditor()) + + got, err := auditorClient.AgentFirewallSessionByID(ctx, session.ID) + require.NoError(t, err) + require.Equal(t, session.ID, got.ID) + require.Equal(t, "claude-code", got.ConfinedProcess) + }) + + 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, + }, + }, + }) + ctx := testutil.Context(t, testutil.WaitLong) + + session, _ := seedBoundarySession(t, db, owner.UserID, owner.OrganizationID) + + memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + _, err := memberClient.AgentFirewallSessionByID(ctx, session.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + ownerClient, _ := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureBoundary: 1, + }, + }, + }) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Testing owner role. + _, err := ownerClient.AgentFirewallSessionByID(ctx, uuid.New()) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} + +// TestInsertBoundaryLogs_AgentAuth verifies that a workspace agent context +// can insert boundary logs through the dbauthz layer. Create is user-scoped +// in the member role; the agent's owner ID must match the resource owner. +func TestInsertBoundaryLogs_AgentAuth(t *testing.T) { + t.Parallel() + + rawDB, _ := dbtestutil.NewDB(t) + authorizer := rbac.NewStrictAuthorizer(prometheus.NewRegistry()) + authzDB := dbauthz.New(rawDB, authorizer, slogtest.Make(t, nil), &atomic.Pointer[dbauthz.AccessControlStore]{}) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Seed a workspace with an agent. + user := dbgen.User(t, rawDB, database.User{}) + org := dbgen.Organization(t, rawDB, database.Organization{}) + _ = dbgen.OrganizationMember(t, rawDB, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + tmpl := dbgen.Template(t, rawDB, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + tmplVer := dbgen.TemplateVersion(t, rawDB, database.TemplateVersion{ + TemplateID: uuid.NullUUID{Valid: true, UUID: tmpl.ID}, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + ws := dbgen.Workspace(t, rawDB, database.WorkspaceTable{ + OrganizationID: org.ID, + TemplateID: tmpl.ID, + OwnerID: user.ID, + }) + job := dbgen.ProvisionerJob(t, rawDB, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeWorkspaceBuild, + }) + build := dbgen.WorkspaceBuild(t, rawDB, database.WorkspaceBuild{ + JobID: job.ID, + WorkspaceID: ws.ID, + TemplateVersionID: tmplVer.ID, + }) + resource := dbgen.WorkspaceResource(t, rawDB, database.WorkspaceResource{ + JobID: build.JobID, + }) + agent := dbgen.WorkspaceAgent(t, rawDB, database.WorkspaceAgent{ + ResourceID: resource.ID, + }) + + // Insert a boundary session using the raw DB (no auth check). + now := time.Now().UTC() + sessionID := uuid.New() + _, err := rawDB.InsertBoundarySession(ctx, database.InsertBoundarySessionParams{ + ID: sessionID, + WorkspaceAgentID: agent.ID, + OwnerID: uuid.NullUUID{UUID: user.ID, Valid: true}, + ConfinedProcessName: "claude-code", + StartedAt: now, + UpdatedAt: now, + }) + require.NoError(t, err) + + // Build a workspace agent RBAC subject. + memberRole, err := rbac.RoleByName(rbac.RoleMember()) + require.NoError(t, err) + agentSubject := rbac.Subject{ + ID: user.ID.String(), + Roles: rbac.Roles{memberRole}, + Scope: rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{ + WorkspaceID: ws.ID, + OwnerID: user.ID, + TemplateID: tmpl.ID, + VersionID: tmplVer.ID, + }), + }.WithCachedASTValue() + agentCtx := dbauthz.As(ctx, agentSubject) + + // Insert boundary logs through dbauthz with the correct owner. + // User-scoped create succeeds because the agent subject ID matches. + logID := uuid.New() + _, err = authzDB.InsertBoundaryLogs(agentCtx, database.InsertBoundaryLogsParams{ + SessionID: sessionID, + OwnerID: user.ID, + ID: []uuid.UUID{logID}, + SequenceNumber: []int32{1}, + CapturedAt: []time.Time{now}, + CreatedAt: []time.Time{now}, + Proto: []string{"tcp"}, + Method: []string{"connect"}, + Detail: []string{"example.com:443"}, + MatchedRule: []string{"allow-all"}, + }) + require.NoError(t, err, "agent should be able to insert boundary logs for own owner") + + // Verify the logs were actually persisted. + got, err := rawDB.GetBoundaryLogByID(ctx, logID) + require.NoError(t, err) + require.Equal(t, sessionID, got.SessionID) + + // Inserting with a different owner ID must fail (user-scoped create). + otherUser := dbgen.User(t, rawDB, database.User{}) + _, err = authzDB.InsertBoundaryLogs(agentCtx, database.InsertBoundaryLogsParams{ + SessionID: sessionID, + OwnerID: otherUser.ID, + ID: []uuid.UUID{uuid.New()}, + SequenceNumber: []int32{2}, + CapturedAt: []time.Time{now}, + CreatedAt: []time.Time{now}, + Proto: []string{"tcp"}, + Method: []string{"connect"}, + Detail: []string{"evil.com:443"}, + MatchedRule: []string{"allow-all"}, + }) + require.Error(t, err, "agent must not insert boundary logs for a different owner") +} diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 40d1e7f097..d229b6f9ff 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -329,6 +329,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { ) r.Get("/", api.connectionLogs) }) + r.Route("/agent-firewall", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + api.RequireFeatureMW(codersdk.FeatureBoundary), + ) + r.Route("/sessions/{id}", func(r chi.Router) { + r.Get("/", api.agentFirewallSessionByID) + }) + }) r.Route("/licenses", func(r chi.Router) { r.Use(apiKeyMiddleware) r.Post("/refresh-entitlements", api.postRefreshEntitlements) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9b580dfec6..4bbf63eef0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1024,6 +1024,18 @@ export const AgentDisplayModes: AgentDisplayMode[] = [ "auto", ]; +// From codersdk/agentfirewall.go +/** + * AgentFirewallSession represents a firewall session for a workspace agent. + */ +export interface AgentFirewallSession { + readonly id: string; + readonly workspace_id: string; + readonly owner_id: string; + readonly confined_process: string; + readonly started_at: string; +} + // From codersdk/workspacebuilds.go export interface AgentScriptTiming { readonly started_at: string;