diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 5ddfabff0e..5682341ef9 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -12,6 +12,7 @@ const ( CheckAiModelPricesOutputPriceCheck CheckConstraint = "ai_model_prices_output_price_check" // ai_model_prices CheckAiProvidersNameCheck CheckConstraint = "ai_providers_name_check" // ai_providers CheckAPIKeysAllowListNotEmpty CheckConstraint = "api_keys_allow_list_not_empty" // api_keys + CheckBoundaryLogsSequenceNumberCheck CheckConstraint = "boundary_logs_sequence_number_check" // boundary_logs CheckChatModelConfigsAiProviderRequiredWhenActive CheckConstraint = "chat_model_configs_ai_provider_required_when_active" // chat_model_configs CheckChatModelConfigsCompressionThresholdCheck CheckConstraint = "chat_model_configs_compression_threshold_check" // chat_model_configs CheckChatModelConfigsContextLimitCheck CheckConstraint = "chat_model_configs_context_limit_check" // chat_model_configs diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index e0d5617c1a..086a35d229 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2161,6 +2161,14 @@ func (q *querier) DeleteOldAuditLogs(ctx context.Context, arg database.DeleteOld return q.db.DeleteOldAuditLogs(ctx, arg) } +// TODO (PR #24810): Replace rbac.ResourceSystem with dedicated boundary_log resource type. +func (q *querier) DeleteOldBoundaryLogs(ctx context.Context, arg database.DeleteOldBoundaryLogsParams) (int64, error) { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { + return 0, err + } + return q.db.DeleteOldBoundaryLogs(ctx, arg) +} + func (q *querier) DeleteOldChatDebugRuns(ctx context.Context, arg database.DeleteOldChatDebugRunsParams) (int64, error) { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { return 0, err @@ -2742,6 +2750,22 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI return q.db.GetAuthorizationUserRoles(ctx, userID) } +// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. +func (q *querier) GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (database.BoundaryLog, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil { + return database.BoundaryLog{}, err + } + return q.db.GetBoundaryLogByID(ctx, id) +} + +// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. +func (q *querier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (database.BoundarySession, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil { + return database.BoundarySession{}, err + } + return q.db.GetBoundarySessionByID(ctx, id) +} + func (q *querier) GetChatACLByID(ctx context.Context, id uuid.UUID) (database.GetChatACLByIDRow, error) { chat, err := q.db.GetChatByID(ctx, id) if err != nil { @@ -5413,6 +5437,16 @@ func (q *querier) InsertAuditLog(ctx context.Context, arg database.InsertAuditLo return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertAuditLog)(ctx, arg) } +// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. +func (q *querier) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) { + return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertBoundaryLog)(ctx, arg) +} + +// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. +func (q *querier) InsertBoundarySession(ctx context.Context, arg database.InsertBoundarySessionParams) (database.BoundarySession, error) { + return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertBoundarySession)(ctx, arg) +} + func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { return insert(q.log, q.auth, rbac.ResourceChat.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID), q.db.InsertChat)(ctx, arg) } @@ -6126,6 +6160,14 @@ func (q *querier) ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Context, return q.db.ListAIBridgeUserPromptsByInterceptionIDs(ctx, interceptionIDs) } +// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. +func (q *querier) ListBoundaryLogsBySessionID(ctx context.Context, arg database.ListBoundaryLogsBySessionIDParams) ([]database.BoundaryLog, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil { + return nil, err + } + return q.db.ListBoundaryLogsBySessionID(ctx, arg) +} + func (q *querier) ListChatUsageLimitGroupOverrides(ctx context.Context) ([]database.ListChatUsageLimitGroupOverridesRow, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { return nil, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 354453da40..2cff1d6c8c 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -440,6 +440,38 @@ func (s *MethodTestSuite) TestAuditLogs() { })) } +// TODO (PR #24810): These RBAC assertions use placeholder resource types. +// They will be updated when the dedicated boundary_log resource type is added. +func (s *MethodTestSuite) TestBoundaryLogs() { + s.Run("InsertBoundarySession", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertBoundarySessionParams{} + dbm.EXPECT().InsertBoundarySession(gomock.Any(), arg).Return(database.BoundarySession{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionCreate) + })) + 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() + check.Args(uuid.Nil).Asserts(rbac.ResourceAuditLog, policy.ActionRead) + })) + s.Run("InsertBoundaryLog", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.InsertBoundaryLogParams{} + dbm.EXPECT().InsertBoundaryLog(gomock.Any(), arg).Return(database.BoundaryLog{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAuditLog, 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() + check.Args(uuid.Nil).Asserts(rbac.ResourceAuditLog, policy.ActionRead) + })) + s.Run("ListBoundaryLogsBySessionID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.ListBoundaryLogsBySessionIDParams{} + dbm.EXPECT().ListBoundaryLogsBySessionID(gomock.Any(), arg).Return([]database.BoundaryLog{}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionRead) + })) + s.Run("DeleteOldBoundaryLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().DeleteOldBoundaryLogs(gomock.Any(), database.DeleteOldBoundaryLogsParams{}).Return(int64(0), nil).AnyTimes() + check.Args(database.DeleteOldBoundaryLogsParams{}).Asserts(rbac.ResourceSystem, policy.ActionDelete) + })) +} + func (s *MethodTestSuite) TestConnectionLogs() { s.Run("BatchUpsertConnectionLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { arg := database.BatchUpsertConnectionLogsParams{} diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 200a2ed6dd..995294bebc 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -453,6 +453,34 @@ func ConnectionLog(t testing.TB, db database.Store, seed database.UpsertConnecti return database.ConnectionLog{} // unreachable } +func BoundarySession(t testing.TB, db database.Store, seed database.BoundarySession) database.BoundarySession { + session, err := db.InsertBoundarySession(genCtx, database.InsertBoundarySessionParams{ + ID: takeFirst(seed.ID, uuid.New()), + WorkspaceAgentID: takeFirst(seed.WorkspaceAgentID, uuid.New()), + ConfinedProcessName: takeFirst(seed.ConfinedProcessName, "claude-code"), + StartedAt: takeFirst(seed.StartedAt, dbtime.Now()), + UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()), + }) + require.NoError(t, err, "insert boundary session") + return session +} + +func BoundaryLog(t testing.TB, db database.Store, seed database.BoundaryLog) database.BoundaryLog { + log, err := db.InsertBoundaryLog(genCtx, database.InsertBoundaryLogParams{ + ID: takeFirst(seed.ID, uuid.New()), + SessionID: seed.SessionID, + SequenceNumber: takeFirst(seed.SequenceNumber, 0), + CapturedAt: takeFirst(seed.CapturedAt, dbtime.Now()), + CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), + Proto: takeFirst(seed.Proto, "http"), + Method: takeFirst(seed.Method, "GET"), + Detail: takeFirst(seed.Detail, "https://example.com"), + MatchedRule: seed.MatchedRule, + }) + require.NoError(t, err, "insert boundary log") + return log +} + func Template(t testing.TB, db database.Store, seed database.Template) database.Template { id := takeFirst(seed.ID, uuid.New()) if seed.GroupACL == nil { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index a53d7962c8..fd4537ccec 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -657,6 +657,14 @@ func (m queryMetricsStore) DeleteOldAuditLogs(ctx context.Context, arg database. return r0, r1 } +func (m queryMetricsStore) DeleteOldBoundaryLogs(ctx context.Context, arg database.DeleteOldBoundaryLogsParams) (int64, error) { + start := time.Now() + r0, r1 := m.s.DeleteOldBoundaryLogs(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteOldBoundaryLogs").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteOldBoundaryLogs").Inc() + return r0, r1 +} + func (m queryMetricsStore) DeleteOldChatDebugRuns(ctx context.Context, arg database.DeleteOldChatDebugRunsParams) (int64, error) { start := time.Now() r0, r1 := m.s.DeleteOldChatDebugRuns(ctx, arg) @@ -1249,6 +1257,22 @@ func (m queryMetricsStore) GetAuthorizationUserRoles(ctx context.Context, userID return r0, r1 } +func (m queryMetricsStore) GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (database.BoundaryLog, error) { + start := time.Now() + r0, r1 := m.s.GetBoundaryLogByID(ctx, id) + m.queryLatencies.WithLabelValues("GetBoundaryLogByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetBoundaryLogByID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (database.BoundarySession, error) { + start := time.Now() + r0, r1 := m.s.GetBoundarySessionByID(ctx, id) + m.queryLatencies.WithLabelValues("GetBoundarySessionByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetBoundarySessionByID").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatACLByID(ctx context.Context, id uuid.UUID) (database.GetChatACLByIDRow, error) { start := time.Now() r0, r1 := m.s.GetChatACLByID(ctx, id) @@ -3721,6 +3745,22 @@ func (m queryMetricsStore) InsertAuditLog(ctx context.Context, arg database.Inse return r0, r1 } +func (m queryMetricsStore) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) { + start := time.Now() + r0, r1 := m.s.InsertBoundaryLog(ctx, arg) + m.queryLatencies.WithLabelValues("InsertBoundaryLog").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertBoundaryLog").Inc() + return r0, r1 +} + +func (m queryMetricsStore) InsertBoundarySession(ctx context.Context, arg database.InsertBoundarySessionParams) (database.BoundarySession, error) { + start := time.Now() + r0, r1 := m.s.InsertBoundarySession(ctx, arg) + m.queryLatencies.WithLabelValues("InsertBoundarySession").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertBoundarySession").Inc() + return r0, r1 +} + func (m queryMetricsStore) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { start := time.Now() r0, r1 := m.s.InsertChat(ctx, arg) @@ -4361,6 +4401,14 @@ func (m queryMetricsStore) ListAIBridgeUserPromptsByInterceptionIDs(ctx context. return r0, r1 } +func (m queryMetricsStore) ListBoundaryLogsBySessionID(ctx context.Context, arg database.ListBoundaryLogsBySessionIDParams) ([]database.BoundaryLog, error) { + start := time.Now() + r0, r1 := m.s.ListBoundaryLogsBySessionID(ctx, arg) + m.queryLatencies.WithLabelValues("ListBoundaryLogsBySessionID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListBoundaryLogsBySessionID").Inc() + return r0, r1 +} + func (m queryMetricsStore) ListChatUsageLimitGroupOverrides(ctx context.Context) ([]database.ListChatUsageLimitGroupOverridesRow, error) { start := time.Now() r0, r1 := m.s.ListChatUsageLimitGroupOverrides(ctx) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index f0dde67907..36f8429e8f 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1102,6 +1102,21 @@ func (mr *MockStoreMockRecorder) DeleteOldAuditLogs(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAuditLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAuditLogs), ctx, arg) } +// DeleteOldBoundaryLogs mocks base method. +func (m *MockStore) DeleteOldBoundaryLogs(ctx context.Context, arg database.DeleteOldBoundaryLogsParams) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOldBoundaryLogs", ctx, arg) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteOldBoundaryLogs indicates an expected call of DeleteOldBoundaryLogs. +func (mr *MockStoreMockRecorder) DeleteOldBoundaryLogs(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldBoundaryLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldBoundaryLogs), ctx, arg) +} + // DeleteOldChatDebugRuns mocks base method. func (m *MockStore) DeleteOldChatDebugRuns(ctx context.Context, arg database.DeleteOldChatDebugRunsParams) (int64, error) { m.ctrl.T.Helper() @@ -2310,6 +2325,36 @@ func (mr *MockStoreMockRecorder) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspacesAndAgentsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspacesAndAgentsByOwnerID), ctx, ownerID, prepared) } +// GetBoundaryLogByID mocks base method. +func (m *MockStore) GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (database.BoundaryLog, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBoundaryLogByID", ctx, id) + ret0, _ := ret[0].(database.BoundaryLog) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBoundaryLogByID indicates an expected call of GetBoundaryLogByID. +func (mr *MockStoreMockRecorder) GetBoundaryLogByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoundaryLogByID", reflect.TypeOf((*MockStore)(nil).GetBoundaryLogByID), ctx, id) +} + +// GetBoundarySessionByID mocks base method. +func (m *MockStore) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (database.BoundarySession, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBoundarySessionByID", ctx, id) + ret0, _ := ret[0].(database.BoundarySession) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBoundarySessionByID indicates an expected call of GetBoundarySessionByID. +func (mr *MockStoreMockRecorder) GetBoundarySessionByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoundarySessionByID", reflect.TypeOf((*MockStore)(nil).GetBoundarySessionByID), ctx, id) +} + // GetChatACLByID mocks base method. func (m *MockStore) GetChatACLByID(ctx context.Context, id uuid.UUID) (database.GetChatACLByIDRow, error) { m.ctrl.T.Helper() @@ -6989,6 +7034,36 @@ func (mr *MockStoreMockRecorder) InsertAuditLog(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAuditLog", reflect.TypeOf((*MockStore)(nil).InsertAuditLog), ctx, arg) } +// InsertBoundaryLog mocks base method. +func (m *MockStore) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertBoundaryLog", ctx, arg) + ret0, _ := ret[0].(database.BoundaryLog) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertBoundaryLog indicates an expected call of InsertBoundaryLog. +func (mr *MockStoreMockRecorder) InsertBoundaryLog(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBoundaryLog", reflect.TypeOf((*MockStore)(nil).InsertBoundaryLog), ctx, arg) +} + +// InsertBoundarySession mocks base method. +func (m *MockStore) InsertBoundarySession(ctx context.Context, arg database.InsertBoundarySessionParams) (database.BoundarySession, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertBoundarySession", ctx, arg) + ret0, _ := ret[0].(database.BoundarySession) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertBoundarySession indicates an expected call of InsertBoundarySession. +func (mr *MockStoreMockRecorder) InsertBoundarySession(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBoundarySession", reflect.TypeOf((*MockStore)(nil).InsertBoundarySession), ctx, arg) +} + // InsertChat mocks base method. func (m *MockStore) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { m.ctrl.T.Helper() @@ -8249,6 +8324,21 @@ func (mr *MockStoreMockRecorder) ListAuthorizedAIBridgeSessions(ctx, arg, prepar return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAuthorizedAIBridgeSessions", reflect.TypeOf((*MockStore)(nil).ListAuthorizedAIBridgeSessions), ctx, arg, prepared) } +// ListBoundaryLogsBySessionID mocks base method. +func (m *MockStore) ListBoundaryLogsBySessionID(ctx context.Context, arg database.ListBoundaryLogsBySessionIDParams) ([]database.BoundaryLog, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListBoundaryLogsBySessionID", ctx, arg) + ret0, _ := ret[0].([]database.BoundaryLog) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListBoundaryLogsBySessionID indicates an expected call of ListBoundaryLogsBySessionID. +func (mr *MockStoreMockRecorder) ListBoundaryLogsBySessionID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListBoundaryLogsBySessionID", reflect.TypeOf((*MockStore)(nil).ListBoundaryLogsBySessionID), ctx, arg) +} + // ListChatUsageLimitGroupOverrides mocks base method. func (m *MockStore) ListChatUsageLimitGroupOverrides(ctx context.Context) ([]database.ListChatUsageLimitGroupOverridesRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 20a1b78a77..7fe8b7b80f 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1377,6 +1377,57 @@ CREATE TABLE audit_logs ( resource_icon text NOT NULL ); +CREATE TABLE boundary_logs ( + id uuid NOT NULL, + session_id uuid NOT NULL, + sequence_number integer NOT NULL, + captured_at timestamp with time zone NOT NULL, + created_at timestamp with time zone NOT NULL, + proto text DEFAULT ''::text NOT NULL, + method text DEFAULT ''::text NOT NULL, + detail text DEFAULT ''::text NOT NULL, + matched_rule text, + CONSTRAINT boundary_logs_sequence_number_check CHECK ((sequence_number >= 0)) +); + +COMMENT ON TABLE boundary_logs IS 'Persisted boundary audit events. Each row is a single audit event processed by a Boundary proxy.'; + +COMMENT ON COLUMN boundary_logs.session_id IS 'The session ID generated by the Boundary process on startup. Groups all events from one invocation.'; + +COMMENT ON COLUMN boundary_logs.sequence_number IS 'Monotonically increasing integer assigned by Boundary, starting at 0 per session. Primary ordering key when Boundary is in use.'; + +COMMENT ON COLUMN boundary_logs.captured_at IS 'When the log was sent to the DB.'; + +COMMENT ON COLUMN boundary_logs.created_at IS 'When the event happened on the workspace.'; + +COMMENT ON COLUMN boundary_logs.proto IS 'The protocol of the audited action. e.g. http, dns, git, fs.'; + +COMMENT ON COLUMN boundary_logs.method IS 'The operation within the protocol. e.g. GET/POST for http, clone for git, A for dns, read/write for fs.'; + +COMMENT ON COLUMN boundary_logs.detail IS 'Protocol-specific detail. e.g. the full URL for http, the hostname for dns, the path for fs.'; + +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.'; + +CREATE TABLE boundary_sessions ( + id uuid NOT NULL, + workspace_agent_id uuid NOT NULL, + confined_process_name text NOT NULL, + started_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +COMMENT ON TABLE boundary_sessions IS 'Boundary session metadata. Each row represents a single invocation of a Boundary process wrapping a confined agent.'; + +COMMENT ON COLUMN boundary_sessions.id IS 'The unique session ID generated by the Boundary process on startup.'; + +COMMENT ON COLUMN boundary_sessions.workspace_agent_id IS 'The workspace agent that this Boundary session is associated with.'; + +COMMENT ON COLUMN boundary_sessions.confined_process_name IS 'Name of the confined process (e.g. claude-code, codex, copilot).'; + +COMMENT ON COLUMN boundary_sessions.started_at IS 'Time when the first log for this session was received by coderd.'; + +COMMENT ON COLUMN boundary_sessions.updated_at IS 'Time when the session was last updated.'; + CREATE TABLE boundary_usage_stats ( replica_id uuid NOT NULL, unique_workspaces_count bigint DEFAULT 0 NOT NULL, @@ -3614,6 +3665,12 @@ ALTER TABLE ONLY api_keys ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); +ALTER TABLE ONLY boundary_logs + ADD CONSTRAINT boundary_logs_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY boundary_sessions + ADD CONSTRAINT boundary_sessions_pkey PRIMARY KEY (id); + ALTER TABLE ONLY boundary_usage_stats ADD CONSTRAINT boundary_usage_stats_pkey PRIMARY KEY (replica_id); @@ -4023,6 +4080,10 @@ CREATE INDEX idx_audit_log_user_id ON audit_logs USING btree (user_id); CREATE INDEX idx_audit_logs_time_desc ON audit_logs USING btree ("time" DESC); +CREATE INDEX idx_boundary_logs_captured_at ON boundary_logs USING btree (captured_at); + +CREATE INDEX idx_boundary_logs_session_seq ON boundary_logs USING btree (session_id, sequence_number); + CREATE INDEX idx_chat_debug_runs_chat_started ON chat_debug_runs USING btree (chat_id, started_at DESC); CREATE UNIQUE INDEX idx_chat_debug_runs_id_chat ON chat_debug_runs USING btree (id, chat_id); @@ -4365,6 +4426,12 @@ 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_session_id_fkey FOREIGN KEY (session_id) REFERENCES boundary_sessions(id) ON DELETE CASCADE; + +ALTER TABLE ONLY boundary_sessions + ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id); + ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index cc2c1ac485..47dba3d673 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -12,6 +12,8 @@ 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; + ForeignKeyBoundaryLogsSessionID ForeignKeyConstraint = "boundary_logs_session_id_fkey" // ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_session_id_fkey FOREIGN KEY (session_id) REFERENCES boundary_sessions(id) ON DELETE CASCADE; + 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); ForeignKeyChatDebugRunsChatID ForeignKeyConstraint = "chat_debug_runs_chat_id_fkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatDebugStepsChatID ForeignKeyConstraint = "chat_debug_steps_chat_id_fkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatDiffStatusesChatID ForeignKeyConstraint = "chat_diff_statuses_chat_id_fkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000507_boundary_sessions_and_logs.down.sql b/coderd/database/migrations/000507_boundary_sessions_and_logs.down.sql new file mode 100644 index 0000000000..452862cd94 --- /dev/null +++ b/coderd/database/migrations/000507_boundary_sessions_and_logs.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_boundary_logs_captured_at; +DROP INDEX IF EXISTS idx_boundary_logs_session_seq; +DROP TABLE IF EXISTS boundary_logs; +DROP TABLE IF EXISTS boundary_sessions; diff --git a/coderd/database/migrations/000507_boundary_sessions_and_logs.up.sql b/coderd/database/migrations/000507_boundary_sessions_and_logs.up.sql new file mode 100644 index 0000000000..043512fe75 --- /dev/null +++ b/coderd/database/migrations/000507_boundary_sessions_and_logs.up.sql @@ -0,0 +1,43 @@ +CREATE TABLE boundary_sessions ( + id UUID PRIMARY KEY, + workspace_agent_id UUID NOT NULL REFERENCES workspace_agents(id), + confined_process_name TEXT NOT NULL, + started_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +COMMENT ON TABLE boundary_sessions IS 'Boundary session metadata. Each row represents a single invocation of a Boundary process wrapping a confined agent.'; +COMMENT ON COLUMN boundary_sessions.id IS 'The unique session ID generated by the Boundary process on startup.'; +COMMENT ON COLUMN boundary_sessions.workspace_agent_id IS 'The workspace agent that this Boundary session is associated with.'; +COMMENT ON COLUMN boundary_sessions.confined_process_name IS 'Name of the confined process (e.g. claude-code, codex, copilot).'; +COMMENT ON COLUMN boundary_sessions.started_at IS 'Time when the first log for this session was received by coderd.'; +COMMENT ON COLUMN boundary_sessions.updated_at IS 'Time when the session was last updated.'; + +CREATE TABLE boundary_logs ( + id UUID NOT NULL, + session_id UUID NOT NULL REFERENCES boundary_sessions(id) ON DELETE CASCADE, + sequence_number INT NOT NULL CHECK (sequence_number >= 0), + captured_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + proto TEXT NOT NULL DEFAULT '', + method TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + matched_rule TEXT, + + PRIMARY KEY (id) +); + +COMMENT ON TABLE boundary_logs IS 'Persisted boundary audit events. Each row is a single audit event processed by a Boundary proxy.'; +COMMENT ON COLUMN boundary_logs.session_id IS 'The session ID generated by the Boundary process on startup. Groups all events from one invocation.'; +COMMENT ON COLUMN boundary_logs.sequence_number IS 'Monotonically increasing integer assigned by Boundary, starting at 0 per session. Primary ordering key when Boundary is in use.'; +COMMENT ON COLUMN boundary_logs.captured_at IS 'When the log was sent to the DB.'; +COMMENT ON COLUMN boundary_logs.created_at IS 'When the event happened on the workspace.'; +COMMENT ON COLUMN boundary_logs.proto IS 'The protocol of the audited action. e.g. http, dns, git, fs.'; +COMMENT ON COLUMN boundary_logs.method IS 'The operation within the protocol. e.g. GET/POST for http, clone for git, A for dns, read/write for fs.'; +COMMENT ON COLUMN boundary_logs.detail IS 'Protocol-specific detail. e.g. the full URL for http, the hostname for dns, the path for fs.'; +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.'; + +-- Ordering query path: list events for a session, sorted by sequence number. +CREATE INDEX idx_boundary_logs_session_seq ON boundary_logs (session_id, sequence_number); +-- Retention purge path: delete old rows by capture time. +CREATE INDEX idx_boundary_logs_captured_at ON boundary_logs (captured_at); diff --git a/coderd/database/migrations/testdata/fixtures/000507_boundary_sessions_and_logs.up.sql b/coderd/database/migrations/testdata/fixtures/000507_boundary_sessions_and_logs.up.sql new file mode 100644 index 0000000000..59979d26a8 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000507_boundary_sessions_and_logs.up.sql @@ -0,0 +1,35 @@ +INSERT INTO boundary_sessions ( + id, + workspace_agent_id, + confined_process_name, + started_at, + updated_at +) VALUES ( + 'a1b2c3d4-e5f6-4890-abcd-ef1234567890', + '45e89705-e09d-4850-bcec-f9a937f5d78d', + 'claude-code', + '2026-04-01 10:00:00+00', + '2026-04-01 10:00:00+00' +); + +INSERT INTO boundary_logs ( + id, + session_id, + sequence_number, + captured_at, + created_at, + proto, + method, + detail, + matched_rule +) VALUES ( + 'b2c3d4e5-f6a7-4901-bcde-f12345678901', + 'a1b2c3d4-e5f6-4890-abcd-ef1234567890', + 0, + '2026-04-01 10:00:01+00', + '2026-04-01 10:00:00+00', + 'http', + 'GET', + 'https://api.anthropic.com/v1/messages', + 'domain=api.anthropic.com' +); diff --git a/coderd/database/models.go b/coderd/database/models.go index 3eacf45aa7..593d89e4d1 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4510,6 +4510,41 @@ type AuditLog struct { ResourceIcon string `db:"resource_icon" json:"resource_icon"` } +// Persisted boundary audit events. Each row is a single audit event processed by a Boundary proxy. +type BoundaryLog struct { + ID uuid.UUID `db:"id" json:"id"` + // The session ID generated by the Boundary process on startup. Groups all events from one invocation. + SessionID uuid.UUID `db:"session_id" json:"session_id"` + // Monotonically increasing integer assigned by Boundary, starting at 0 per session. Primary ordering key when Boundary is in use. + SequenceNumber int32 `db:"sequence_number" json:"sequence_number"` + // When the log was sent to the DB. + CapturedAt time.Time `db:"captured_at" json:"captured_at"` + // When the event happened on the workspace. + CreatedAt time.Time `db:"created_at" json:"created_at"` + // The protocol of the audited action. e.g. http, dns, git, fs. + Proto string `db:"proto" json:"proto"` + // The operation within the protocol. e.g. GET/POST for http, clone for git, A for dns, read/write for fs. + Method string `db:"method" json:"method"` + // Protocol-specific detail. e.g. the full URL for http, the hostname for dns, the path for fs. + 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"` +} + +// Boundary session metadata. Each row represents a single invocation of a Boundary process wrapping a confined agent. +type BoundarySession struct { + // The unique session ID generated by the Boundary process on startup. + ID uuid.UUID `db:"id" json:"id"` + // The workspace agent that this Boundary session is associated with. + WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"` + // Name of the confined process (e.g. claude-code, codex, copilot). + ConfinedProcessName string `db:"confined_process_name" json:"confined_process_name"` + // Time when the first log for this session was received by coderd. + StartedAt time.Time `db:"started_at" json:"started_at"` + // Time when the session was last updated. + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + // Per-replica boundary usage statistics for telemetry aggregation. type BoundaryUsageStat struct { // The unique identifier of the replica reporting stats. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 5ea8ab21fa..6b16e0771a 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -152,6 +152,9 @@ type sqlcQuerier interface { // connection events (connect, disconnect, open, close) which are handled // separately by DeleteOldAuditLogConnectionEvents. DeleteOldAuditLogs(ctx context.Context, arg DeleteOldAuditLogsParams) (int64, error) + // Deletes boundary logs older than the given time, bounded by a row limit + // to avoid long-running transactions. + DeleteOldBoundaryLogs(ctx context.Context, arg DeleteOldBoundaryLogsParams) (int64, error) // updated_at is the retention clock, so the window starts after the run // stops being written to. // Intentionally no finished_at IS NOT NULL guard: abandoned in-flight rows @@ -313,6 +316,8 @@ type sqlcQuerier interface { // This function returns roles for authorization purposes. Implied member roles // are included. GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) + GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (BoundaryLog, error) + GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (BoundarySession, 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 @@ -915,6 +920,8 @@ type sqlcQuerier interface { // every member of the org. InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) + InsertBoundaryLog(ctx context.Context, arg InsertBoundaryLogParams) (BoundaryLog, error) + InsertBoundarySession(ctx context.Context, arg InsertBoundarySessionParams) (BoundarySession, error) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) // updated_at is the retention clock used by DeleteOldChatDebugRuns. // Set it on every write to keep retention semantics correct. @@ -1039,6 +1046,10 @@ type sqlcQuerier interface { ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeTokenUsage, error) ListAIBridgeToolUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeToolUsage, error) ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeUserPrompt, 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. + ListBoundaryLogsBySessionID(ctx context.Context, arg ListBoundaryLogsBySessionIDParams) ([]BoundaryLog, error) ListChatUsageLimitGroupOverrides(ctx context.Context) ([]ListChatUsageLimitGroupOverridesRow, error) ListChatUsageLimitOverrides(ctx context.Context) ([]ListChatUsageLimitOverridesRow, error) ListProvisionerKeysByOrganization(ctx context.Context, organizationID uuid.UUID) ([]ProvisionerKey, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 58f3bd5fc7..270f017e57 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3549,6 +3549,243 @@ func (q *sqlQuerier) InsertAuditLog(ctx context.Context, arg InsertAuditLogParam return i, err } +const deleteOldBoundaryLogs = `-- name: DeleteOldBoundaryLogs :execrows +WITH old_logs AS ( + SELECT id + FROM boundary_logs + WHERE captured_at < $1::timestamptz + ORDER BY captured_at ASC + LIMIT $2 +) +DELETE FROM boundary_logs +USING old_logs +WHERE boundary_logs.id = old_logs.id +` + +type DeleteOldBoundaryLogsParams struct { + BeforeTime time.Time `db:"before_time" json:"before_time"` + LimitCount int32 `db:"limit_count" json:"limit_count"` +} + +// Deletes boundary logs older than the given time, bounded by a row limit +// to avoid long-running transactions. +func (q *sqlQuerier) DeleteOldBoundaryLogs(ctx context.Context, arg DeleteOldBoundaryLogsParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteOldBoundaryLogs, arg.BeforeTime, arg.LimitCount) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +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 +` + +func (q *sqlQuerier) GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (BoundaryLog, error) { + row := q.db.QueryRowContext(ctx, getBoundaryLogByID, id) + var i BoundaryLog + err := row.Scan( + &i.ID, + &i.SessionID, + &i.SequenceNumber, + &i.CapturedAt, + &i.CreatedAt, + &i.Proto, + &i.Method, + &i.Detail, + &i.MatchedRule, + ) + return i, err +} + +const getBoundarySessionByID = `-- name: GetBoundarySessionByID :one +SELECT id, workspace_agent_id, confined_process_name, started_at, updated_at FROM boundary_sessions WHERE id = $1 +` + +func (q *sqlQuerier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (BoundarySession, error) { + row := q.db.QueryRowContext(ctx, getBoundarySessionByID, id) + var i BoundarySession + err := row.Scan( + &i.ID, + &i.WorkspaceAgentID, + &i.ConfinedProcessName, + &i.StartedAt, + &i.UpdatedAt, + ) + return i, err +} + +const insertBoundaryLog = `-- name: InsertBoundaryLog :one +INSERT INTO boundary_logs ( + id, + session_id, + sequence_number, + captured_at, + created_at, + proto, + method, + detail, + matched_rule +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 +) RETURNING id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule +` + +type InsertBoundaryLogParams struct { + ID uuid.UUID `db:"id" json:"id"` + SessionID uuid.UUID `db:"session_id" json:"session_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"` + Proto string `db:"proto" json:"proto"` + Method string `db:"method" json:"method"` + Detail string `db:"detail" json:"detail"` + MatchedRule sql.NullString `db:"matched_rule" json:"matched_rule"` +} + +func (q *sqlQuerier) InsertBoundaryLog(ctx context.Context, arg InsertBoundaryLogParams) (BoundaryLog, error) { + row := q.db.QueryRowContext(ctx, insertBoundaryLog, + arg.ID, + arg.SessionID, + arg.SequenceNumber, + arg.CapturedAt, + arg.CreatedAt, + arg.Proto, + arg.Method, + arg.Detail, + arg.MatchedRule, + ) + var i BoundaryLog + err := row.Scan( + &i.ID, + &i.SessionID, + &i.SequenceNumber, + &i.CapturedAt, + &i.CreatedAt, + &i.Proto, + &i.Method, + &i.Detail, + &i.MatchedRule, + ) + return i, err +} + +const insertBoundarySession = `-- name: InsertBoundarySession :one +INSERT INTO boundary_sessions ( + id, + workspace_agent_id, + confined_process_name, + started_at, + updated_at +) VALUES ( + $1, + $2, + $3, + $4, + $5 +) RETURNING id, workspace_agent_id, confined_process_name, started_at, updated_at +` + +type InsertBoundarySessionParams 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"` +} + +func (q *sqlQuerier) InsertBoundarySession(ctx context.Context, arg InsertBoundarySessionParams) (BoundarySession, error) { + row := q.db.QueryRowContext(ctx, insertBoundarySession, + arg.ID, + arg.WorkspaceAgentID, + arg.ConfinedProcessName, + arg.StartedAt, + arg.UpdatedAt, + ) + var i BoundarySession + err := row.Scan( + &i.ID, + &i.WorkspaceAgentID, + &i.ConfinedProcessName, + &i.StartedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listBoundaryLogsBySessionID = `-- name: ListBoundaryLogsBySessionID :many +SELECT id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule +FROM boundary_logs +WHERE + session_id = $1 + AND CASE + WHEN $2::int IS NOT NULL THEN sequence_number > $2 + ELSE true + END + AND CASE + WHEN $3::int IS NOT NULL THEN sequence_number < $3 + ELSE true + END +ORDER BY sequence_number ASC +LIMIT COALESCE(NULLIF($4::int, 0), 100) +` + +type ListBoundaryLogsBySessionIDParams struct { + SessionID uuid.UUID `db:"session_id" json:"session_id"` + SeqAfter sql.NullInt32 `db:"seq_after" json:"seq_after"` + SeqBefore sql.NullInt32 `db:"seq_before" json:"seq_before"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` +} + +// 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. +func (q *sqlQuerier) ListBoundaryLogsBySessionID(ctx context.Context, arg ListBoundaryLogsBySessionIDParams) ([]BoundaryLog, error) { + rows, err := q.db.QueryContext(ctx, listBoundaryLogsBySessionID, + arg.SessionID, + arg.SeqAfter, + arg.SeqBefore, + arg.LimitOpt, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BoundaryLog + for rows.Next() { + var i BoundaryLog + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.SequenceNumber, + &i.CapturedAt, + &i.CreatedAt, + &i.Proto, + &i.Method, + &i.Detail, + &i.MatchedRule, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getAndResetBoundaryUsageSummary = `-- name: GetAndResetBoundaryUsageSummary :one WITH deleted AS ( DELETE FROM boundary_usage_stats diff --git a/coderd/database/queries/boundarylogs.sql b/coderd/database/queries/boundarylogs.sql new file mode 100644 index 0000000000..d8c35fd7eb --- /dev/null +++ b/coderd/database/queries/boundarylogs.sql @@ -0,0 +1,76 @@ +-- name: InsertBoundarySession :one +INSERT INTO boundary_sessions ( + id, + workspace_agent_id, + confined_process_name, + started_at, + updated_at +) VALUES ( + @id, + @workspace_agent_id, + @confined_process_name, + @started_at, + @updated_at +) RETURNING *; + +-- name: GetBoundarySessionByID :one +SELECT * FROM boundary_sessions WHERE id = @id; + +-- name: InsertBoundaryLog :one +INSERT INTO boundary_logs ( + id, + session_id, + sequence_number, + captured_at, + created_at, + proto, + method, + detail, + matched_rule +) VALUES ( + @id, + @session_id, + @sequence_number, + @captured_at, + @created_at, + @proto, + @method, + @detail, + @matched_rule +) RETURNING *; + +-- name: GetBoundaryLogByID :one +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. +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') + ELSE true + END + AND CASE + WHEN sqlc.narg('seq_before')::int IS NOT NULL THEN sequence_number < sqlc.narg('seq_before') + ELSE true + END +ORDER BY sequence_number ASC +LIMIT COALESCE(NULLIF(@limit_opt::int, 0), 100); + +-- name: DeleteOldBoundaryLogs :execrows +-- Deletes boundary logs older than the given time, bounded by a row limit +-- to avoid long-running transactions. +WITH old_logs AS ( + SELECT id + FROM boundary_logs + WHERE captured_at < @before_time::timestamptz + ORDER BY captured_at ASC + LIMIT @limit_count +) +DELETE FROM boundary_logs +USING old_logs +WHERE boundary_logs.id = old_logs.id; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 104b842ea8..8ef517a9cb 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -17,6 +17,8 @@ const ( UniqueAibridgeUserPromptsPkey UniqueConstraint = "aibridge_user_prompts_pkey" // ALTER TABLE ONLY aibridge_user_prompts ADD CONSTRAINT aibridge_user_prompts_pkey PRIMARY KEY (id); UniqueAPIKeysPkey UniqueConstraint = "api_keys_pkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); + UniqueBoundaryLogsPkey UniqueConstraint = "boundary_logs_pkey" // ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_pkey PRIMARY KEY (id); + UniqueBoundarySessionsPkey UniqueConstraint = "boundary_sessions_pkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_pkey PRIMARY KEY (id); UniqueBoundaryUsageStatsPkey UniqueConstraint = "boundary_usage_stats_pkey" // ALTER TABLE ONLY boundary_usage_stats ADD CONSTRAINT boundary_usage_stats_pkey PRIMARY KEY (replica_id); UniqueChatDebugRunsPkey UniqueConstraint = "chat_debug_runs_pkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_pkey PRIMARY KEY (id); UniqueChatDebugStepsPkey UniqueConstraint = "chat_debug_steps_pkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_pkey PRIMARY KEY (id);