From d235001ec80c15722136e8b5cc4a062a666bbf4e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 3 Mar 2025 14:58:38 +0200 Subject: [PATCH 01/39] add migrations --- ..._add_workspace_app_audit_sessions.down.sql | 1 + ...01_add_workspace_app_audit_sessions.up.sql | 25 +++++++++++++++++++ ...01_add_workspace_app_audit_sessions.up.sql | 6 +++++ 3 files changed, 32 insertions(+) create mode 100644 coderd/database/migrations/000301_add_workspace_app_audit_sessions.down.sql create mode 100644 coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql diff --git a/coderd/database/migrations/000301_add_workspace_app_audit_sessions.down.sql b/coderd/database/migrations/000301_add_workspace_app_audit_sessions.down.sql new file mode 100644 index 0000000000000..f02436336f8dc --- /dev/null +++ b/coderd/database/migrations/000301_add_workspace_app_audit_sessions.down.sql @@ -0,0 +1 @@ +DROP TABLE workspace_app_audit_sessions; diff --git a/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql b/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql new file mode 100644 index 0000000000000..72af9e5a31395 --- /dev/null +++ b/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql @@ -0,0 +1,25 @@ +CREATE UNLOGGED TABLE workspace_app_audit_sessions ( + id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), + agent_id UUID NOT NULL, + app_id UUID NULL, + user_id UUID, + ip inet, + started_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (agent_id) REFERENCES workspace_agents (id) ON DELETE CASCADE, + FOREIGN KEY (app_id) REFERENCES workspace_apps (id) ON DELETE CASCADE +); + +COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, the data in this table is ephemeral and is used to track the current session of a user in a workspace app.'; +COMMENT ON COLUMN workspace_app_audit_sessions.id IS 'Unique identifier for the workspace app audit session.'; +COMMENT ON COLUMN workspace_app_audit_sessions.user_id IS 'The user that is currently using the workspace app. This is nullable because the app may be public.'; +COMMENT ON COLUMN workspace_app_audit_sessions.ip IS 'The IP address of the user that is currently using the workspace app.'; +COMMENT ON COLUMN workspace_app_audit_sessions.agent_id IS 'The agent that is currently in the workspace app.'; +COMMENT ON COLUMN workspace_app_audit_sessions.app_id IS 'The app that is currently in the workspace app. This is nullable because ports are not associated with an app.'; +COMMENT ON COLUMN workspace_app_audit_sessions.started_at IS 'The time the user started the session.'; +COMMENT ON COLUMN workspace_app_audit_sessions.updated_at IS 'The time the session was last updated.'; + +CREATE INDEX workspace_app_audit_sessions_agent_id_app_id ON workspace_app_audit_sessions (agent_id, app_id); + +COMMENT ON INDEX workspace_app_audit_sessions_agent_id_app_id IS 'Index for the agent_id and app_id columns to perform updates.'; diff --git a/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql b/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql new file mode 100644 index 0000000000000..a0e76dd41d792 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql @@ -0,0 +1,6 @@ +INSERT INTO workspace_app_audit_sessions + (agent_id, app_id, user_id, ip, started_at, updated_at) +VALUES + ('45e89705-e09d-4850-bcec-f9a937f5d78d', '36b65d0c-042b-4653-863a-655ee739861c', '30095c71-380b-457a-8995-97b8ee6e5307', '127.0.0.1', '2025-03-04 15:08:38.579772+02', '2025-03-04 15:06:48.755158+02'), + ('45e89705-e09d-4850-bcec-f9a937f5d78d', '36b65d0c-042b-4653-863a-655ee739861c', NULL, '127.0.0.1', '2025-03-04 15:08:44.411389+02', '2025-03-04 15:08:44.411389+02'), + ('45e89705-e09d-4850-bcec-f9a937f5d78d', NULL, NULL, '::1', '2025-03-04 15:25:55.555306+02', '2025-03-04 15:25:55.555306+02'); From b18740c67367232fb6ec5b72b5915bc8bb325eb9 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 3 Mar 2025 14:58:45 +0200 Subject: [PATCH 02/39] add queries --- coderd/database/queries/workspaceappaudit.sql | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 coderd/database/queries/workspaceappaudit.sql diff --git a/coderd/database/queries/workspaceappaudit.sql b/coderd/database/queries/workspaceappaudit.sql new file mode 100644 index 0000000000000..9d8c4f7e6eb8f --- /dev/null +++ b/coderd/database/queries/workspaceappaudit.sql @@ -0,0 +1,38 @@ +-- name: InsertWorkspaceAppAuditSession :one +INSERT INTO + workspace_app_audit_sessions ( + agent_id, + app_id, + user_id, + ip, + started_at, + updated_at + ) +VALUES + ( + $1, + $2, + $3, + $4, + $5, + $6 + ) +RETURNING + id; + +-- name: UpdateWorkspaceAppAuditSession :many +-- +-- Return ID to determine if a row was updated or not. This table isn't strict +-- about uniqueness, so we need to know if we updated an existing row or not. +UPDATE + workspace_app_audit_sessions +SET + updated_at = @updated_at +WHERE + agent_id = @agent_id + AND app_id IS NOT DISTINCT FROM @app_id + AND user_id IS NOT DISTINCT FROM @user_id + AND ip IS NOT DISTINCT FROM @ip + AND updated_at > NOW() - (@stale_interval_ms::bigint || ' ms')::interval +RETURNING + id; From 4dfb4fb0d0daee13c632b8765f06f22ccf8bfc3d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 3 Mar 2025 18:36:42 +0200 Subject: [PATCH 03/39] make gen --- coderd/database/dbauthz/dbauthz.go | 14 +++ coderd/database/dbmem/dbmem.go | 18 ++++ coderd/database/dbmetrics/querymetrics.go | 14 +++ coderd/database/dbmock/dbmock.go | 30 +++++++ coderd/database/dump.sql | 42 +++++++++ coderd/database/foreign_key_constraint.go | 3 + coderd/database/models.go | 18 ++++ coderd/database/querier.go | 5 ++ coderd/database/queries.sql.go | 102 ++++++++++++++++++++++ coderd/database/unique_constraint.go | 1 + scripts/dbgen/main.go | 2 +- 11 files changed, 248 insertions(+), 1 deletion(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index a4d76fa0198ed..7007fae0ae82c 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3439,6 +3439,13 @@ func (q *querier) InsertWorkspaceApp(ctx context.Context, arg database.InsertWor return q.db.InsertWorkspaceApp(ctx, arg) } +func (q *querier) InsertWorkspaceAppAuditSession(ctx context.Context, arg database.InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil { + return uuid.Nil, err + } + return q.db.InsertWorkspaceAppAuditSession(ctx, arg) +} + func (q *querier) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil { return err @@ -4269,6 +4276,13 @@ func (q *querier) UpdateWorkspaceAgentStartupByID(ctx context.Context, arg datab return q.db.UpdateWorkspaceAgentStartupByID(ctx, arg) } +func (q *querier) UpdateWorkspaceAppAuditSession(ctx context.Context, arg database.UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.UpdateWorkspaceAppAuditSession(ctx, arg) +} + func (q *querier) UpdateWorkspaceAppHealthByID(ctx context.Context, arg database.UpdateWorkspaceAppHealthByIDParams) error { // TODO: This is a workspace agent operation. Should users be able to query this? workspace, err := q.db.GetWorkspaceByWorkspaceAppID(ctx, arg.ID) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 7f7ff987ff544..369f57c94ea8a 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -9249,6 +9249,15 @@ func (q *FakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertW return workspaceApp, nil } +func (q *FakeQuerier) InsertWorkspaceAppAuditSession(ctx context.Context, arg database.InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { + err := validateDatabaseType(arg) + if err != nil { + return uuid.Nil, err + } + + panic("not implemented") +} + func (q *FakeQuerier) InsertWorkspaceAppStats(_ context.Context, arg database.InsertWorkspaceAppStatsParams) error { err := validateDatabaseType(arg) if err != nil { @@ -10995,6 +11004,15 @@ func (q *FakeQuerier) UpdateWorkspaceAgentStartupByID(_ context.Context, arg dat return sql.ErrNoRows } +func (q *FakeQuerier) UpdateWorkspaceAppAuditSession(_ context.Context, arg database.UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + panic("not implemented") +} + func (q *FakeQuerier) UpdateWorkspaceAppHealthByID(_ context.Context, arg database.UpdateWorkspaceAppHealthByIDParams) error { if err := validateDatabaseType(arg); err != nil { return err diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 0d021f978151b..392c9b14d7811 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2187,6 +2187,13 @@ func (m queryMetricsStore) InsertWorkspaceApp(ctx context.Context, arg database. return app, err } +func (m queryMetricsStore) InsertWorkspaceAppAuditSession(ctx context.Context, arg database.InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { + start := time.Now() + r0, r1 := m.s.InsertWorkspaceAppAuditSession(ctx, arg) + m.queryLatencies.WithLabelValues("InsertWorkspaceAppAuditSession").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error { start := time.Now() r0 := m.s.InsertWorkspaceAppStats(ctx, arg) @@ -2705,6 +2712,13 @@ func (m queryMetricsStore) UpdateWorkspaceAgentStartupByID(ctx context.Context, return err } +func (m queryMetricsStore) UpdateWorkspaceAppAuditSession(ctx context.Context, arg database.UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) { + start := time.Now() + r0, r1 := m.s.UpdateWorkspaceAppAuditSession(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspaceAppAuditSession").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) UpdateWorkspaceAppHealthByID(ctx context.Context, arg database.UpdateWorkspaceAppHealthByIDParams) error { start := time.Now() err := m.s.UpdateWorkspaceAppHealthByID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 6e07614f4cb3f..ab198e70ff435 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4616,6 +4616,21 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceApp(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceApp", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceApp), ctx, arg) } +// InsertWorkspaceAppAuditSession mocks base method. +func (m *MockStore) InsertWorkspaceAppAuditSession(ctx context.Context, arg database.InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertWorkspaceAppAuditSession", ctx, arg) + ret0, _ := ret[0].(uuid.UUID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertWorkspaceAppAuditSession indicates an expected call of InsertWorkspaceAppAuditSession. +func (mr *MockStoreMockRecorder) InsertWorkspaceAppAuditSession(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAppAuditSession", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAppAuditSession), ctx, arg) +} + // InsertWorkspaceAppStats mocks base method. func (m *MockStore) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error { m.ctrl.T.Helper() @@ -5718,6 +5733,21 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceAgentStartupByID(ctx, arg any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAgentStartupByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAgentStartupByID), ctx, arg) } +// UpdateWorkspaceAppAuditSession mocks base method. +func (m *MockStore) UpdateWorkspaceAppAuditSession(ctx context.Context, arg database.UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWorkspaceAppAuditSession", ctx, arg) + ret0, _ := ret[0].([]uuid.UUID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateWorkspaceAppAuditSession indicates an expected call of UpdateWorkspaceAppAuditSession. +func (mr *MockStoreMockRecorder) UpdateWorkspaceAppAuditSession(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAppAuditSession", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAppAuditSession), ctx, arg) +} + // UpdateWorkspaceAppHealthByID mocks base method. func (m *MockStore) UpdateWorkspaceAppHealthByID(ctx context.Context, arg database.UpdateWorkspaceAppHealthByIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 492aaefc12aa5..6136ca4169912 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1758,6 +1758,32 @@ COMMENT ON COLUMN workspace_agents.ready_at IS 'The time the agent entered the r COMMENT ON COLUMN workspace_agents.display_order IS 'Specifies the order in which to display agents in user interfaces.'; +CREATE UNLOGGED TABLE workspace_app_audit_sessions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + agent_id uuid NOT NULL, + app_id uuid, + user_id uuid, + ip inet, + started_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, the data in this table is ephemeral and is used to track the current session of a user in a workspace app.'; + +COMMENT ON COLUMN workspace_app_audit_sessions.id IS 'Unique identifier for the workspace app audit session.'; + +COMMENT ON COLUMN workspace_app_audit_sessions.agent_id IS 'The agent that is currently in the workspace app.'; + +COMMENT ON COLUMN workspace_app_audit_sessions.app_id IS 'The app that is currently in the workspace app. This is nullable because ports are not associated with an app.'; + +COMMENT ON COLUMN workspace_app_audit_sessions.user_id IS 'The user that is currently using the workspace app. This is nullable because the app may be '; + +COMMENT ON COLUMN workspace_app_audit_sessions.ip IS 'The IP address of the user that is currently using the workspace app.'; + +COMMENT ON COLUMN workspace_app_audit_sessions.started_at IS 'The time the user started the session.'; + +COMMENT ON COLUMN workspace_app_audit_sessions.updated_at IS 'The time the session was last updated.'; + CREATE TABLE workspace_app_stats ( id bigint NOT NULL, user_id uuid NOT NULL, @@ -2244,6 +2270,9 @@ ALTER TABLE ONLY workspace_agent_volume_resource_monitors ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_pkey PRIMARY KEY (id); +ALTER TABLE ONLY workspace_app_audit_sessions + ADD CONSTRAINT workspace_app_audit_sessions_pkey PRIMARY KEY (id); + ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_pkey PRIMARY KEY (id); @@ -2382,6 +2411,10 @@ CREATE INDEX workspace_agents_auth_token_idx ON workspace_agents USING btree (au CREATE INDEX workspace_agents_resource_id_idx ON workspace_agents USING btree (resource_id); +CREATE INDEX workspace_app_audit_sessions_agent_id_app_id ON workspace_app_audit_sessions USING btree (agent_id, app_id); + +COMMENT ON INDEX workspace_app_audit_sessions_agent_id_app_id IS 'Index for the agent_id and app_id columns to perform updates.'; + CREATE INDEX workspace_app_stats_workspace_id_idx ON workspace_app_stats USING btree (workspace_id); CREATE INDEX workspace_modules_created_at_idx ON workspace_modules USING btree (created_at); @@ -2664,6 +2697,15 @@ ALTER TABLE ONLY workspace_agent_volume_resource_monitors ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_resource_id_fkey FOREIGN KEY (resource_id) REFERENCES workspace_resources(id) ON DELETE CASCADE; +ALTER TABLE ONLY workspace_app_audit_sessions + ADD CONSTRAINT workspace_app_audit_sessions_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + +ALTER TABLE ONLY workspace_app_audit_sessions + ADD CONSTRAINT workspace_app_audit_sessions_app_id_fkey FOREIGN KEY (app_id) REFERENCES workspace_apps(id) ON DELETE CASCADE; + +ALTER TABLE ONLY workspace_app_audit_sessions + ADD CONSTRAINT workspace_app_audit_sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index f7044815852cd..b231644443f2c 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -66,6 +66,9 @@ const ( ForeignKeyWorkspaceAgentStartupLogsAgentID ForeignKeyConstraint = "workspace_agent_startup_logs_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_logs ADD CONSTRAINT workspace_agent_startup_logs_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentVolumeResourceMonitorsAgentID ForeignKeyConstraint = "workspace_agent_volume_resource_monitors_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_volume_resource_monitors ADD CONSTRAINT workspace_agent_volume_resource_monitors_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentsResourceID ForeignKeyConstraint = "workspace_agents_resource_id_fkey" // ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_resource_id_fkey FOREIGN KEY (resource_id) REFERENCES workspace_resources(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAppAuditSessionsAgentID ForeignKeyConstraint = "workspace_app_audit_sessions_agent_id_fkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAppAuditSessionsAppID ForeignKeyConstraint = "workspace_app_audit_sessions_app_id_fkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_app_id_fkey FOREIGN KEY (app_id) REFERENCES workspace_apps(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAppAuditSessionsUserID ForeignKeyConstraint = "workspace_app_audit_sessions_user_id_fkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyWorkspaceAppStatsAgentID ForeignKeyConstraint = "workspace_app_stats_agent_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); ForeignKeyWorkspaceAppStatsUserID ForeignKeyConstraint = "workspace_app_stats_user_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); ForeignKeyWorkspaceAppStatsWorkspaceID ForeignKeyConstraint = "workspace_app_stats_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); diff --git a/coderd/database/models.go b/coderd/database/models.go index e0064916b0135..e9d43ef62736a 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3434,6 +3434,24 @@ type WorkspaceApp struct { OpenIn WorkspaceAppOpenIn `db:"open_in" json:"open_in"` } +// Audit sessions for workspace apps, the data in this table is ephemeral and is used to track the current session of a user in a workspace app. +type WorkspaceAppAuditSession struct { + // Unique identifier for the workspace app audit session. + ID uuid.UUID `db:"id" json:"id"` + // The agent that is currently in the workspace app. + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + // The app that is currently in the workspace app. This is nullable because ports are not associated with an app. + AppID uuid.NullUUID `db:"app_id" json:"app_id"` + // The user that is currently using the workspace app. This is nullable because the app may be + UserID uuid.NullUUID `db:"user_id" json:"user_id"` + // The IP address of the user that is currently using the workspace app. + Ip pqtype.Inet `db:"ip" json:"ip"` + // The time the user started the session. + StartedAt time.Time `db:"started_at" json:"started_at"` + // The time the session was last updated. + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + // A record of workspace app usage statistics type WorkspaceAppStat struct { // The ID of the record diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 28227797c7e3f..dbf13b49c800d 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -459,6 +459,7 @@ type sqlcQuerier interface { InsertWorkspaceAgentScripts(ctx context.Context, arg InsertWorkspaceAgentScriptsParams) ([]WorkspaceAgentScript, error) InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) + InsertWorkspaceAppAuditSession(ctx context.Context, arg InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) InsertWorkspaceAppStats(ctx context.Context, arg InsertWorkspaceAppStatsParams) error InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error InsertWorkspaceBuildParameters(ctx context.Context, arg InsertWorkspaceBuildParametersParams) error @@ -544,6 +545,10 @@ type sqlcQuerier interface { UpdateWorkspaceAgentLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentLogOverflowByIDParams) error UpdateWorkspaceAgentMetadata(ctx context.Context, arg UpdateWorkspaceAgentMetadataParams) error UpdateWorkspaceAgentStartupByID(ctx context.Context, arg UpdateWorkspaceAgentStartupByIDParams) error + // + // Return ID to determine if a row was updated or not. This table isn't strict + // about uniqueness, so we need to know if we updated an existing row or not. + UpdateWorkspaceAppAuditSession(ctx context.Context, arg UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg UpdateWorkspaceAutomaticUpdatesParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2d38ab38b0f25..15694d915204f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -14560,6 +14560,108 @@ func (q *sqlQuerier) InsertWorkspaceAgentStats(ctx context.Context, arg InsertWo return err } +const insertWorkspaceAppAuditSession = `-- name: InsertWorkspaceAppAuditSession :one +INSERT INTO + workspace_app_audit_sessions ( + agent_id, + app_id, + user_id, + ip, + started_at, + updated_at + ) +VALUES + ( + $1, + $2, + $3, + $4, + $5, + $6 + ) +RETURNING + id +` + +type InsertWorkspaceAppAuditSessionParams struct { + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + AppID uuid.NullUUID `db:"app_id" json:"app_id"` + UserID uuid.NullUUID `db:"user_id" json:"user_id"` + Ip pqtype.Inet `db:"ip" json:"ip"` + StartedAt time.Time `db:"started_at" json:"started_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (q *sqlQuerier) InsertWorkspaceAppAuditSession(ctx context.Context, arg InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { + row := q.db.QueryRowContext(ctx, insertWorkspaceAppAuditSession, + arg.AgentID, + arg.AppID, + arg.UserID, + arg.Ip, + arg.StartedAt, + arg.UpdatedAt, + ) + var id uuid.UUID + err := row.Scan(&id) + return id, err +} + +const updateWorkspaceAppAuditSession = `-- name: UpdateWorkspaceAppAuditSession :many +UPDATE + workspace_app_audit_sessions +SET + updated_at = $1 +WHERE + agent_id = $2 + AND app_id IS NOT DISTINCT FROM $3 + AND user_id IS NOT DISTINCT FROM $4 + AND ip IS NOT DISTINCT FROM $5 + AND updated_at > NOW() - ($6::bigint || ' ms')::interval +RETURNING + id +` + +type UpdateWorkspaceAppAuditSessionParams struct { + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + AppID uuid.NullUUID `db:"app_id" json:"app_id"` + UserID uuid.NullUUID `db:"user_id" json:"user_id"` + Ip pqtype.Inet `db:"ip" json:"ip"` + StaleIntervalMS int64 `db:"stale_interval_ms" json:"stale_interval_ms"` +} + +// Return ID to determine if a row was updated or not. This table isn't strict +// about uniqueness, so we need to know if we updated an existing row or not. +func (q *sqlQuerier) UpdateWorkspaceAppAuditSession(ctx context.Context, arg UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) { + rows, err := q.db.QueryContext(ctx, updateWorkspaceAppAuditSession, + arg.UpdatedAt, + arg.AgentID, + arg.AppID, + arg.UserID, + arg.Ip, + arg.StaleIntervalMS, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWorkspaceAppByAgentIDAndSlug = `-- name: GetWorkspaceAppByAgentIDAndSlug :one SELECT id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in FROM workspace_apps WHERE agent_id = $1 AND slug = $2 ` diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index b2c814241d55a..10a6b4c77386b 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -79,6 +79,7 @@ const ( UniqueWorkspaceAgentStartupLogsPkey UniqueConstraint = "workspace_agent_startup_logs_pkey" // ALTER TABLE ONLY workspace_agent_logs ADD CONSTRAINT workspace_agent_startup_logs_pkey PRIMARY KEY (id); UniqueWorkspaceAgentVolumeResourceMonitorsPkey UniqueConstraint = "workspace_agent_volume_resource_monitors_pkey" // ALTER TABLE ONLY workspace_agent_volume_resource_monitors ADD CONSTRAINT workspace_agent_volume_resource_monitors_pkey PRIMARY KEY (agent_id, path); UniqueWorkspaceAgentsPkey UniqueConstraint = "workspace_agents_pkey" // ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_pkey PRIMARY KEY (id); + UniqueWorkspaceAppAuditSessionsPkey UniqueConstraint = "workspace_app_audit_sessions_pkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_pkey PRIMARY KEY (id); UniqueWorkspaceAppStatsPkey UniqueConstraint = "workspace_app_stats_pkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_pkey PRIMARY KEY (id); UniqueWorkspaceAppStatsUserIDAgentIDSessionIDKey UniqueConstraint = "workspace_app_stats_user_id_agent_id_session_id_key" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_agent_id_session_id_key UNIQUE (user_id, agent_id, session_id); UniqueWorkspaceAppsAgentIDSlugIndex UniqueConstraint = "workspace_apps_agent_id_slug_idx" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug); diff --git a/scripts/dbgen/main.go b/scripts/dbgen/main.go index 4ec08920e9741..5070b0a42aa15 100644 --- a/scripts/dbgen/main.go +++ b/scripts/dbgen/main.go @@ -340,7 +340,7 @@ func orderAndStubDatabaseFunctions(filePath, receiver, structName string, stub f }) for _, r := range fn.Func.Results.List { switch typ := r.Type.(type) { - case *dst.StarExpr, *dst.ArrayType: + case *dst.StarExpr, *dst.ArrayType, *dst.SelectorExpr: returnStmt.Results = append(returnStmt.Results, dst.NewIdent("nil")) case *dst.Ident: if typ.Path != "" { From 7d7922c0ad08d16fd69ed687de2be4a22702b04f Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 4 Mar 2025 15:32:16 +0200 Subject: [PATCH 04/39] add user-agent support to background audit --- coderd/audit/request.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 1621c91762435..536c91ea687d0 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -71,6 +71,7 @@ type BackgroundAuditParams[T Auditable] struct { Action database.AuditAction OrganizationID uuid.UUID IP string + UserAgent string // todo: this should automatically marshal an interface{} instead of accepting a raw message. AdditionalFields json.RawMessage @@ -479,7 +480,7 @@ func BackgroundAudit[T Auditable](ctx context.Context, p *BackgroundAuditParams[ UserID: p.UserID, OrganizationID: requireOrgID[T](ctx, p.OrganizationID, p.Log), Ip: ip, - UserAgent: sql.NullString{}, + UserAgent: sql.NullString{Valid: p.UserAgent != "", String: p.UserAgent}, ResourceType: either(p.Old, p.New, ResourceType[T], p.Action), ResourceID: either(p.Old, p.New, ResourceID[T], p.Action), ResourceTarget: either(p.Old, p.New, ResourceTarget[T], p.Action), From 19ca904c5dcdddaa8780d9c2021baa1fe6eff7fb Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 4 Mar 2025 15:32:45 +0200 Subject: [PATCH 05/39] add done func support for tracing response writer --- coderd/tracing/status_writer.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/coderd/tracing/status_writer.go b/coderd/tracing/status_writer.go index e9337c20e022f..277b3daff0f09 100644 --- a/coderd/tracing/status_writer.go +++ b/coderd/tracing/status_writer.go @@ -27,6 +27,7 @@ type StatusWriter struct { http.ResponseWriter Status int Hijacked bool + Done []func() // If non-nil, this function will be called when the handler is done. responseBody []byte wroteHeader bool @@ -37,6 +38,9 @@ func StatusWriterMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { sw := &StatusWriter{ResponseWriter: rw} next.ServeHTTP(sw, r) + for _, done := range sw.Done { + done() + } }) } From 30bb732c860bb9df3a06a51a94ff00f9eea2a73d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 3 Mar 2025 15:46:37 +0200 Subject: [PATCH 06/39] wip auditor --- coderd/audit/request.go | 6 +- coderd/coderd.go | 21 ++-- coderd/workspaceapps/db.go | 199 +++++++++++++++++++++++++++++++- coderd/workspaceapps/request.go | 9 +- 4 files changed, 218 insertions(+), 17 deletions(-) diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 536c91ea687d0..d837d30518805 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -423,7 +423,7 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request action = req.Action } - ip := parseIP(p.Request.RemoteAddr) + ip := ParseIP(p.Request.RemoteAddr) auditLog := database.AuditLog{ ID: uuid.New(), Time: dbtime.Now(), @@ -454,7 +454,7 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request // BackgroundAudit creates an audit log for a background event. // The audit log is committed upon invocation. func BackgroundAudit[T Auditable](ctx context.Context, p *BackgroundAuditParams[T]) { - ip := parseIP(p.IP) + ip := ParseIP(p.IP) diff := Diff(p.Audit, p.Old, p.New) var err error @@ -567,7 +567,7 @@ func either[T Auditable, R any](old, new T, fn func(T) R, auditAction database.A panic("both old and new are nil") } -func parseIP(ipStr string) pqtype.Inet { +func ParseIP(ipStr string) pqtype.Inet { ip := net.ParseIP(ipStr) ipNet := net.IPNet{} if ip != nil { diff --git a/coderd/coderd.go b/coderd/coderd.go index ab8e99d29dea8..a17dc38a79258 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -534,16 +534,6 @@ func New(options *Options) *API { Authorizer: options.Authorizer, Logger: options.Logger, }, - WorkspaceAppsProvider: workspaceapps.NewDBTokenProvider( - options.Logger.Named("workspaceapps"), - options.AccessURL, - options.Authorizer, - options.Database, - options.DeploymentValues, - oauthConfigs, - options.AgentInactiveDisconnectTimeout, - options.AppSigningKeyCache, - ), metricsCache: metricsCache, Auditor: atomic.Pointer[audit.Auditor]{}, TailnetCoordinator: atomic.Pointer[tailnet.Coordinator]{}, @@ -561,6 +551,17 @@ func New(options *Options) *API { ), dbRolluper: options.DatabaseRolluper, } + api.WorkspaceAppsProvider = workspaceapps.NewDBTokenProvider( + options.Logger.Named("workspaceapps"), + options.AccessURL, + options.Authorizer, + &api.Auditor, + options.Database, + options.DeploymentValues, + oauthConfigs, + options.AgentInactiveDisconnectTimeout, + options.AppSigningKeyCache, + ) f := appearance.NewDefaultFetcher(api.DeploymentValues.DocsURL.String()) api.AppearanceFetcher.Store(&f) diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 602983959948d..3296c2fb87d59 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -3,27 +3,33 @@ package workspaceapps import ( "context" "database/sql" + "encoding/json" "fmt" "net/http" "net/url" "path" "slices" "strings" + "sync/atomic" "time" - "golang.org/x/xerrors" - "github.com/go-jose/go-jose/v4/jwt" + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" + "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/cryptokeys" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/jwtutils" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/codersdk" ) @@ -35,6 +41,7 @@ type DBTokenProvider struct { // DashboardURL is the main dashboard access URL for error pages. DashboardURL *url.URL Authorizer rbac.Authorizer + Auditor *atomic.Pointer[audit.Auditor] Database database.Store DeploymentValues *codersdk.DeploymentValues OAuth2Configs *httpmw.OAuth2Configs @@ -47,6 +54,7 @@ var _ SignedTokenProvider = &DBTokenProvider{} func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authorizer, + auditor *atomic.Pointer[audit.Auditor], db database.Store, cfg *codersdk.DeploymentValues, oauth2Cfgs *httpmw.OAuth2Configs, @@ -61,6 +69,7 @@ func NewDBTokenProvider(log slog.Logger, Logger: log, DashboardURL: accessURL, Authorizer: authz, + Auditor: auditor, Database: db, DeploymentValues: cfg, OAuth2Configs: oauth2Cfgs, @@ -81,6 +90,8 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r * // // permissions. dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx) + aReq := p.auditInitAutocommitRequest(ctx, rw, r) + appReq := issueReq.AppRequest.Normalize() err := appReq.Check() if err != nil { @@ -111,6 +122,8 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r * return nil, "", false } + aReq.apiKey = apiKey // Update audit request. + // Lookup workspace app details from DB. dbReq, err := appReq.getDatabase(dangerousSystemCtx, p.Database) if xerrors.Is(err, sql.ErrNoRows) { @@ -123,6 +136,9 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r * WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "get app details from database") return nil, "", false } + + aReq.dbReq = dbReq // Update audit request. + token.UserID = dbReq.User.ID token.WorkspaceID = dbReq.Workspace.ID token.AgentID = dbReq.Agent.ID @@ -133,6 +149,7 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r * // Verify the user has access to the app. authed, warnings, err := p.authorizeRequest(r.Context(), authz, dbReq) if err != nil { + // TODO(mafredri): Audit? WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "verify authz") return nil, "", false } @@ -341,3 +358,181 @@ func (p *DBTokenProvider) authorizeRequest(ctx context.Context, roles *rbac.Subj // No checks were successful. return false, warnings, nil } + +type auditRequest struct { + time time.Time + ip pqtype.Inet + apiKey *database.APIKey + dbReq *databaseRequest +} + +// auditInitAutocommitRequest creates a new audit session and audit log for the +// given request, if one does not already exist. If an audit session already +// exists, it will be updated with the current timestamp. A session is used to +// reduce the number of audit logs created. +// +// A session is unique to the agent, app, user and users IP. If any of these +// values change, a new session and audit log is created. +func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (aReq *auditRequest) { + // Get the status writer from the request context so we can figure + // out the HTTP status and autocommit the audit log. + sw, ok := w.(*tracing.StatusWriter) + if !ok { + panic("dev error: http.ResponseWriter is not *tracing.StatusWriter") + } + + aReq = &auditRequest{ + time: dbtime.Now(), + ip: audit.ParseIP(r.RemoteAddr), + } + + // Set the commit function on the status writer to create an audit + // log, this ensures that the status and response body are available. + sw.Done = append(sw.Done, func() { + p.Logger.Info(ctx, "workspace app audit session", slog.F("status", sw.Status), slog.F("body", string(sw.ResponseBody())), slog.F("api_key", aReq.apiKey), slog.F("db_req", aReq.dbReq)) + + if sw.Status == http.StatusSeeOther { + // Redirects aren't interesting as we will capture the audit + // log after the redirect. + // + // There's a case where we call httpmw.RedirectToLogin for + // path-based apps the user doesn't have access to, in which + // case the dashboard login redirect is used and we end up + // not hitting the workspaceapps API again due to dashboard + // showing 404. (Bug?) + return + } + + if aReq.dbReq == nil { + // App doesn't exist, there's information in the Request + // struct but we need UUIDs for audit logging. + return + } + + type additionalFields struct { + audit.AdditionalFields + App string `json:"app"` + } + appInfo := additionalFields{ + AdditionalFields: audit.AdditionalFields{ + WorkspaceOwner: aReq.dbReq.Workspace.OwnerUsername, + WorkspaceName: aReq.dbReq.Workspace.Name, + WorkspaceID: aReq.dbReq.Workspace.ID, + }, + App: aReq.dbReq.AppSlugOrPort, + } + + appInfoBytes, err := json.Marshal(appInfo) + if err != nil { + p.Logger.Error(ctx, "marshal additional fields failed", slog.Error(err)) + } + + userID := uuid.NullUUID{} + if aReq.apiKey != nil { + userID = uuid.NullUUID{Valid: true, UUID: aReq.apiKey.UserID} + } + + var ( + updatedIDs []uuid.UUID + sessionID = uuid.Nil + ) + err = p.Database.InTx(func(tx database.Store) error { + // nolint:gocritic // System context is needed to write audit sessions. + dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx) + + updatedIDs, err = tx.UpdateWorkspaceAppAuditSession(dangerousSystemCtx, database.UpdateWorkspaceAppAuditSessionParams{ + AgentID: aReq.dbReq.Agent.ID, + AppID: uuid.NullUUID{Valid: aReq.dbReq.App.ID != uuid.Nil, UUID: aReq.dbReq.App.ID}, + UserID: userID, + Ip: aReq.ip, + UpdatedAt: aReq.time, + StaleIntervalMS: (2 * time.Hour).Milliseconds(), + }) + if err != nil { + return xerrors.Errorf("update workspace app audit session: %w", err) + } + if len(updatedIDs) > 0 { + // Session is valid and got updated, no need to create a new audit log. + return nil + } + + sessionID, err = tx.InsertWorkspaceAppAuditSession(dangerousSystemCtx, database.InsertWorkspaceAppAuditSessionParams{ + AgentID: aReq.dbReq.Agent.ID, + AppID: uuid.NullUUID{Valid: aReq.dbReq.App.ID != uuid.Nil, UUID: aReq.dbReq.App.ID}, + UserID: userID, + Ip: aReq.ip, + StartedAt: aReq.time, + UpdatedAt: aReq.time, + }) + if err != nil { + return xerrors.Errorf("insert workspace app audit session: %w", err) + } + + return nil + }, nil) + if err != nil { + p.Logger.Error(ctx, "update workspace app audit session failed", slog.Error(err)) + } + + p.Logger.Info(ctx, "workspace app audit session", slog.F("session_id", sessionID), slog.F("status", sw.Status), slog.F("body", string(sw.ResponseBody()))) + + if sessionID == uuid.Nil { + if sw.Status < 400 { + // Session was updated and no error occurred, no need to + // create a new audit log. + return + } + if len(updatedIDs) > 0 { + // Session was updated but an error occurred, we need to + // create a new audit log. + sessionID = updatedIDs[0] + } else { + // This shouldn't happen, but fall-back to request so it + // can be correlated to _something_. + sessionID = httpmw.RequestID(r) + } + } + + // We use the background audit function instead of init request + // here because we don't know the resource type ahead of time. + // This also allows us to log unauthenticated access. + auditor := *p.Auditor.Load() + switch { + case aReq.dbReq.App.ID != uuid.Nil: + audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceApp]{ + Audit: auditor, + Log: p.Logger, + + Action: database.AuditActionOpen, + OrganizationID: aReq.dbReq.Workspace.OrganizationID, + UserID: userID.UUID, + RequestID: sessionID, + Time: aReq.time, + Status: sw.Status, + IP: aReq.ip.IPNet.IP.String(), + UserAgent: r.UserAgent(), + New: aReq.dbReq.App, + AdditionalFields: appInfoBytes, + }) + default: + // Web terminal, port app, etc. + audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceAgent]{ + Audit: auditor, + Log: p.Logger, + + Action: database.AuditActionOpen, + OrganizationID: aReq.dbReq.Workspace.OrganizationID, + UserID: userID.UUID, + RequestID: sessionID, + Time: aReq.time, + Status: sw.Status, + IP: aReq.ip.IPNet.IP.String(), + UserAgent: r.UserAgent(), + New: aReq.dbReq.Agent, + AdditionalFields: appInfoBytes, + }) + } + }) + + return aReq +} diff --git a/coderd/workspaceapps/request.go b/coderd/workspaceapps/request.go index 0833ab731fe67..0e6a43cb4cbe4 100644 --- a/coderd/workspaceapps/request.go +++ b/coderd/workspaceapps/request.go @@ -195,6 +195,8 @@ type databaseRequest struct { Workspace database.Workspace // Agent is the agent that the app is running on. Agent database.WorkspaceAgent + // App is the app that the user is trying to access. + App database.WorkspaceApp // AppURL is the resolved URL to the workspace app. This is only set for non // terminal requests. @@ -288,6 +290,7 @@ func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseR // in the workspace or not. var ( agentNameOrID = r.AgentNameOrID + app database.WorkspaceApp appURL string appSharingLevel database.AppSharingLevel // First check if it's a port-based URL with an optional "s" suffix for HTTPS. @@ -353,8 +356,9 @@ func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseR appSharingLevel = ps.ShareLevel } } else { - for _, app := range apps { - if app.Slug == r.AppSlugOrPort { + for _, a := range apps { + if a.Slug == r.AppSlugOrPort { + app = a if !app.Url.Valid { return nil, xerrors.Errorf("app URL is not valid") } @@ -410,6 +414,7 @@ func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseR User: user, Workspace: workspace, Agent: agent, + App: app, AppURL: appURLParsed, AppSharingLevel: appSharingLevel, }, nil From 94ddbbe32fe74904ad1cb9a1fa9e622e9c547d6b Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 4 Mar 2025 16:52:53 +0200 Subject: [PATCH 07/39] linttt --- coderd/database/dbmem/dbmem.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 369f57c94ea8a..64af9c1eb3296 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -9249,7 +9249,7 @@ func (q *FakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertW return workspaceApp, nil } -func (q *FakeQuerier) InsertWorkspaceAppAuditSession(ctx context.Context, arg database.InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { +func (*FakeQuerier) InsertWorkspaceAppAuditSession(_ context.Context, arg database.InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { err := validateDatabaseType(arg) if err != nil { return uuid.Nil, err @@ -11004,7 +11004,7 @@ func (q *FakeQuerier) UpdateWorkspaceAgentStartupByID(_ context.Context, arg dat return sql.ErrNoRows } -func (q *FakeQuerier) UpdateWorkspaceAppAuditSession(_ context.Context, arg database.UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) { +func (*FakeQuerier) UpdateWorkspaceAppAuditSession(_ context.Context, arg database.UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) { err := validateDatabaseType(arg) if err != nil { return nil, err From bef761401b652d2eccf483730517a0d690c975e8 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 4 Mar 2025 19:35:51 +0200 Subject: [PATCH 08/39] dbmem impl --- coderd/database/dbmem/dbmem.go | 48 +++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 64af9c1eb3296..8114ca42c9c6e 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -92,6 +92,7 @@ func New() database.Store { workspaceAgentLogs: make([]database.WorkspaceAgentLog, 0), workspaceBuilds: make([]database.WorkspaceBuild, 0), workspaceApps: make([]database.WorkspaceApp, 0), + workspaceAppAuditSessions: make([]database.WorkspaceAppAuditSession, 0), workspaces: make([]database.WorkspaceTable, 0), workspaceProxies: make([]database.WorkspaceProxy, 0), }, @@ -237,6 +238,7 @@ type data struct { workspaceAgentMemoryResourceMonitors []database.WorkspaceAgentMemoryResourceMonitor workspaceAgentVolumeResourceMonitors []database.WorkspaceAgentVolumeResourceMonitor workspaceApps []database.WorkspaceApp + workspaceAppAuditSessions []database.WorkspaceAppAuditSession workspaceAppStatsLastInsertID int64 workspaceAppStats []database.WorkspaceAppStat workspaceBuilds []database.WorkspaceBuild @@ -9249,13 +9251,27 @@ func (q *FakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertW return workspaceApp, nil } -func (*FakeQuerier) InsertWorkspaceAppAuditSession(_ context.Context, arg database.InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { +func (q *FakeQuerier) InsertWorkspaceAppAuditSession(_ context.Context, arg database.InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { err := validateDatabaseType(arg) if err != nil { return uuid.Nil, err } - panic("not implemented") + q.mutex.Lock() + defer q.mutex.Unlock() + + id := uuid.New() + q.workspaceAppAuditSessions = append(q.workspaceAppAuditSessions, database.WorkspaceAppAuditSession{ + ID: id, + AgentID: arg.AgentID, + AppID: arg.AppID, + UserID: arg.UserID, + Ip: arg.Ip, + StartedAt: arg.StartedAt, + UpdatedAt: arg.UpdatedAt, + }) + + return id, nil } func (q *FakeQuerier) InsertWorkspaceAppStats(_ context.Context, arg database.InsertWorkspaceAppStatsParams) error { @@ -11004,13 +11020,37 @@ func (q *FakeQuerier) UpdateWorkspaceAgentStartupByID(_ context.Context, arg dat return sql.ErrNoRows } -func (*FakeQuerier) UpdateWorkspaceAppAuditSession(_ context.Context, arg database.UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) { +func (q *FakeQuerier) UpdateWorkspaceAppAuditSession(_ context.Context, arg database.UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) { err := validateDatabaseType(arg) if err != nil { return nil, err } - panic("not implemented") + q.mutex.Lock() + defer q.mutex.Unlock() + + var updated []uuid.UUID + for i, s := range q.workspaceAppAuditSessions { + if s.AgentID != arg.AgentID { + continue + } + if s.AppID != arg.AppID { + continue + } + if s.UserID != arg.UserID { + continue + } + if s.Ip.IPNet.String() != arg.Ip.IPNet.String() { + continue + } + staleTime := dbtime.Now().Add(-(time.Duration(arg.StaleIntervalMS) * time.Millisecond)) + if !s.UpdatedAt.After(staleTime) { + continue + } + q.workspaceAppAuditSessions[i].UpdatedAt = arg.UpdatedAt + updated = append(updated, s.ID) + } + return updated, nil } func (q *FakeQuerier) UpdateWorkspaceAppHealthByID(_ context.Context, arg database.UpdateWorkspaceAppHealthByIDParams) error { From d13d3c09fbef3a4bcf18c5b685a0f74a3b8a63f4 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 4 Mar 2025 23:05:11 +0200 Subject: [PATCH 09/39] resolve request with middleware --- coderd/workspaceapps/db_test.go | 44 +++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index bf364f1ce62b3..5b12e694dfa66 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -22,6 +22,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/jwtutils" + "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/coderd/workspaceapps" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/codersdk" @@ -30,6 +31,13 @@ import ( "github.com/coder/coder/v2/testutil" ) +func resolveRequestWithStatusMW(w http.ResponseWriter, r *http.Request, opts workspaceapps.ResolveRequestOptions) (token *workspaceapps.SignedToken, ok bool) { + tracing.StatusWriterMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok = workspaceapps.ResolveRequest(w, r, opts) + })).ServeHTTP(w, r) + return token, ok +} + func Test_ResolveRequest(t *testing.T) { t.Parallel() @@ -259,7 +267,7 @@ func Test_ResolveRequest(t *testing.T) { r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) // Try resolving the request without a token. - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -308,7 +316,7 @@ func Test_ResolveRequest(t *testing.T) { r = httptest.NewRequest("GET", "/app", nil) r.AddCookie(cookie) - secondToken, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + secondToken, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -344,7 +352,7 @@ func Test_ResolveRequest(t *testing.T) { r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken()) - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -383,7 +391,7 @@ func Test_ResolveRequest(t *testing.T) { t.Log("app", app) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -421,7 +429,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -502,7 +510,7 @@ func Test_ResolveRequest(t *testing.T) { r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -576,7 +584,7 @@ func Test_ResolveRequest(t *testing.T) { // Even though the token is invalid, we should still perform request // resolution without failure since we'll just ignore the bad token. - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -618,7 +626,7 @@ func Test_ResolveRequest(t *testing.T) { r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -646,7 +654,7 @@ func Test_ResolveRequest(t *testing.T) { r := httptest.NewRequest("GET", "/", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -675,7 +683,7 @@ func Test_ResolveRequest(t *testing.T) { r := httptest.NewRequest("GET", "/", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - _, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + _, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -708,7 +716,7 @@ func Test_ResolveRequest(t *testing.T) { r := httptest.NewRequest("GET", "/", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -733,7 +741,7 @@ func Test_ResolveRequest(t *testing.T) { r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -767,7 +775,7 @@ func Test_ResolveRequest(t *testing.T) { r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken()) - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -794,7 +802,7 @@ func Test_ResolveRequest(t *testing.T) { r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -823,7 +831,7 @@ func Test_ResolveRequest(t *testing.T) { // Should not be used as the hostname in the redirect URI. r.Host = "app.com" - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -880,7 +888,7 @@ func Test_ResolveRequest(t *testing.T) { r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -937,7 +945,7 @@ func Test_ResolveRequest(t *testing.T) { r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -989,7 +997,7 @@ func Test_ResolveRequest(t *testing.T) { r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, From 9a3a4c8619caea086419f701965988cdc99e641c Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 5 Mar 2025 14:31:12 +0200 Subject: [PATCH 10/39] refactor tracing status writer done func --- coderd/tracing/status_writer.go | 9 +++++++-- coderd/tracing/status_writer_test.go | 21 +++++++++++++++++++++ coderd/workspaceapps/db.go | 2 +- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/coderd/tracing/status_writer.go b/coderd/tracing/status_writer.go index 277b3daff0f09..aae6db90ca747 100644 --- a/coderd/tracing/status_writer.go +++ b/coderd/tracing/status_writer.go @@ -27,7 +27,7 @@ type StatusWriter struct { http.ResponseWriter Status int Hijacked bool - Done []func() // If non-nil, this function will be called when the handler is done. + doneFuncs []func() // If non-nil, this function will be called when the handler is done. responseBody []byte wroteHeader bool @@ -38,12 +38,17 @@ func StatusWriterMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { sw := &StatusWriter{ResponseWriter: rw} next.ServeHTTP(sw, r) - for _, done := range sw.Done { + for _, done := range sw.doneFuncs { done() } }) } +func (w *StatusWriter) AddDoneFunc(f func()) { + // Prepend, as if deferred. + w.doneFuncs = append([]func(){f}, w.doneFuncs...) +} + func (w *StatusWriter) WriteHeader(status int) { if buildinfo.IsDev() || flag.Lookup("test.v") != nil { if w.wroteHeader { diff --git a/coderd/tracing/status_writer_test.go b/coderd/tracing/status_writer_test.go index ba19cd29a915c..78c8a7826491a 100644 --- a/coderd/tracing/status_writer_test.go +++ b/coderd/tracing/status_writer_test.go @@ -116,6 +116,27 @@ func TestStatusWriter(t *testing.T) { require.Error(t, err) require.Equal(t, "hijacked", err.Error()) }) + + t.Run("Middleware", func(t *testing.T) { + t.Parallel() + + var ( + sw *tracing.StatusWriter + done = false + rr = httptest.NewRecorder() + ) + tracing.StatusWriterMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sw = w.(*tracing.StatusWriter) + sw.AddDoneFunc(func() { + done = true + }) + w.WriteHeader(http.StatusNoContent) + })).ServeHTTP(rr, httptest.NewRequest("GET", "/", nil)) + + require.Equal(t, http.StatusNoContent, rr.Code, "rr status code not set") + require.Equal(t, http.StatusNoContent, sw.Status, "sw status code not set") + require.True(t, done, "done func not called") + }) } type hijacker struct { diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 3296c2fb87d59..c2ab6748b5288 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -388,7 +388,7 @@ func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http // Set the commit function on the status writer to create an audit // log, this ensures that the status and response body are available. - sw.Done = append(sw.Done, func() { + sw.AddDoneFunc(func() { p.Logger.Info(ctx, "workspace app audit session", slog.F("status", sw.Status), slog.F("body", string(sw.ResponseBody())), slog.F("api_key", aReq.apiKey), slog.F("db_req", aReq.dbReq)) if sw.Status == http.StatusSeeOther { From 1f4e95b7e8f15d8499699bd6ecef97d2fbb5360e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 5 Mar 2025 14:31:29 +0200 Subject: [PATCH 11/39] fix audit mock check --- coderd/audit/audit.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/audit/audit.go b/coderd/audit/audit.go index a965c27a004c6..2a264605c6428 100644 --- a/coderd/audit/audit.go +++ b/coderd/audit/audit.go @@ -93,7 +93,7 @@ func (a *MockAuditor) Contains(t testing.TB, expected database.AuditLog) bool { t.Logf("audit log %d: expected UserID %s, got %s", idx+1, expected.UserID, al.UserID) continue } - if expected.OrganizationID != uuid.Nil && al.UserID != expected.UserID { + if expected.OrganizationID != uuid.Nil && al.OrganizationID != expected.OrganizationID { t.Logf("audit log %d: expected OrganizationID %s, got %s", idx+1, expected.OrganizationID, al.OrganizationID) continue } From bda2d1273d505274b0d07f5d00aec8ca612b2733 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 5 Mar 2025 14:31:53 +0200 Subject: [PATCH 12/39] mimic http response writer default status 200 --- coderd/workspaceapps/db.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index c2ab6748b5288..739befed3de87 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -493,6 +493,13 @@ func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http } } + // Mimic the behavior of a HTTP status writer + // by defaulting to 200 if the status is 0. + status := sw.Status + if status == 0 { + status = http.StatusOK + } + // We use the background audit function instead of init request // here because we don't know the resource type ahead of time. // This also allows us to log unauthenticated access. @@ -508,7 +515,7 @@ func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http UserID: userID.UUID, RequestID: sessionID, Time: aReq.time, - Status: sw.Status, + Status: status, IP: aReq.ip.IPNet.IP.String(), UserAgent: r.UserAgent(), New: aReq.dbReq.App, @@ -525,7 +532,7 @@ func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http UserID: userID.UUID, RequestID: sessionID, Time: aReq.time, - Status: sw.Status, + Status: status, IP: aReq.ip.IPNet.IP.String(), UserAgent: r.UserAgent(), New: aReq.dbReq.Agent, From 93784b90ce05f4f129597e1ce326dfa32de1e7f4 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 5 Mar 2025 14:37:33 +0200 Subject: [PATCH 13/39] update db tests --- coderd/workspaceapps/db_test.go | 296 +++++++++++++++++++++++++++++--- 1 file changed, 270 insertions(+), 26 deletions(-) diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index 5b12e694dfa66..a90d26cc60728 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -2,6 +2,7 @@ package workspaceapps_test import ( "context" + "crypto/rand" "fmt" "io" "net" @@ -10,6 +11,7 @@ import ( "net/http/httputil" "net/url" "strings" + "sync/atomic" "testing" "time" @@ -19,7 +21,9 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/agent/agenttest" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/jwtutils" "github.com/coder/coder/v2/coderd/tracing" @@ -31,13 +35,6 @@ import ( "github.com/coder/coder/v2/testutil" ) -func resolveRequestWithStatusMW(w http.ResponseWriter, r *http.Request, opts workspaceapps.ResolveRequestOptions) (token *workspaceapps.SignedToken, ok bool) { - tracing.StatusWriterMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token, ok = workspaceapps.ResolveRequest(w, r, opts) - })).ServeHTTP(w, r) - return token, ok -} - func Test_ResolveRequest(t *testing.T) { t.Parallel() @@ -84,6 +81,10 @@ func Test_ResolveRequest(t *testing.T) { deploymentValues.Dangerous.AllowPathAppSharing = true deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = true + auditor := audit.NewMock() + t.Cleanup(func() { + assert.Len(t, auditor.AuditLogs(), 0, "one or more test cases produced unexpected audit logs, did you replace the auditor or forget to call ResetLogs?") + }) client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ AppHostname: "*.test.coder.com", DeploymentValues: deploymentValues, @@ -99,6 +100,7 @@ func Test_ResolveRequest(t *testing.T) { "CF-Connecting-IP", }, }, + Auditor: auditor, }) t.Cleanup(func() { _ = closer.Close() @@ -110,7 +112,7 @@ func Test_ResolveRequest(t *testing.T) { me, err := client.User(ctx, codersdk.Me) require.NoError(t, err) - secondUserClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + secondUserClient, secondUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) agentAuthToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{ @@ -223,6 +225,9 @@ func Test_ResolveRequest(t *testing.T) { } require.NotEqual(t, uuid.Nil, agentID) + // Reset audit logs so cleanup check can pass. + auditor.ResetLogs() + t.Run("OK", func(t *testing.T) { t.Parallel() @@ -261,13 +266,17 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: app, }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + t.Log("app", app) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) // Try resolving the request without a token. - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -303,6 +312,14 @@ func Test_ResolveRequest(t *testing.T) { require.Equal(t, codersdk.SignedAppTokenCookie, cookie.Name) require.Equal(t, req.BasePath, cookie.Path) + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + UserID: me.ID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log") + require.Len(t, auditor.AuditLogs(), 1, "single audit log") + var parsedToken workspaceapps.SignedToken err := jwtutils.Verify(ctx, api.AppSigningKeyCache, cookie.Value, &parsedToken) require.NoError(t, err) @@ -315,8 +332,9 @@ func Test_ResolveRequest(t *testing.T) { rw = httptest.NewRecorder() r = httptest.NewRequest("GET", "/app", nil) r.AddCookie(cookie) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - secondToken, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + secondToken, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -329,6 +347,8 @@ func Test_ResolveRequest(t *testing.T) { require.WithinDuration(t, token.Expiry.Time(), secondToken.Expiry.Time(), 2*time.Second) secondToken.Expiry = token.Expiry require.Equal(t, token, secondToken) + + require.Len(t, auditor.AuditLogs(), 1, "single audit log, same user and app audit session is active") } }) } @@ -347,12 +367,16 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: app, }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + t.Log("app", app) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -372,6 +396,14 @@ func Test_ResolveRequest(t *testing.T) { require.True(t, ok) require.NotNil(t, token) require.Zero(t, w.StatusCode) + + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + UserID: secondUser.ID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log") + require.Len(t, auditor.AuditLogs(), 1, "single audit log") } }) @@ -388,10 +420,14 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: app, }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + t.Log("app", app) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -405,6 +441,8 @@ func Test_ResolveRequest(t *testing.T) { require.Nil(t, token) require.NotZero(t, rw.Code) require.NotEqual(t, http.StatusOK, rw.Code) + + require.Len(t, auditor.AuditLogs(), 0, "no audit logs for unauthenticated requests") } else { if !assert.True(t, ok) { dump, err := httputil.DumpResponse(w, true) @@ -416,6 +454,14 @@ func Test_ResolveRequest(t *testing.T) { if rw.Code != 0 && rw.Code != http.StatusOK { t.Fatalf("expected 200 (or unset) response code, got %d", rw.Code) } + + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log") + require.Len(t, auditor.AuditLogs(), 1, "single audit log") + require.Equal(t, uuid.Nil, auditor.AuditLogs()[0].UserID, "no user ID in audit log") } _ = w.Body.Close() } @@ -427,9 +473,12 @@ func Test_ResolveRequest(t *testing.T) { req := (workspaceapps.Request{ AccessMethod: "invalid", }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -439,6 +488,7 @@ func Test_ResolveRequest(t *testing.T) { }) require.False(t, ok) require.Nil(t, token) + require.Len(t, auditor.AuditLogs(), 0, "no audit logs for invalid requests") }) t.Run("SplitWorkspaceAndAgent", func(t *testing.T) { @@ -506,11 +556,15 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNamePublic, }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -531,8 +585,16 @@ func Test_ResolveRequest(t *testing.T) { require.Equal(t, token.AgentNameOrID, c.agent) require.Equal(t, token.WorkspaceID, workspace.ID) require.Equal(t, token.AgentID, agentID) + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + UserID: me.ID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log") + require.Len(t, auditor.AuditLogs(), 1, "single audit log") } else { require.Nil(t, token) + require.Len(t, auditor.AuditLogs(), 0, "no audit logs") } _ = w.Body.Close() }) @@ -574,6 +636,9 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameOwner, }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) @@ -581,10 +646,11 @@ func Test_ResolveRequest(t *testing.T) { Name: codersdk.SignedAppTokenCookie, Value: badTokenStr, }) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) // Even though the token is invalid, we should still perform request // resolution without failure since we'll just ignore the bad token. - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -608,6 +674,14 @@ func Test_ResolveRequest(t *testing.T) { err = jwtutils.Verify(ctx, api.AppSigningKeyCache, cookies[0].Value, &parsedToken) require.NoError(t, err) require.Equal(t, appNameOwner, parsedToken.AppSlugOrPort) + + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + UserID: me.ID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log") + require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) t.Run("PortPathBlocked", func(t *testing.T) { @@ -622,11 +696,15 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: "8080", }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -636,6 +714,12 @@ func Test_ResolveRequest(t *testing.T) { }) require.False(t, ok) require.Nil(t, token) + + w := rw.Result() + _ = w.Body.Close() + // TODO(mafredri): Verify this is the correct status code. + require.Equal(t, http.StatusInternalServerError, w.StatusCode) + require.Len(t, auditor.AuditLogs(), 0, "no audit logs for port path blocked requests") }) t.Run("PortSubdomain", func(t *testing.T) { @@ -650,11 +734,15 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: "9090", }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -665,6 +753,17 @@ func Test_ResolveRequest(t *testing.T) { require.True(t, ok) require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort) require.Equal(t, "http://127.0.0.1:9090", token.AppURL) + + w := rw.Result() + _ = w.Body.Close() + require.Equal(t, http.StatusOK, w.StatusCode) + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + UserID: me.ID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log") + require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) t.Run("PortSubdomainHTTPSS", func(t *testing.T) { @@ -679,11 +778,15 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: "9090ss", }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - _, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + _, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -698,6 +801,8 @@ func Test_ResolveRequest(t *testing.T) { b, err := io.ReadAll(w.Body) require.NoError(t, err) require.Contains(t, string(b), "404 - Application Not Found") + require.Equal(t, http.StatusNotFound, w.StatusCode) + require.Len(t, auditor.AuditLogs(), 0, "no audit logs for invalid requests") }) t.Run("SubdomainEndsInS", func(t *testing.T) { @@ -712,11 +817,15 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameEndsInS, }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -726,6 +835,15 @@ func Test_ResolveRequest(t *testing.T) { }) require.True(t, ok) require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort) + w := rw.Result() + _ = w.Body.Close() + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + UserID: me.ID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log") + require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) t.Run("Terminal", func(t *testing.T) { @@ -737,11 +855,15 @@ func Test_ResolveRequest(t *testing.T) { AgentNameOrID: agentID.String(), }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -757,6 +879,15 @@ func Test_ResolveRequest(t *testing.T) { require.Equal(t, req.AgentNameOrID, token.Request.AgentNameOrID) require.Empty(t, token.AppSlugOrPort) require.Empty(t, token.AppURL) + w := rw.Result() + _ = w.Body.Close() + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + UserID: me.ID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log") + require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) t.Run("InsufficientPermissions", func(t *testing.T) { @@ -771,11 +902,15 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameOwner, }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -785,6 +920,16 @@ func Test_ResolveRequest(t *testing.T) { }) require.False(t, ok) require.Nil(t, token) + w := rw.Result() + _ = w.Body.Close() + require.Equal(t, http.StatusNotFound, w.StatusCode) + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + UserID: secondUser.ID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log insufficient permissions") + require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) t.Run("UserNotFound", func(t *testing.T) { @@ -798,11 +943,15 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameOwner, }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -812,6 +961,7 @@ func Test_ResolveRequest(t *testing.T) { }) require.False(t, ok) require.Nil(t, token) + require.Len(t, auditor.AuditLogs(), 0, "no audit logs for user not found") }) t.Run("RedirectSubdomainAuth", func(t *testing.T) { @@ -826,12 +976,16 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameOwner, }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/some-path", nil) // Should not be used as the hostname in the redirect URI. r.Host = "app.com" + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -846,6 +1000,7 @@ func Test_ResolveRequest(t *testing.T) { w := rw.Result() defer w.Body.Close() require.Equal(t, http.StatusSeeOther, w.StatusCode) + require.Len(t, auditor.AuditLogs(), 0, "no audit logs for redirect requests") loc, err := w.Location() require.NoError(t, err) @@ -884,11 +1039,15 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameAgentUnhealthy, }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -902,6 +1061,13 @@ func Test_ResolveRequest(t *testing.T) { w := rw.Result() defer w.Body.Close() require.Equal(t, http.StatusBadGateway, w.StatusCode) + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + UserID: me.ID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log unhealthy agent") + require.Len(t, auditor.AuditLogs(), 1, "single audit log") body, err := io.ReadAll(w.Body) require.NoError(t, err) @@ -941,11 +1107,15 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameInitializing, }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -955,6 +1125,15 @@ func Test_ResolveRequest(t *testing.T) { }) require.True(t, ok, "ResolveRequest failed, should pass even though app is initializing") require.NotNil(t, token) + w := rw.Result() + _ = w.Body.Close() + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + UserID: me.ID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log initializing app") + require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) // Unhealthy apps are now permitted to connect anyways. This wasn't always @@ -993,11 +1172,15 @@ func Test_ResolveRequest(t *testing.T) { AppSlugOrPort: appNameUnhealthy, }).Normalize() + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - token, ok := resolveRequestWithStatusMW(rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -1007,5 +1190,66 @@ func Test_ResolveRequest(t *testing.T) { }) require.True(t, ok, "ResolveRequest failed, should pass even though app is unhealthy") require.NotNil(t, token) + w := rw.Result() + _ = w.Body.Close() + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + UserID: me.ID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log unhealthy app") + require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) } + +type auditorKey int + +const auditorKey0 auditorKey = iota + +func requestWithAuditorAndRemoteAddr(r *http.Request, auditor audit.Auditor, remoteAddr string) *http.Request { + ctx := r.Context() + ctx = context.WithValue(ctx, auditorKey0, auditor) + rr := r.WithContext(ctx) + rr.RemoteAddr = remoteAddr + return rr +} + +func randomIPv6(t testing.TB) string { + t.Helper() + + // 2001:db8::/32 is reserved for documentation and examples. + buf := make([]byte, 16) + _, err := rand.Read(buf) + require.NoError(t, err, "error generating random IPv6 address") + return fmt.Sprintf("2001:db8:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x", + buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], + buf[6], buf[7], buf[8], buf[9], buf[10], buf[11]) +} + +func workspaceappsResolveRequest(t testing.TB, w http.ResponseWriter, r *http.Request, opts workspaceapps.ResolveRequestOptions) (token *workspaceapps.SignedToken, ok bool) { + t.Helper() + ctx := r.Context() + auditorValue := ctx.Value(auditorKey0) + if opts.SignedTokenProvider != nil && auditorValue != nil { + auditor, ok := auditorValue.(audit.Auditor) + require.True(t, ok, "auditor is not an audit.Auditor") + opts.SignedTokenProvider = signedTokenProviderWithAuditor(t, opts.SignedTokenProvider, auditor) + } + + tracing.StatusWriterMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok = workspaceapps.ResolveRequest(w, r, opts) + })).ServeHTTP(w, r) + + return token, ok +} + +func signedTokenProviderWithAuditor(t testing.TB, provider workspaceapps.SignedTokenProvider, auditor audit.Auditor) workspaceapps.SignedTokenProvider { + t.Helper() + p, ok := provider.(*workspaceapps.DBTokenProvider) + require.True(t, ok, "provider is not a DBTokenProvider") + + shallowCopy := *p + shallowCopy.Auditor = &atomic.Pointer[audit.Auditor]{} + shallowCopy.Auditor.Store(&auditor) + return &shallowCopy +} From e38ba0fac66d11992e65701e30cfdd056aa44d13 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 5 Mar 2025 14:58:01 +0200 Subject: [PATCH 14/39] dbauthz --- coderd/database/dbauthz/dbauthz_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 614a357efcbc5..e8294d3298768 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4039,6 +4039,21 @@ func (s *MethodTestSuite) TestSystemFunctions() { s.Run("InsertWorkspaceAppStats", s.Subtest(func(db database.Store, check *expects) { check.Args(database.InsertWorkspaceAppStatsParams{}).Asserts(rbac.ResourceSystem, policy.ActionCreate) })) + s.Run("InsertWorkspaceAppAuditSession", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + pj := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{}) + res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: pj.ID}) + agent := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) + app := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: agent.ID}) + check.Args(database.InsertWorkspaceAppAuditSessionParams{ + AgentID: agent.ID, + AppID: uuid.NullUUID{UUID: app.ID, Valid: true}, + UserID: uuid.NullUUID{UUID: u.ID, Valid: true}, + }).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) + s.Run("UpdateWorkspaceAppAuditSession", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.UpdateWorkspaceAppAuditSessionParams{}).Asserts(rbac.ResourceSystem, policy.ActionUpdate) + })) s.Run("InsertWorkspaceAgentScriptTimings", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) check.Args(database.InsertWorkspaceAgentScriptTimingsParams{ From 5f0c141d6a20ecbdcfec0af1e7849dfbc24388ea Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 5 Mar 2025 15:02:31 +0200 Subject: [PATCH 15/39] add app audit session timeout to dbtokenprovider --- coderd/coderd.go | 2 ++ coderd/workspaceapps/db.go | 42 ++++++++++++++++++++++---------------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index a17dc38a79258..e393df155b612 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -226,6 +226,7 @@ type Options struct { UpdateAgentMetrics func(ctx context.Context, labels prometheusmetrics.AgentMetricLabels, metrics []*agentproto.Stats_Metric) StatsBatcher workspacestats.Batcher + WorkspaceAppAuditSessionTimeout time.Duration WorkspaceAppsStatsCollectorOptions workspaceapps.StatsCollectorOptions // This janky function is used in telemetry to parse fields out of the raw @@ -560,6 +561,7 @@ func New(options *Options) *API { options.DeploymentValues, oauthConfigs, options.AgentInactiveDisconnectTimeout, + options.WorkspaceAppAuditSessionTimeout, options.AppSigningKeyCache, ) diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 739befed3de87..8df3f00eb44c4 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -39,14 +39,15 @@ type DBTokenProvider struct { Logger slog.Logger // DashboardURL is the main dashboard access URL for error pages. - DashboardURL *url.URL - Authorizer rbac.Authorizer - Auditor *atomic.Pointer[audit.Auditor] - Database database.Store - DeploymentValues *codersdk.DeploymentValues - OAuth2Configs *httpmw.OAuth2Configs - WorkspaceAgentInactiveTimeout time.Duration - Keycache cryptokeys.SigningKeycache + DashboardURL *url.URL + Authorizer rbac.Authorizer + Auditor *atomic.Pointer[audit.Auditor] + Database database.Store + DeploymentValues *codersdk.DeploymentValues + OAuth2Configs *httpmw.OAuth2Configs + WorkspaceAgentInactiveTimeout time.Duration + WorkspaceAppAuditSessionTimeout time.Duration + Keycache cryptokeys.SigningKeycache } var _ SignedTokenProvider = &DBTokenProvider{} @@ -59,22 +60,27 @@ func NewDBTokenProvider(log slog.Logger, cfg *codersdk.DeploymentValues, oauth2Cfgs *httpmw.OAuth2Configs, workspaceAgentInactiveTimeout time.Duration, + workspaceAppAuditSessionTimeout time.Duration, signer cryptokeys.SigningKeycache, ) SignedTokenProvider { if workspaceAgentInactiveTimeout == 0 { workspaceAgentInactiveTimeout = 1 * time.Minute } + if workspaceAppAuditSessionTimeout == 0 { + workspaceAppAuditSessionTimeout = time.Hour + } return &DBTokenProvider{ - Logger: log, - DashboardURL: accessURL, - Authorizer: authz, - Auditor: auditor, - Database: db, - DeploymentValues: cfg, - OAuth2Configs: oauth2Cfgs, - WorkspaceAgentInactiveTimeout: workspaceAgentInactiveTimeout, - Keycache: signer, + Logger: log, + DashboardURL: accessURL, + Authorizer: authz, + Auditor: auditor, + Database: db, + DeploymentValues: cfg, + OAuth2Configs: oauth2Cfgs, + WorkspaceAgentInactiveTimeout: workspaceAgentInactiveTimeout, + WorkspaceAppAuditSessionTimeout: workspaceAppAuditSessionTimeout, + Keycache: signer, } } @@ -446,7 +452,7 @@ func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http UserID: userID, Ip: aReq.ip, UpdatedAt: aReq.time, - StaleIntervalMS: (2 * time.Hour).Milliseconds(), + StaleIntervalMS: p.WorkspaceAppAuditSessionTimeout.Milliseconds(), }) if err != nil { return xerrors.Errorf("update workspace app audit session: %w", err) From ae06fe4c60d39cd6b6b28fcead0a6e99b201ca7e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 5 Mar 2025 15:10:35 +0200 Subject: [PATCH 16/39] remove log spam --- coderd/workspaceapps/db.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 8df3f00eb44c4..aaa4dfef3b3b4 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -395,8 +395,6 @@ func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http // Set the commit function on the status writer to create an audit // log, this ensures that the status and response body are available. sw.AddDoneFunc(func() { - p.Logger.Info(ctx, "workspace app audit session", slog.F("status", sw.Status), slog.F("body", string(sw.ResponseBody())), slog.F("api_key", aReq.apiKey), slog.F("db_req", aReq.dbReq)) - if sw.Status == http.StatusSeeOther { // Redirects aren't interesting as we will capture the audit // log after the redirect. @@ -480,8 +478,6 @@ func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http p.Logger.Error(ctx, "update workspace app audit session failed", slog.Error(err)) } - p.Logger.Info(ctx, "workspace app audit session", slog.F("session_id", sessionID), slog.F("status", sw.Status), slog.F("body", string(sw.ResponseBody()))) - if sessionID == uuid.Nil { if sw.Status < 400 { // Session was updated and no error occurred, no need to From cf1180e9ab687bc0b8b9652158ba93efc9be1b55 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 5 Mar 2025 15:52:59 +0000 Subject: [PATCH 17/39] verify audit log --- coderd/workspaceapps/db_test.go | 52 +++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index a90d26cc60728..e2007e8861ba9 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -3,6 +3,7 @@ package workspaceapps_test import ( "context" "crypto/rand" + "database/sql" "fmt" "io" "net" @@ -24,6 +25,7 @@ import ( "github.com/coder/coder/v2/coderd/audit" "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/httpmw" "github.com/coder/coder/v2/coderd/jwtutils" "github.com/coder/coder/v2/coderd/tracing" @@ -83,6 +85,9 @@ func Test_ResolveRequest(t *testing.T) { auditor := audit.NewMock() t.Cleanup(func() { + if t.Failed() { + return + } assert.Len(t, auditor.AuditLogs(), 0, "one or more test cases produced unexpected audit logs, did you replace the auditor or forget to call ResetLogs?") }) client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ @@ -220,11 +225,24 @@ func Test_ResolveRequest(t *testing.T) { for _, agnt := range resource.Agents { if agnt.Name == agentName { agentID = agnt.ID + break } } } require.NotEqual(t, uuid.Nil, agentID) + //nonlint:gocritic // This is a test, allow dbauthz.AsSystemRestricted. + agent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) + require.NoError(t, err) + + //nolint:gocritic // This is a test, allow dbauthz.AsSystemRestricted. + apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) + require.NoError(t, err) + appsBySlug := make(map[string]database.WorkspaceApp, len(apps)) + for _, app := range apps { + appsBySlug[app.Slug] = app + } + // Reset audit logs so cleanup check can pass. auditor.ResetLogs() @@ -268,12 +286,14 @@ func Test_ResolveRequest(t *testing.T) { auditor := audit.NewMock() auditableIP := randomIPv6(t) + auditableUA := "Tidua" t.Log("app", app) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.Header.Set("User-Agent", auditableUA) // Try resolving the request without a token. token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ @@ -314,7 +334,12 @@ func Test_ResolveRequest(t *testing.T) { require.True(t, auditor.Contains(t, database.AuditLog{ OrganizationID: workspace.OrganizationID, + Action: database.AuditActionOpen, + ResourceType: audit.ResourceType(appsBySlug[app]), + ResourceID: audit.ResourceID(appsBySlug[app]), + ResourceTarget: audit.ResourceTarget(appsBySlug[app]), UserID: me.ID, + UserAgent: sql.NullString{Valid: true, String: auditableUA}, Ip: audit.ParseIP(auditableIP), StatusCode: int32(w.StatusCode), //nolint:gosec }), "audit log") @@ -399,6 +424,10 @@ func Test_ResolveRequest(t *testing.T) { require.True(t, auditor.Contains(t, database.AuditLog{ OrganizationID: workspace.OrganizationID, + Action: database.AuditActionOpen, + ResourceType: audit.ResourceType(appsBySlug[app]), + ResourceID: audit.ResourceID(appsBySlug[app]), + ResourceTarget: audit.ResourceTarget(appsBySlug[app]), UserID: secondUser.ID, Ip: audit.ParseIP(auditableIP), StatusCode: int32(w.StatusCode), //nolint:gosec @@ -457,6 +486,10 @@ func Test_ResolveRequest(t *testing.T) { require.True(t, auditor.Contains(t, database.AuditLog{ OrganizationID: workspace.OrganizationID, + ResourceType: audit.ResourceType(appsBySlug[app]), + ResourceID: audit.ResourceID(appsBySlug[app]), + ResourceTarget: audit.ResourceTarget(appsBySlug[app]), + UserID: uuid.Nil, // Nil is not verified by Contains, see below. Ip: audit.ParseIP(auditableIP), StatusCode: int32(w.StatusCode), //nolint:gosec }), "audit log") @@ -587,6 +620,9 @@ func Test_ResolveRequest(t *testing.T) { require.Equal(t, token.AgentID, agentID) require.True(t, auditor.Contains(t, database.AuditLog{ OrganizationID: workspace.OrganizationID, + ResourceType: audit.ResourceType(appsBySlug[token.AppSlugOrPort]), + ResourceID: audit.ResourceID(appsBySlug[token.AppSlugOrPort]), + ResourceTarget: audit.ResourceTarget(appsBySlug[token.AppSlugOrPort]), UserID: me.ID, Ip: audit.ParseIP(auditableIP), StatusCode: int32(w.StatusCode), //nolint:gosec @@ -677,6 +713,9 @@ func Test_ResolveRequest(t *testing.T) { require.True(t, auditor.Contains(t, database.AuditLog{ OrganizationID: workspace.OrganizationID, + ResourceType: audit.ResourceType(appsBySlug[token.AppSlugOrPort]), + ResourceID: audit.ResourceID(appsBySlug[token.AppSlugOrPort]), + ResourceTarget: audit.ResourceTarget(appsBySlug[token.AppSlugOrPort]), UserID: me.ID, Ip: audit.ParseIP(auditableIP), StatusCode: int32(w.StatusCode), //nolint:gosec @@ -759,10 +798,13 @@ func Test_ResolveRequest(t *testing.T) { require.Equal(t, http.StatusOK, w.StatusCode) require.True(t, auditor.Contains(t, database.AuditLog{ OrganizationID: workspace.OrganizationID, + ResourceType: audit.ResourceType(agent), + ResourceID: audit.ResourceID(agent), + ResourceTarget: audit.ResourceTarget(agent), UserID: me.ID, Ip: audit.ParseIP(auditableIP), StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log") + }), "audit log for agent, not app") require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) @@ -839,6 +881,9 @@ func Test_ResolveRequest(t *testing.T) { _ = w.Body.Close() require.True(t, auditor.Contains(t, database.AuditLog{ OrganizationID: workspace.OrganizationID, + ResourceType: audit.ResourceType(appsBySlug[token.AppSlugOrPort]), + ResourceID: audit.ResourceID(appsBySlug[token.AppSlugOrPort]), + ResourceTarget: audit.ResourceTarget(appsBySlug[token.AppSlugOrPort]), UserID: me.ID, Ip: audit.ParseIP(auditableIP), StatusCode: int32(w.StatusCode), //nolint:gosec @@ -883,10 +928,13 @@ func Test_ResolveRequest(t *testing.T) { _ = w.Body.Close() require.True(t, auditor.Contains(t, database.AuditLog{ OrganizationID: workspace.OrganizationID, + ResourceType: audit.ResourceType(agent), + ResourceID: audit.ResourceID(agent), + ResourceTarget: audit.ResourceTarget(agent), UserID: me.ID, Ip: audit.ParseIP(auditableIP), StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log") + }), "audit log for agent, not app") require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) From 623ad6f7eacbef1c3f1887f213ca486329a3ff7a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 5 Mar 2025 22:20:28 +0000 Subject: [PATCH 18/39] add specific test for audit --- coderd/workspaceapps/db.go | 1 - coderd/workspaceapps/db_test.go | 136 +++++++++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 5 deletions(-) diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index aaa4dfef3b3b4..a03f378882563 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -155,7 +155,6 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r * // Verify the user has access to the app. authed, warnings, err := p.authorizeRequest(r.Context(), authz, dbReq) if err != nil { - // TODO(mafredri): Audit? WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "verify authz") return nil, "", false } diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index e2007e8861ba9..bfbf87aff4a91 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -372,8 +372,7 @@ func Test_ResolveRequest(t *testing.T) { require.WithinDuration(t, token.Expiry.Time(), secondToken.Expiry.Time(), 2*time.Second) secondToken.Expiry = token.Expiry require.Equal(t, token, secondToken) - - require.Len(t, auditor.AuditLogs(), 1, "single audit log, same user and app audit session is active") + require.Len(t, auditor.AuditLogs(), 1, "no new audit log, FromRequest returned the same token and is not audited") } }) } @@ -1248,6 +1247,134 @@ func Test_ResolveRequest(t *testing.T) { }), "audit log unhealthy app") require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) + + t.Run("AuditLogging", func(t *testing.T) { + t.Parallel() + + for _, app := range allApps { + req := (workspaceapps.Request{ + AccessMethod: workspaceapps.AccessMethodPath, + BasePath: "/app", + UsernameOrID: me.Username, + WorkspaceNameOrID: workspace.Name, + AgentNameOrID: agentName, + AppSlugOrPort: app, + }).Normalize() + + auditor := audit.NewMock() + auditableIP := randomIPv6(t) + + t.Log("app", app) + + // First request, new audit log. + rw := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/app", nil) + r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + + _, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + Logger: api.Logger, + SignedTokenProvider: api.WorkspaceAppsProvider, + DashboardURL: api.AccessURL, + PathAppBaseURL: api.AccessURL, + AppHostname: api.AppHostname, + AppRequest: req, + }) + require.True(t, ok) + w := rw.Result() + _ = w.Body.Close() + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + Action: database.AuditActionOpen, + ResourceType: audit.ResourceType(appsBySlug[app]), + ResourceID: audit.ResourceID(appsBySlug[app]), + ResourceTarget: audit.ResourceTarget(appsBySlug[app]), + UserID: me.ID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log 1") + require.Len(t, auditor.AuditLogs(), 1, "single audit log") + + // Second request, no audit log because the session is active. + rw = httptest.NewRecorder() + r = httptest.NewRequest("GET", "/app", nil) + r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + + _, ok = workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + Logger: api.Logger, + SignedTokenProvider: api.WorkspaceAppsProvider, + DashboardURL: api.AccessURL, + PathAppBaseURL: api.AccessURL, + AppHostname: api.AppHostname, + AppRequest: req, + }) + require.True(t, ok) + w = rw.Result() + _ = w.Body.Close() + require.Len(t, auditor.AuditLogs(), 1, "single audit log, previous session active") + + // Third request, session timed out, new audit log. + rw = httptest.NewRecorder() + r = httptest.NewRequest("GET", "/app", nil) + r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r.RemoteAddr = auditableIP + + sessionTimeoutTokenProvider := signedTokenProviderWithAuditor(t, api.WorkspaceAppsProvider, auditor, 0) + _, ok = workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + Logger: api.Logger, + SignedTokenProvider: sessionTimeoutTokenProvider, + DashboardURL: api.AccessURL, + PathAppBaseURL: api.AccessURL, + AppHostname: api.AppHostname, + AppRequest: req, + }) + require.True(t, ok) + w = rw.Result() + _ = w.Body.Close() + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + Action: database.AuditActionOpen, + ResourceType: audit.ResourceType(appsBySlug[app]), + ResourceID: audit.ResourceID(appsBySlug[app]), + ResourceTarget: audit.ResourceTarget(appsBySlug[app]), + UserID: me.ID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log 2") + require.Len(t, auditor.AuditLogs(), 2, "two audit logs, session timed out") + + // Fourth request, new IP produces new audit log. + auditableIP = randomIPv6(t) + rw = httptest.NewRecorder() + r = httptest.NewRequest("GET", "/app", nil) + r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) + r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + + _, ok = workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + Logger: api.Logger, + SignedTokenProvider: api.WorkspaceAppsProvider, + DashboardURL: api.AccessURL, + PathAppBaseURL: api.AccessURL, + AppHostname: api.AppHostname, + AppRequest: req, + }) + require.True(t, ok) + w = rw.Result() + _ = w.Body.Close() + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + Action: database.AuditActionOpen, + ResourceType: audit.ResourceType(appsBySlug[app]), + ResourceID: audit.ResourceID(appsBySlug[app]), + ResourceTarget: audit.ResourceTarget(appsBySlug[app]), + UserID: me.ID, + Ip: audit.ParseIP(auditableIP), + StatusCode: int32(w.StatusCode), //nolint:gosec + }), "audit log 3") + require.Len(t, auditor.AuditLogs(), 3, "three audit logs, new IP") + } + }) } type auditorKey int @@ -1281,7 +1408,7 @@ func workspaceappsResolveRequest(t testing.TB, w http.ResponseWriter, r *http.Re if opts.SignedTokenProvider != nil && auditorValue != nil { auditor, ok := auditorValue.(audit.Auditor) require.True(t, ok, "auditor is not an audit.Auditor") - opts.SignedTokenProvider = signedTokenProviderWithAuditor(t, opts.SignedTokenProvider, auditor) + opts.SignedTokenProvider = signedTokenProviderWithAuditor(t, opts.SignedTokenProvider, auditor, time.Hour) } tracing.StatusWriterMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1291,7 +1418,7 @@ func workspaceappsResolveRequest(t testing.TB, w http.ResponseWriter, r *http.Re return token, ok } -func signedTokenProviderWithAuditor(t testing.TB, provider workspaceapps.SignedTokenProvider, auditor audit.Auditor) workspaceapps.SignedTokenProvider { +func signedTokenProviderWithAuditor(t testing.TB, provider workspaceapps.SignedTokenProvider, auditor audit.Auditor, sessionTimeout time.Duration) workspaceapps.SignedTokenProvider { t.Helper() p, ok := provider.(*workspaceapps.DBTokenProvider) require.True(t, ok, "provider is not a DBTokenProvider") @@ -1299,5 +1426,6 @@ func signedTokenProviderWithAuditor(t testing.TB, provider workspaceapps.SignedT shallowCopy := *p shallowCopy.Auditor = &atomic.Pointer[audit.Auditor]{} shallowCopy.Auditor.Store(&auditor) + shallowCopy.WorkspaceAppAuditSessionTimeout = sessionTimeout return &shallowCopy } From c91024ec72e9cd8dfd2a0205c3ad7a98c66e4aef Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 5 Mar 2025 22:33:36 +0000 Subject: [PATCH 19/39] cleanup request context hack --- coderd/workspaceapps/db_test.go | 106 ++++++++++++++------------------ 1 file changed, 45 insertions(+), 61 deletions(-) diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index bfbf87aff4a91..79a6e430fed8d 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -292,11 +292,11 @@ func Test_ResolveRequest(t *testing.T) { rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP r.Header.Set("User-Agent", auditableUA) // Try resolving the request without a token. - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -357,9 +357,9 @@ func Test_ResolveRequest(t *testing.T) { rw = httptest.NewRecorder() r = httptest.NewRequest("GET", "/app", nil) r.AddCookie(cookie) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - secondToken, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + secondToken, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -398,9 +398,9 @@ func Test_ResolveRequest(t *testing.T) { rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -454,8 +454,8 @@ func Test_ResolveRequest(t *testing.T) { t.Log("app", app) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + r.RemoteAddr = auditableIP + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -509,8 +509,8 @@ func Test_ResolveRequest(t *testing.T) { auditableIP := randomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + r.RemoteAddr = auditableIP + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -594,9 +594,9 @@ func Test_ResolveRequest(t *testing.T) { rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -681,11 +681,11 @@ func Test_ResolveRequest(t *testing.T) { Name: codersdk.SignedAppTokenCookie, Value: badTokenStr, }) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP // Even though the token is invalid, we should still perform request // resolution without failure since we'll just ignore the bad token. - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -740,9 +740,9 @@ func Test_ResolveRequest(t *testing.T) { rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -778,9 +778,9 @@ func Test_ResolveRequest(t *testing.T) { rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -825,9 +825,9 @@ func Test_ResolveRequest(t *testing.T) { rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - _, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + _, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -864,9 +864,9 @@ func Test_ResolveRequest(t *testing.T) { rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -905,9 +905,9 @@ func Test_ResolveRequest(t *testing.T) { rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -955,9 +955,9 @@ func Test_ResolveRequest(t *testing.T) { rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -996,9 +996,9 @@ func Test_ResolveRequest(t *testing.T) { rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -1030,9 +1030,9 @@ func Test_ResolveRequest(t *testing.T) { r := httptest.NewRequest("GET", "/some-path", nil) // Should not be used as the hostname in the redirect URI. r.Host = "app.com" - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -1092,9 +1092,9 @@ func Test_ResolveRequest(t *testing.T) { rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -1160,9 +1160,9 @@ func Test_ResolveRequest(t *testing.T) { rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -1225,9 +1225,9 @@ func Test_ResolveRequest(t *testing.T) { rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - token, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + token, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -1270,9 +1270,9 @@ func Test_ResolveRequest(t *testing.T) { rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - _, ok := workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + _, ok := workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -1299,9 +1299,9 @@ func Test_ResolveRequest(t *testing.T) { rw = httptest.NewRecorder() r = httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - _, ok = workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + _, ok = workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -1321,7 +1321,7 @@ func Test_ResolveRequest(t *testing.T) { r.RemoteAddr = auditableIP sessionTimeoutTokenProvider := signedTokenProviderWithAuditor(t, api.WorkspaceAppsProvider, auditor, 0) - _, ok = workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + _, ok = workspaceappsResolveRequest(t, nil, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: sessionTimeoutTokenProvider, DashboardURL: api.AccessURL, @@ -1349,9 +1349,9 @@ func Test_ResolveRequest(t *testing.T) { rw = httptest.NewRecorder() r = httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) - r = requestWithAuditorAndRemoteAddr(r, auditor, auditableIP) + r.RemoteAddr = auditableIP - _, ok = workspaceappsResolveRequest(t, rw, r, workspaceapps.ResolveRequestOptions{ + _, ok = workspaceappsResolveRequest(t, auditor, rw, r, workspaceapps.ResolveRequestOptions{ Logger: api.Logger, SignedTokenProvider: api.WorkspaceAppsProvider, DashboardURL: api.AccessURL, @@ -1377,18 +1377,6 @@ func Test_ResolveRequest(t *testing.T) { }) } -type auditorKey int - -const auditorKey0 auditorKey = iota - -func requestWithAuditorAndRemoteAddr(r *http.Request, auditor audit.Auditor, remoteAddr string) *http.Request { - ctx := r.Context() - ctx = context.WithValue(ctx, auditorKey0, auditor) - rr := r.WithContext(ctx) - rr.RemoteAddr = remoteAddr - return rr -} - func randomIPv6(t testing.TB) string { t.Helper() @@ -1401,13 +1389,9 @@ func randomIPv6(t testing.TB) string { buf[6], buf[7], buf[8], buf[9], buf[10], buf[11]) } -func workspaceappsResolveRequest(t testing.TB, w http.ResponseWriter, r *http.Request, opts workspaceapps.ResolveRequestOptions) (token *workspaceapps.SignedToken, ok bool) { +func workspaceappsResolveRequest(t testing.TB, auditor audit.Auditor, w http.ResponseWriter, r *http.Request, opts workspaceapps.ResolveRequestOptions) (token *workspaceapps.SignedToken, ok bool) { t.Helper() - ctx := r.Context() - auditorValue := ctx.Value(auditorKey0) - if opts.SignedTokenProvider != nil && auditorValue != nil { - auditor, ok := auditorValue.(audit.Auditor) - require.True(t, ok, "auditor is not an audit.Auditor") + if opts.SignedTokenProvider != nil && auditor != nil { opts.SignedTokenProvider = signedTokenProviderWithAuditor(t, opts.SignedTokenProvider, auditor, time.Hour) } From 9608da1127e7c86a7e30d3b646b5dbd5642f2cf0 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 5 Mar 2025 23:01:01 +0000 Subject: [PATCH 20/39] nonlint --- coderd/workspaceapps/db_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index 79a6e430fed8d..3b8797235f849 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -231,7 +231,7 @@ func Test_ResolveRequest(t *testing.T) { } require.NotEqual(t, uuid.Nil, agentID) - //nonlint:gocritic // This is a test, allow dbauthz.AsSystemRestricted. + //nolint:gocritic // This is a test, allow dbauthz.AsSystemRestricted. agent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) require.NoError(t, err) From 5bb42c2d56031a851f7d8969e3739dab90acb3b9 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 6 Mar 2025 12:30:48 +0200 Subject: [PATCH 21/39] add slug or port to support separation of terminal and ports --- coderd/database/dbmem/dbmem.go | 18 +++++++++++------- ...301_add_workspace_app_audit_sessions.up.sql | 14 ++++++++------ coderd/database/queries/workspaceappaudit.sql | 5 ++++- coderd/workspaceapps/db.go | 11 +++++++++-- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 8114ca42c9c6e..6b3009e4ae2d9 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -9262,13 +9262,14 @@ func (q *FakeQuerier) InsertWorkspaceAppAuditSession(_ context.Context, arg data id := uuid.New() q.workspaceAppAuditSessions = append(q.workspaceAppAuditSessions, database.WorkspaceAppAuditSession{ - ID: id, - AgentID: arg.AgentID, - AppID: arg.AppID, - UserID: arg.UserID, - Ip: arg.Ip, - StartedAt: arg.StartedAt, - UpdatedAt: arg.UpdatedAt, + ID: id, + AgentID: arg.AgentID, + AppID: arg.AppID, + UserID: arg.UserID, + Ip: arg.Ip, + SlugOrPort: arg.SlugOrPort, + StartedAt: arg.StartedAt, + UpdatedAt: arg.UpdatedAt, }) return id, nil @@ -11043,6 +11044,9 @@ func (q *FakeQuerier) UpdateWorkspaceAppAuditSession(_ context.Context, arg data if s.Ip.IPNet.String() != arg.Ip.IPNet.String() { continue } + if s.SlugOrPort != arg.SlugOrPort { + continue + } staleTime := dbtime.Now().Add(-(time.Duration(arg.StaleIntervalMS) * time.Millisecond)) if !s.UpdatedAt.After(staleTime) { continue diff --git a/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql b/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql index 72af9e5a31395..7ca8f2346b007 100644 --- a/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql +++ b/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql @@ -2,8 +2,9 @@ CREATE UNLOGGED TABLE workspace_app_audit_sessions ( id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), agent_id UUID NOT NULL, app_id UUID NULL, - user_id UUID, - ip inet, + user_id UUID NULL, + ip inet NOT NULL, + slug_or_port TEXT NOT NULL, started_at TIMESTAMP WITH TIME ZONE NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, @@ -13,13 +14,14 @@ CREATE UNLOGGED TABLE workspace_app_audit_sessions ( COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, the data in this table is ephemeral and is used to track the current session of a user in a workspace app.'; COMMENT ON COLUMN workspace_app_audit_sessions.id IS 'Unique identifier for the workspace app audit session.'; -COMMENT ON COLUMN workspace_app_audit_sessions.user_id IS 'The user that is currently using the workspace app. This is nullable because the app may be public.'; -COMMENT ON COLUMN workspace_app_audit_sessions.ip IS 'The IP address of the user that is currently using the workspace app.'; COMMENT ON COLUMN workspace_app_audit_sessions.agent_id IS 'The agent that is currently in the workspace app.'; COMMENT ON COLUMN workspace_app_audit_sessions.app_id IS 'The app that is currently in the workspace app. This is nullable because ports are not associated with an app.'; +COMMENT ON COLUMN workspace_app_audit_sessions.user_id IS 'The user that is currently using the workspace app. This is nullable because the app may be public.'; +COMMENT ON COLUMN workspace_app_audit_sessions.ip IS 'The IP address of the user that is currently using the workspace app.'; +COMMENT ON COLUMN workspace_app_audit_sessions.slug_or_port IS 'The slug or port of the workspace app that the user is currently using.'; COMMENT ON COLUMN workspace_app_audit_sessions.started_at IS 'The time the user started the session.'; COMMENT ON COLUMN workspace_app_audit_sessions.updated_at IS 'The time the session was last updated.'; -CREATE INDEX workspace_app_audit_sessions_agent_id_app_id ON workspace_app_audit_sessions (agent_id, app_id); +CREATE INDEX workspace_app_audit_sessions_agent_id_app_id_slug_or_port ON workspace_app_audit_sessions (agent_id, app_id, slug_or_port); -COMMENT ON INDEX workspace_app_audit_sessions_agent_id_app_id IS 'Index for the agent_id and app_id columns to perform updates.'; +COMMENT ON INDEX workspace_app_audit_sessions_agent_id_app_id_slug_or_port IS 'Index for the agent_id and app_id columns to perform updates.'; diff --git a/coderd/database/queries/workspaceappaudit.sql b/coderd/database/queries/workspaceappaudit.sql index 9d8c4f7e6eb8f..9ceeebfd0ab2c 100644 --- a/coderd/database/queries/workspaceappaudit.sql +++ b/coderd/database/queries/workspaceappaudit.sql @@ -5,6 +5,7 @@ INSERT INTO app_id, user_id, ip, + slug_or_port, started_at, updated_at ) @@ -15,7 +16,8 @@ VALUES $3, $4, $5, - $6 + $6, + $7 ) RETURNING id; @@ -33,6 +35,7 @@ WHERE AND app_id IS NOT DISTINCT FROM @app_id AND user_id IS NOT DISTINCT FROM @user_id AND ip IS NOT DISTINCT FROM @ip + AND slug_or_port = @slug_or_port AND updated_at > NOW() - (@stale_interval_ms::bigint || ' ms')::interval RETURNING id; diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index a03f378882563..6fb7b45747bce 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -414,7 +414,7 @@ func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http type additionalFields struct { audit.AdditionalFields - App string `json:"app"` + SlugOrPort string `json:"slug_or_port,omitempty"` } appInfo := additionalFields{ AdditionalFields: audit.AdditionalFields{ @@ -422,7 +422,13 @@ func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http WorkspaceName: aReq.dbReq.Workspace.Name, WorkspaceID: aReq.dbReq.Workspace.ID, }, - App: aReq.dbReq.AppSlugOrPort, + } + switch { + case aReq.dbReq.AccessMethod == AccessMethodTerminal: + appInfo.SlugOrPort = "terminal" + case aReq.dbReq.App.ID == uuid.Nil: + // If this isn't an app or a terminal, it's a port. + appInfo.SlugOrPort = aReq.dbReq.AppSlugOrPort } appInfoBytes, err := json.Marshal(appInfo) @@ -448,6 +454,7 @@ func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http AppID: uuid.NullUUID{Valid: aReq.dbReq.App.ID != uuid.Nil, UUID: aReq.dbReq.App.ID}, UserID: userID, Ip: aReq.ip, + SlugOrPort: appInfo.SlugOrPort, UpdatedAt: aReq.time, StaleIntervalMS: p.WorkspaceAppAuditSessionTimeout.Milliseconds(), }) From 14a17407331fcd477aa8ed119ad417a75c355ca9 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 6 Mar 2025 12:39:15 +0200 Subject: [PATCH 22/39] make gen --- coderd/database/dump.sql | 9 ++++++--- coderd/database/models.go | 2 ++ coderd/database/queries.sql.go | 23 +++++++++++++++-------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 6136ca4169912..3c592c9117dfc 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1763,7 +1763,8 @@ CREATE UNLOGGED TABLE workspace_app_audit_sessions ( agent_id uuid NOT NULL, app_id uuid, user_id uuid, - ip inet, + ip inet NOT NULL, + slug_or_port text NOT NULL, started_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL ); @@ -1780,6 +1781,8 @@ COMMENT ON COLUMN workspace_app_audit_sessions.user_id IS 'The user that is curr COMMENT ON COLUMN workspace_app_audit_sessions.ip IS 'The IP address of the user that is currently using the workspace app.'; +COMMENT ON COLUMN workspace_app_audit_sessions.slug_or_port IS 'The slug or port of the workspace app that the user is currently using.'; + COMMENT ON COLUMN workspace_app_audit_sessions.started_at IS 'The time the user started the session.'; COMMENT ON COLUMN workspace_app_audit_sessions.updated_at IS 'The time the session was last updated.'; @@ -2411,9 +2414,9 @@ CREATE INDEX workspace_agents_auth_token_idx ON workspace_agents USING btree (au CREATE INDEX workspace_agents_resource_id_idx ON workspace_agents USING btree (resource_id); -CREATE INDEX workspace_app_audit_sessions_agent_id_app_id ON workspace_app_audit_sessions USING btree (agent_id, app_id); +CREATE INDEX workspace_app_audit_sessions_agent_id_app_id_slug_or_port ON workspace_app_audit_sessions USING btree (agent_id, app_id, slug_or_port); -COMMENT ON INDEX workspace_app_audit_sessions_agent_id_app_id IS 'Index for the agent_id and app_id columns to perform updates.'; +COMMENT ON INDEX workspace_app_audit_sessions_agent_id_app_id_slug_or_port IS 'Index for the agent_id and app_id columns to perform updates.'; CREATE INDEX workspace_app_stats_workspace_id_idx ON workspace_app_stats USING btree (workspace_id); diff --git a/coderd/database/models.go b/coderd/database/models.go index e9d43ef62736a..9ece5a7902f4a 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3446,6 +3446,8 @@ type WorkspaceAppAuditSession struct { UserID uuid.NullUUID `db:"user_id" json:"user_id"` // The IP address of the user that is currently using the workspace app. Ip pqtype.Inet `db:"ip" json:"ip"` + // The slug or port of the workspace app that the user is currently using. + SlugOrPort string `db:"slug_or_port" json:"slug_or_port"` // The time the user started the session. StartedAt time.Time `db:"started_at" json:"started_at"` // The time the session was last updated. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 15694d915204f..8fd8da17be3fb 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -14567,6 +14567,7 @@ INSERT INTO app_id, user_id, ip, + slug_or_port, started_at, updated_at ) @@ -14577,19 +14578,21 @@ VALUES $3, $4, $5, - $6 + $6, + $7 ) RETURNING id ` type InsertWorkspaceAppAuditSessionParams struct { - AgentID uuid.UUID `db:"agent_id" json:"agent_id"` - AppID uuid.NullUUID `db:"app_id" json:"app_id"` - UserID uuid.NullUUID `db:"user_id" json:"user_id"` - Ip pqtype.Inet `db:"ip" json:"ip"` - StartedAt time.Time `db:"started_at" json:"started_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + AppID uuid.NullUUID `db:"app_id" json:"app_id"` + UserID uuid.NullUUID `db:"user_id" json:"user_id"` + Ip pqtype.Inet `db:"ip" json:"ip"` + SlugOrPort string `db:"slug_or_port" json:"slug_or_port"` + StartedAt time.Time `db:"started_at" json:"started_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } func (q *sqlQuerier) InsertWorkspaceAppAuditSession(ctx context.Context, arg InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { @@ -14598,6 +14601,7 @@ func (q *sqlQuerier) InsertWorkspaceAppAuditSession(ctx context.Context, arg Ins arg.AppID, arg.UserID, arg.Ip, + arg.SlugOrPort, arg.StartedAt, arg.UpdatedAt, ) @@ -14616,7 +14620,8 @@ WHERE AND app_id IS NOT DISTINCT FROM $3 AND user_id IS NOT DISTINCT FROM $4 AND ip IS NOT DISTINCT FROM $5 - AND updated_at > NOW() - ($6::bigint || ' ms')::interval + AND slug_or_port = $6 + AND updated_at > NOW() - ($7::bigint || ' ms')::interval RETURNING id ` @@ -14627,6 +14632,7 @@ type UpdateWorkspaceAppAuditSessionParams struct { AppID uuid.NullUUID `db:"app_id" json:"app_id"` UserID uuid.NullUUID `db:"user_id" json:"user_id"` Ip pqtype.Inet `db:"ip" json:"ip"` + SlugOrPort string `db:"slug_or_port" json:"slug_or_port"` StaleIntervalMS int64 `db:"stale_interval_ms" json:"stale_interval_ms"` } @@ -14639,6 +14645,7 @@ func (q *sqlQuerier) UpdateWorkspaceAppAuditSession(ctx context.Context, arg Upd arg.AppID, arg.UserID, arg.Ip, + arg.SlugOrPort, arg.StaleIntervalMS, ) if err != nil { From 4426bcf5e0edd19f0a2d0ae57bba0281d436dcef Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 6 Mar 2025 13:54:56 +0200 Subject: [PATCH 23/39] improve and reduce boilerplate in tests --- coderd/workspaceapps/db_test.go | 215 +++++++++----------------------- 1 file changed, 62 insertions(+), 153 deletions(-) diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index 3b8797235f849..a632816b3a76f 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "database/sql" + "encoding/json" "fmt" "io" "net" @@ -246,6 +247,9 @@ func Test_ResolveRequest(t *testing.T) { // Reset audit logs so cleanup check can pass. auditor.ResetLogs() + assertAuditAgent := auditAsserter[database.WorkspaceAgent](workspace) + assertAuditApp := auditAsserter[database.WorkspaceApp](workspace) + t.Run("OK", func(t *testing.T) { t.Parallel() @@ -332,18 +336,8 @@ func Test_ResolveRequest(t *testing.T) { require.Equal(t, codersdk.SignedAppTokenCookie, cookie.Name) require.Equal(t, req.BasePath, cookie.Path) - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - Action: database.AuditActionOpen, - ResourceType: audit.ResourceType(appsBySlug[app]), - ResourceID: audit.ResourceID(appsBySlug[app]), - ResourceTarget: audit.ResourceTarget(appsBySlug[app]), - UserID: me.ID, - UserAgent: sql.NullString{Valid: true, String: auditableUA}, - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log") - require.Len(t, auditor.AuditLogs(), 1, "single audit log") + assertAuditApp(t, rw, r, auditor, appsBySlug[app], me.ID, nil) + require.Len(t, auditor.AuditLogs(), 1, "audit log count") var parsedToken workspaceapps.SignedToken err := jwtutils.Verify(ctx, api.AppSigningKeyCache, cookie.Value, &parsedToken) @@ -421,16 +415,7 @@ func Test_ResolveRequest(t *testing.T) { require.NotNil(t, token) require.Zero(t, w.StatusCode) - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - Action: database.AuditActionOpen, - ResourceType: audit.ResourceType(appsBySlug[app]), - ResourceID: audit.ResourceID(appsBySlug[app]), - ResourceTarget: audit.ResourceTarget(appsBySlug[app]), - UserID: secondUser.ID, - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log") + assertAuditApp(t, rw, r, auditor, appsBySlug[app], secondUser.ID, nil) require.Len(t, auditor.AuditLogs(), 1, "single audit log") } }) @@ -483,17 +468,8 @@ func Test_ResolveRequest(t *testing.T) { t.Fatalf("expected 200 (or unset) response code, got %d", rw.Code) } - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - ResourceType: audit.ResourceType(appsBySlug[app]), - ResourceID: audit.ResourceID(appsBySlug[app]), - ResourceTarget: audit.ResourceTarget(appsBySlug[app]), - UserID: uuid.Nil, // Nil is not verified by Contains, see below. - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log") + assertAuditApp(t, rw, r, auditor, appsBySlug[app], uuid.Nil, nil) require.Len(t, auditor.AuditLogs(), 1, "single audit log") - require.Equal(t, uuid.Nil, auditor.AuditLogs()[0].UserID, "no user ID in audit log") } _ = w.Body.Close() } @@ -617,15 +593,7 @@ func Test_ResolveRequest(t *testing.T) { require.Equal(t, token.AgentNameOrID, c.agent) require.Equal(t, token.WorkspaceID, workspace.ID) require.Equal(t, token.AgentID, agentID) - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - ResourceType: audit.ResourceType(appsBySlug[token.AppSlugOrPort]), - ResourceID: audit.ResourceID(appsBySlug[token.AppSlugOrPort]), - ResourceTarget: audit.ResourceTarget(appsBySlug[token.AppSlugOrPort]), - UserID: me.ID, - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log") + assertAuditApp(t, rw, r, auditor, appsBySlug[token.AppSlugOrPort], me.ID, nil) require.Len(t, auditor.AuditLogs(), 1, "single audit log") } else { require.Nil(t, token) @@ -710,15 +678,7 @@ func Test_ResolveRequest(t *testing.T) { require.NoError(t, err) require.Equal(t, appNameOwner, parsedToken.AppSlugOrPort) - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - ResourceType: audit.ResourceType(appsBySlug[token.AppSlugOrPort]), - ResourceID: audit.ResourceID(appsBySlug[token.AppSlugOrPort]), - ResourceTarget: audit.ResourceTarget(appsBySlug[token.AppSlugOrPort]), - UserID: me.ID, - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log") + assertAuditApp(t, rw, r, auditor, appsBySlug[appNameOwner], me.ID, nil) require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) @@ -792,18 +752,9 @@ func Test_ResolveRequest(t *testing.T) { require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort) require.Equal(t, "http://127.0.0.1:9090", token.AppURL) - w := rw.Result() - _ = w.Body.Close() - require.Equal(t, http.StatusOK, w.StatusCode) - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - ResourceType: audit.ResourceType(agent), - ResourceID: audit.ResourceID(agent), - ResourceTarget: audit.ResourceTarget(agent), - UserID: me.ID, - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log for agent, not app") + assertAuditAgent(t, rw, r, auditor, agent, me.ID, map[string]any{ + "slug_or_port": "9090", + }) require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) @@ -876,17 +827,7 @@ func Test_ResolveRequest(t *testing.T) { }) require.True(t, ok) require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort) - w := rw.Result() - _ = w.Body.Close() - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - ResourceType: audit.ResourceType(appsBySlug[token.AppSlugOrPort]), - ResourceID: audit.ResourceID(appsBySlug[token.AppSlugOrPort]), - ResourceTarget: audit.ResourceTarget(appsBySlug[token.AppSlugOrPort]), - UserID: me.ID, - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log") + assertAuditApp(t, rw, r, auditor, appsBySlug[appNameEndsInS], me.ID, nil) require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) @@ -923,17 +864,9 @@ func Test_ResolveRequest(t *testing.T) { require.Equal(t, req.AgentNameOrID, token.Request.AgentNameOrID) require.Empty(t, token.AppSlugOrPort) require.Empty(t, token.AppURL) - w := rw.Result() - _ = w.Body.Close() - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - ResourceType: audit.ResourceType(agent), - ResourceID: audit.ResourceID(agent), - ResourceTarget: audit.ResourceTarget(agent), - UserID: me.ID, - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log for agent, not app") + assertAuditAgent(t, rw, r, auditor, agent, me.ID, map[string]any{ + "slug_or_port": "terminal", + }) require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) @@ -967,15 +900,7 @@ func Test_ResolveRequest(t *testing.T) { }) require.False(t, ok) require.Nil(t, token) - w := rw.Result() - _ = w.Body.Close() - require.Equal(t, http.StatusNotFound, w.StatusCode) - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - UserID: secondUser.ID, - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log insufficient permissions") + assertAuditApp(t, rw, r, auditor, appsBySlug[appNameOwner], secondUser.ID, nil) require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) @@ -1108,12 +1033,7 @@ func Test_ResolveRequest(t *testing.T) { w := rw.Result() defer w.Body.Close() require.Equal(t, http.StatusBadGateway, w.StatusCode) - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - UserID: me.ID, - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log unhealthy agent") + assertAuditApp(t, rw, r, auditor, appsBySlug[appNameAgentUnhealthy], me.ID, nil) require.Len(t, auditor.AuditLogs(), 1, "single audit log") body, err := io.ReadAll(w.Body) @@ -1172,14 +1092,7 @@ func Test_ResolveRequest(t *testing.T) { }) require.True(t, ok, "ResolveRequest failed, should pass even though app is initializing") require.NotNil(t, token) - w := rw.Result() - _ = w.Body.Close() - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - UserID: me.ID, - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log initializing app") + assertAuditApp(t, rw, r, auditor, appsBySlug[token.AppSlugOrPort], me.ID, nil) require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) @@ -1237,14 +1150,7 @@ func Test_ResolveRequest(t *testing.T) { }) require.True(t, ok, "ResolveRequest failed, should pass even though app is unhealthy") require.NotNil(t, token) - w := rw.Result() - _ = w.Body.Close() - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - UserID: me.ID, - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log unhealthy app") + assertAuditApp(t, rw, r, auditor, appsBySlug[token.AppSlugOrPort], me.ID, nil) require.Len(t, auditor.AuditLogs(), 1, "single audit log") }) @@ -1281,18 +1187,7 @@ func Test_ResolveRequest(t *testing.T) { AppRequest: req, }) require.True(t, ok) - w := rw.Result() - _ = w.Body.Close() - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - Action: database.AuditActionOpen, - ResourceType: audit.ResourceType(appsBySlug[app]), - ResourceID: audit.ResourceID(appsBySlug[app]), - ResourceTarget: audit.ResourceTarget(appsBySlug[app]), - UserID: me.ID, - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log 1") + assertAuditApp(t, rw, r, auditor, appsBySlug[app], me.ID, nil) require.Len(t, auditor.AuditLogs(), 1, "single audit log") // Second request, no audit log because the session is active. @@ -1310,8 +1205,6 @@ func Test_ResolveRequest(t *testing.T) { AppRequest: req, }) require.True(t, ok) - w = rw.Result() - _ = w.Body.Close() require.Len(t, auditor.AuditLogs(), 1, "single audit log, previous session active") // Third request, session timed out, new audit log. @@ -1330,18 +1223,7 @@ func Test_ResolveRequest(t *testing.T) { AppRequest: req, }) require.True(t, ok) - w = rw.Result() - _ = w.Body.Close() - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - Action: database.AuditActionOpen, - ResourceType: audit.ResourceType(appsBySlug[app]), - ResourceID: audit.ResourceID(appsBySlug[app]), - ResourceTarget: audit.ResourceTarget(appsBySlug[app]), - UserID: me.ID, - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log 2") + assertAuditApp(t, rw, r, auditor, appsBySlug[app], me.ID, nil) require.Len(t, auditor.AuditLogs(), 2, "two audit logs, session timed out") // Fourth request, new IP produces new audit log. @@ -1360,18 +1242,7 @@ func Test_ResolveRequest(t *testing.T) { AppRequest: req, }) require.True(t, ok) - w = rw.Result() - _ = w.Body.Close() - require.True(t, auditor.Contains(t, database.AuditLog{ - OrganizationID: workspace.OrganizationID, - Action: database.AuditActionOpen, - ResourceType: audit.ResourceType(appsBySlug[app]), - ResourceID: audit.ResourceID(appsBySlug[app]), - ResourceTarget: audit.ResourceTarget(appsBySlug[app]), - UserID: me.ID, - Ip: audit.ParseIP(auditableIP), - StatusCode: int32(w.StatusCode), //nolint:gosec - }), "audit log 3") + assertAuditApp(t, rw, r, auditor, appsBySlug[app], me.ID, nil) require.Len(t, auditor.AuditLogs(), 3, "three audit logs, new IP") } }) @@ -1413,3 +1284,41 @@ func signedTokenProviderWithAuditor(t testing.TB, provider workspaceapps.SignedT shallowCopy.WorkspaceAppAuditSessionTimeout = sessionTimeout return &shallowCopy } + +func auditAsserter[T audit.Auditable](workspace codersdk.Workspace) func(t testing.TB, rr *httptest.ResponseRecorder, r *http.Request, auditor *audit.MockAuditor, auditable T, userID uuid.UUID, additionalFields map[string]any) { + return func(t testing.TB, rr *httptest.ResponseRecorder, r *http.Request, auditor *audit.MockAuditor, auditable T, userID uuid.UUID, additionalFields map[string]any) { + t.Helper() + + resp := rr.Result() + defer resp.Body.Close() + + require.True(t, auditor.Contains(t, database.AuditLog{ + OrganizationID: workspace.OrganizationID, + Action: database.AuditActionOpen, + ResourceType: audit.ResourceType(auditable), + ResourceID: audit.ResourceID(auditable), + ResourceTarget: audit.ResourceTarget(auditable), + UserID: userID, + Ip: audit.ParseIP(r.RemoteAddr), + UserAgent: sql.NullString{Valid: r.UserAgent() != "", String: r.UserAgent()}, + StatusCode: int32(resp.StatusCode), //nolint:gosec + }), "audit log") + + // Verify additional fields, assume the last log entry. + alog := auditor.AuditLogs()[len(auditor.AuditLogs())-1] + + // Contains does not verify uuid.Nil. + if userID == uuid.Nil { + require.Equal(t, uuid.Nil, alog.UserID, "unauthenticated user") + } + + add := make(map[string]any) + if len(alog.AdditionalFields) > 0 { + err := json.Unmarshal([]byte(alog.AdditionalFields), &add) + require.NoError(t, err, "audit log unmarhsal additional fields") + } + for k, v := range additionalFields { + require.Equal(t, v, add[k], "audit log additional field %s: additional fields: %v", k, add) + } + } +} From 2c1536ed7a7c6ae95149ee5a478684d056a3cc65 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 6 Mar 2025 14:24:32 +0200 Subject: [PATCH 24/39] fixup! add slug or port to support separation of terminal and ports --- coderd/database/dump.sql | 2 +- .../000301_add_workspace_app_audit_sessions.up.sql | 2 +- .../000301_add_workspace_app_audit_sessions.up.sql | 8 ++++---- coderd/workspaceapps/db.go | 13 +++++++------ 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 3c592c9117dfc..99bf728f923e3 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1763,7 +1763,7 @@ CREATE UNLOGGED TABLE workspace_app_audit_sessions ( agent_id uuid NOT NULL, app_id uuid, user_id uuid, - ip inet NOT NULL, + ip inet, slug_or_port text NOT NULL, started_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL diff --git a/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql b/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql index 7ca8f2346b007..4cd8cfc06e037 100644 --- a/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql +++ b/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql @@ -3,7 +3,7 @@ CREATE UNLOGGED TABLE workspace_app_audit_sessions ( agent_id UUID NOT NULL, app_id UUID NULL, user_id UUID NULL, - ip inet NOT NULL, + ip inet NULL, slug_or_port TEXT NOT NULL, started_at TIMESTAMP WITH TIME ZONE NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL, diff --git a/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql b/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql index a0e76dd41d792..cfd79ca097dac 100644 --- a/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql +++ b/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql @@ -1,6 +1,6 @@ INSERT INTO workspace_app_audit_sessions - (agent_id, app_id, user_id, ip, started_at, updated_at) + (agent_id, app_id, user_id, ip, slug_or_port, started_at, updated_at) VALUES - ('45e89705-e09d-4850-bcec-f9a937f5d78d', '36b65d0c-042b-4653-863a-655ee739861c', '30095c71-380b-457a-8995-97b8ee6e5307', '127.0.0.1', '2025-03-04 15:08:38.579772+02', '2025-03-04 15:06:48.755158+02'), - ('45e89705-e09d-4850-bcec-f9a937f5d78d', '36b65d0c-042b-4653-863a-655ee739861c', NULL, '127.0.0.1', '2025-03-04 15:08:44.411389+02', '2025-03-04 15:08:44.411389+02'), - ('45e89705-e09d-4850-bcec-f9a937f5d78d', NULL, NULL, '::1', '2025-03-04 15:25:55.555306+02', '2025-03-04 15:25:55.555306+02'); + ('45e89705-e09d-4850-bcec-f9a937f5d78d', '36b65d0c-042b-4653-863a-655ee739861c', '30095c71-380b-457a-8995-97b8ee6e5307', '127.0.0.1', '', '2025-03-04 15:08:38.579772+02', '2025-03-04 15:06:48.755158+02'), + ('45e89705-e09d-4850-bcec-f9a937f5d78d', '36b65d0c-042b-4653-863a-655ee739861c', NULL, '127.0.0.1', '', '2025-03-04 15:08:44.411389+02', '2025-03-04 15:08:44.411389+02'), + ('45e89705-e09d-4850-bcec-f9a937f5d78d', NULL, NULL, '::1', 'terminal', '2025-03-04 15:25:55.555306+02', '2025-03-04 15:25:55.555306+02'); diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 6fb7b45747bce..1ce9ecc8e06f6 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -467,12 +467,13 @@ func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http } sessionID, err = tx.InsertWorkspaceAppAuditSession(dangerousSystemCtx, database.InsertWorkspaceAppAuditSessionParams{ - AgentID: aReq.dbReq.Agent.ID, - AppID: uuid.NullUUID{Valid: aReq.dbReq.App.ID != uuid.Nil, UUID: aReq.dbReq.App.ID}, - UserID: userID, - Ip: aReq.ip, - StartedAt: aReq.time, - UpdatedAt: aReq.time, + AgentID: aReq.dbReq.Agent.ID, + AppID: uuid.NullUUID{Valid: aReq.dbReq.App.ID != uuid.Nil, UUID: aReq.dbReq.App.ID}, + UserID: userID, + Ip: aReq.ip, + SlugOrPort: appInfo.SlugOrPort, + StartedAt: aReq.time, + UpdatedAt: aReq.time, }) if err != nil { return xerrors.Errorf("insert workspace app audit session: %w", err) From c070d74e7c2d6f8850e0530979898bba278fef11 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 10 Mar 2025 09:51:25 +0000 Subject: [PATCH 25/39] move RandomIPv6 to testutil --- coderd/workspaceapps/db_test.go | 51 ++++++++++++--------------------- testutil/rand.go | 17 +++++++++++ 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index a632816b3a76f..d7db00653c1ff 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -2,7 +2,6 @@ package workspaceapps_test import ( "context" - "crypto/rand" "database/sql" "encoding/json" "fmt" @@ -289,7 +288,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) auditableUA := "Tidua" t.Log("app", app) @@ -386,7 +385,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) t.Log("app", app) rw := httptest.NewRecorder() @@ -434,7 +433,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) t.Log("app", app) rw := httptest.NewRecorder() @@ -482,7 +481,7 @@ func Test_ResolveRequest(t *testing.T) { AccessMethod: "invalid", }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) r.RemoteAddr = auditableIP @@ -565,7 +564,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) @@ -640,7 +639,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) @@ -695,7 +694,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) @@ -733,7 +732,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) @@ -771,7 +770,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) @@ -810,7 +809,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) @@ -841,7 +840,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) @@ -883,7 +882,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) @@ -916,7 +915,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) @@ -949,7 +948,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/some-path", nil) @@ -1012,7 +1011,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) @@ -1075,7 +1074,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) @@ -1133,7 +1132,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) rw := httptest.NewRecorder() r := httptest.NewRequest("GET", "/app", nil) @@ -1168,7 +1167,7 @@ func Test_ResolveRequest(t *testing.T) { }).Normalize() auditor := audit.NewMock() - auditableIP := randomIPv6(t) + auditableIP := testutil.RandomIPv6(t) t.Log("app", app) @@ -1227,7 +1226,7 @@ func Test_ResolveRequest(t *testing.T) { require.Len(t, auditor.AuditLogs(), 2, "two audit logs, session timed out") // Fourth request, new IP produces new audit log. - auditableIP = randomIPv6(t) + auditableIP = testutil.RandomIPv6(t) rw = httptest.NewRecorder() r = httptest.NewRequest("GET", "/app", nil) r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) @@ -1248,18 +1247,6 @@ func Test_ResolveRequest(t *testing.T) { }) } -func randomIPv6(t testing.TB) string { - t.Helper() - - // 2001:db8::/32 is reserved for documentation and examples. - buf := make([]byte, 16) - _, err := rand.Read(buf) - require.NoError(t, err, "error generating random IPv6 address") - return fmt.Sprintf("2001:db8:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x", - buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], - buf[6], buf[7], buf[8], buf[9], buf[10], buf[11]) -} - func workspaceappsResolveRequest(t testing.TB, auditor audit.Auditor, w http.ResponseWriter, r *http.Request, opts workspaceapps.ResolveRequestOptions) (token *workspaceapps.SignedToken, ok bool) { t.Helper() if opts.SignedTokenProvider != nil && auditor != nil { diff --git a/testutil/rand.go b/testutil/rand.go index b20cb9b0573d1..ddf371a88c7ea 100644 --- a/testutil/rand.go +++ b/testutil/rand.go @@ -1,6 +1,8 @@ package testutil import ( + "crypto/rand" + "fmt" "testing" "github.com/stretchr/testify/require" @@ -15,3 +17,18 @@ func MustRandString(t *testing.T, n int) string { require.NoError(t, err) return s } + +// RandomIPv6 returns a random IPv6 address in the 2001:db8::/32 range. +// 2001:db8::/32 is reserved for documentation and example code. +func RandomIPv6(t testing.TB) string { + t.Helper() + + buf := make([]byte, 16) + _, err := rand.Read(buf) + require.NoError(t, err, "generate random IPv6 address") + return fmt.Sprintf( + "2001:db8:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x", + buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], + buf[6], buf[7], buf[8], buf[9], buf[10], buf[11], + ) +} From 8a61541cc306479ce61e0aef9764ab9132d034dd Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 10 Mar 2025 10:14:12 +0000 Subject: [PATCH 26/39] commit audit in Issue, revert tracing status writer changes --- coderd/tracing/status_writer.go | 9 --------- coderd/tracing/status_writer_test.go | 9 ++------- coderd/workspaceapps/db.go | 25 +++++++++++++++---------- 3 files changed, 17 insertions(+), 26 deletions(-) diff --git a/coderd/tracing/status_writer.go b/coderd/tracing/status_writer.go index aae6db90ca747..e9337c20e022f 100644 --- a/coderd/tracing/status_writer.go +++ b/coderd/tracing/status_writer.go @@ -27,7 +27,6 @@ type StatusWriter struct { http.ResponseWriter Status int Hijacked bool - doneFuncs []func() // If non-nil, this function will be called when the handler is done. responseBody []byte wroteHeader bool @@ -38,17 +37,9 @@ func StatusWriterMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { sw := &StatusWriter{ResponseWriter: rw} next.ServeHTTP(sw, r) - for _, done := range sw.doneFuncs { - done() - } }) } -func (w *StatusWriter) AddDoneFunc(f func()) { - // Prepend, as if deferred. - w.doneFuncs = append([]func(){f}, w.doneFuncs...) -} - func (w *StatusWriter) WriteHeader(status int) { if buildinfo.IsDev() || flag.Lookup("test.v") != nil { if w.wroteHeader { diff --git a/coderd/tracing/status_writer_test.go b/coderd/tracing/status_writer_test.go index 78c8a7826491a..6aff7b915ce46 100644 --- a/coderd/tracing/status_writer_test.go +++ b/coderd/tracing/status_writer_test.go @@ -121,21 +121,16 @@ func TestStatusWriter(t *testing.T) { t.Parallel() var ( - sw *tracing.StatusWriter - done = false - rr = httptest.NewRecorder() + sw *tracing.StatusWriter + rr = httptest.NewRecorder() ) tracing.StatusWriterMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sw = w.(*tracing.StatusWriter) - sw.AddDoneFunc(func() { - done = true - }) w.WriteHeader(http.StatusNoContent) })).ServeHTTP(rr, httptest.NewRequest("GET", "/", nil)) require.Equal(t, http.StatusNoContent, rr.Code, "rr status code not set") require.Equal(t, http.StatusNoContent, sw.Status, "sw status code not set") - require.True(t, done, "done func not called") }) } diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 1ce9ecc8e06f6..a696a6d3c5e57 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -96,7 +96,8 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r * // // permissions. dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx) - aReq := p.auditInitAutocommitRequest(ctx, rw, r) + aReq, commitAudit := p.auditInitRequest(ctx, rw, r) + defer commitAudit() appReq := issueReq.AppRequest.Normalize() err := appReq.Check() @@ -371,14 +372,14 @@ type auditRequest struct { dbReq *databaseRequest } -// auditInitAutocommitRequest creates a new audit session and audit log for the -// given request, if one does not already exist. If an audit session already -// exists, it will be updated with the current timestamp. A session is used to -// reduce the number of audit logs created. +// auditInitRequest creates a new audit session and audit log for the given +// request, if one does not already exist. If an audit session already exists, +// it will be updated with the current timestamp. A session is used to reduce +// the number of audit logs created. // // A session is unique to the agent, app, user and users IP. If any of these // values change, a new session and audit log is created. -func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (aReq *auditRequest) { +func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (aReq *auditRequest, commit func()) { // Get the status writer from the request context so we can figure // out the HTTP status and autocommit the audit log. sw, ok := w.(*tracing.StatusWriter) @@ -393,7 +394,13 @@ func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http // Set the commit function on the status writer to create an audit // log, this ensures that the status and response body are available. - sw.AddDoneFunc(func() { + var committed bool + return aReq, func() { + if committed { + return + } + committed = true + if sw.Status == http.StatusSeeOther { // Redirects aren't interesting as we will capture the audit // log after the redirect. @@ -548,7 +555,5 @@ func (p *DBTokenProvider) auditInitAutocommitRequest(ctx context.Context, w http AdditionalFields: appInfoBytes, }) } - }) - - return aReq + } } From 7279b9a33a4ea17e07bc0c9e1b602370112223f3 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 10 Mar 2025 10:16:44 +0000 Subject: [PATCH 27/39] return if tx failed --- coderd/workspaceapps/db.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index a696a6d3c5e57..8fd272286a4c1 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -490,6 +490,10 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW }, nil) if err != nil { p.Logger.Error(ctx, "update workspace app audit session failed", slog.Error(err)) + + // Avoid spamming the audit log if deduplication failed, this should + // only happen if there are problems communicating with the database. + return } if sessionID == uuid.Nil { From 4ff41d484eb45f92499aa9aa3068d8a3aa29ccb6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 10 Mar 2025 10:17:07 +0000 Subject: [PATCH 28/39] add fields to audit logger --- coderd/workspaceapps/db.go | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 8fd272286a4c1..8e873d995a7f4 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -419,6 +419,11 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW return } + userID := uuid.NullUUID{} + if aReq.apiKey != nil { + userID = uuid.NullUUID{Valid: true, UUID: aReq.apiKey.UserID} + } + type additionalFields struct { audit.AdditionalFields SlugOrPort string `json:"slug_or_port,omitempty"` @@ -438,14 +443,17 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW appInfo.SlugOrPort = aReq.dbReq.AppSlugOrPort } + logger := p.Logger.With( + slog.F("workspace_id", aReq.dbReq.Workspace.ID), + slog.F("agent_id", aReq.dbReq.Agent.ID), + slog.F("app_id", aReq.dbReq.App.ID), + slog.F("user_id", userID.UUID), + slog.F("app_slug_or_port", appInfo.SlugOrPort), + ) + appInfoBytes, err := json.Marshal(appInfo) if err != nil { - p.Logger.Error(ctx, "marshal additional fields failed", slog.Error(err)) - } - - userID := uuid.NullUUID{} - if aReq.apiKey != nil { - userID = uuid.NullUUID{Valid: true, UUID: aReq.apiKey.UserID} + logger.Error(ctx, "marshal additional fields failed", slog.Error(err)) } var ( @@ -489,7 +497,7 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW return nil }, nil) if err != nil { - p.Logger.Error(ctx, "update workspace app audit session failed", slog.Error(err)) + logger.Error(ctx, "update workspace app audit session failed", slog.Error(err)) // Avoid spamming the audit log if deduplication failed, this should // only happen if there are problems communicating with the database. @@ -528,7 +536,7 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW case aReq.dbReq.App.ID != uuid.Nil: audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceApp]{ Audit: auditor, - Log: p.Logger, + Log: logger, Action: database.AuditActionOpen, OrganizationID: aReq.dbReq.Workspace.OrganizationID, @@ -545,7 +553,7 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW // Web terminal, port app, etc. audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceAgent]{ Audit: auditor, - Log: p.Logger, + Log: logger, Action: database.AuditActionOpen, OrganizationID: aReq.dbReq.Workspace.OrganizationID, From c723b95ba9c308f7f10eeff117ac7041d7e79034 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 17 Mar 2025 10:37:31 +0000 Subject: [PATCH 29/39] comment on WorkspaceAppAuditSessionTimeout use-case --- coderd/coderd.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/coderd/coderd.go b/coderd/coderd.go index e393df155b612..fe931d49ddcbf 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -226,6 +226,9 @@ type Options struct { UpdateAgentMetrics func(ctx context.Context, labels prometheusmetrics.AgentMetricLabels, metrics []*agentproto.Stats_Metric) StatsBatcher workspacestats.Batcher + // WorkspaceAppAuditSessionTimeout allows changing the timeout for audit + // sessions. Raising or lowering this value will directly affect the write + // load of the audit log table. This is used for testing. Default 1 hour. WorkspaceAppAuditSessionTimeout time.Duration WorkspaceAppsStatsCollectorOptions workspaceapps.StatsCollectorOptions From 217a0d3d4c3b8716d6d32a0b5a6988f3f5e0c01f Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 17 Mar 2025 12:42:48 +0000 Subject: [PATCH 30/39] update migrations, add status and ua, unique entries --- ...01_add_workspace_app_audit_sessions.up.sql | 32 +++++++++++-------- ...01_add_workspace_app_audit_sessions.up.sql | 8 ++--- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql b/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql index 4cd8cfc06e037..7108f9f048370 100644 --- a/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql +++ b/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql @@ -1,27 +1,33 @@ +-- Keep all unique fields as non-null because `UNIQUE NULLS NOT DISTINCT` +-- requires PostgreSQL 15+. CREATE UNLOGGED TABLE workspace_app_audit_sessions ( - id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), agent_id UUID NOT NULL, - app_id UUID NULL, - user_id UUID NULL, - ip inet NULL, + app_id UUID NOT NULL, -- Can be NULL, but must be uuid.Nil. + user_id UUID NOT NULL, -- Can be NULL, but must be uuid.Nil. + ip inet NOT NULL, + user_agent TEXT NOT NULL, slug_or_port TEXT NOT NULL, + status_code int4 NOT NULL, started_at TIMESTAMP WITH TIME ZONE NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (agent_id) REFERENCES workspace_agents (id) ON DELETE CASCADE, - FOREIGN KEY (app_id) REFERENCES workspace_apps (id) ON DELETE CASCADE + -- Skip foreign keys that we can't enforce due to NOT NULL constraints. + -- FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + -- FOREIGN KEY (app_id) REFERENCES workspace_apps (id) ON DELETE CASCADE, + UNIQUE (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code) ); -COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, the data in this table is ephemeral and is used to track the current session of a user in a workspace app.'; -COMMENT ON COLUMN workspace_app_audit_sessions.id IS 'Unique identifier for the workspace app audit session.'; -COMMENT ON COLUMN workspace_app_audit_sessions.agent_id IS 'The agent that is currently in the workspace app.'; -COMMENT ON COLUMN workspace_app_audit_sessions.app_id IS 'The app that is currently in the workspace app. This is nullable because ports are not associated with an app.'; -COMMENT ON COLUMN workspace_app_audit_sessions.user_id IS 'The user that is currently using the workspace app. This is nullable because the app may be public.'; +COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, the data in this table is ephemeral and is used to deduplicate audit log entries for workspace apps. While a session is active, the same data will not be logged again. This table does not store historical data.'; +COMMENT ON COLUMN workspace_app_audit_sessions.agent_id IS 'The agent that the workspace app or port forward belongs to.'; +COMMENT ON COLUMN workspace_app_audit_sessions.app_id IS 'The app that is currently in the workspace app. This is may be uuid.Nil because ports are not associated with an app.'; +COMMENT ON COLUMN workspace_app_audit_sessions.user_id IS 'The user that is currently using the workspace app. This is may be uuid.Nil if we cannot determine the user.'; COMMENT ON COLUMN workspace_app_audit_sessions.ip IS 'The IP address of the user that is currently using the workspace app.'; +COMMENT ON COLUMN workspace_app_audit_sessions.user_agent IS 'The user agent of the user that is currently using the workspace app.'; COMMENT ON COLUMN workspace_app_audit_sessions.slug_or_port IS 'The slug or port of the workspace app that the user is currently using.'; +COMMENT ON COLUMN workspace_app_audit_sessions.status_code IS 'The HTTP status produced by the token authorization. Defaults to 200 if no status is provided.'; COMMENT ON COLUMN workspace_app_audit_sessions.started_at IS 'The time the user started the session.'; COMMENT ON COLUMN workspace_app_audit_sessions.updated_at IS 'The time the session was last updated.'; -CREATE INDEX workspace_app_audit_sessions_agent_id_app_id_slug_or_port ON workspace_app_audit_sessions (agent_id, app_id, slug_or_port); +CREATE UNIQUE INDEX workspace_app_audit_sessions_unique_index ON workspace_app_audit_sessions (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code); -COMMENT ON INDEX workspace_app_audit_sessions_agent_id_app_id_slug_or_port IS 'Index for the agent_id and app_id columns to perform updates.'; +COMMENT ON INDEX workspace_app_audit_sessions_unique_index IS 'Unique index to ensure that we do not allow duplicate entries from multiple transactions.'; diff --git a/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql b/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql index cfd79ca097dac..46ded469a7463 100644 --- a/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql +++ b/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql @@ -1,6 +1,6 @@ INSERT INTO workspace_app_audit_sessions - (agent_id, app_id, user_id, ip, slug_or_port, started_at, updated_at) + (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code, started_at, updated_at) VALUES - ('45e89705-e09d-4850-bcec-f9a937f5d78d', '36b65d0c-042b-4653-863a-655ee739861c', '30095c71-380b-457a-8995-97b8ee6e5307', '127.0.0.1', '', '2025-03-04 15:08:38.579772+02', '2025-03-04 15:06:48.755158+02'), - ('45e89705-e09d-4850-bcec-f9a937f5d78d', '36b65d0c-042b-4653-863a-655ee739861c', NULL, '127.0.0.1', '', '2025-03-04 15:08:44.411389+02', '2025-03-04 15:08:44.411389+02'), - ('45e89705-e09d-4850-bcec-f9a937f5d78d', NULL, NULL, '::1', 'terminal', '2025-03-04 15:25:55.555306+02', '2025-03-04 15:25:55.555306+02'); + ('45e89705-e09d-4850-bcec-f9a937f5d78d', '36b65d0c-042b-4653-863a-655ee739861c', '30095c71-380b-457a-8995-97b8ee6e5307', '127.0.0.1', 'curl', '', 200, '2025-03-04 15:08:38.579772+02', '2025-03-04 15:06:48.755158+02'), + ('45e89705-e09d-4850-bcec-f9a937f5d78d', '36b65d0c-042b-4653-863a-655ee739861c', NULL, '127.0.0.1', 'curl', '', 200, '2025-03-04 15:08:44.411389+02', '2025-03-04 15:08:44.411389+02'), + ('45e89705-e09d-4850-bcec-f9a937f5d78d', NULL, NULL, '::1', 'curl', 'terminal', 0, '2025-03-04 15:25:55.555306+02', '2025-03-04 15:25:55.555306+02'); From 336c7b8e10e9356e318b7289b344a46b4b62186c Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 17 Mar 2025 12:43:17 +0000 Subject: [PATCH 31/39] rewrite queries, single upsert --- coderd/database/queries/workspaceappaudit.sql | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/coderd/database/queries/workspaceappaudit.sql b/coderd/database/queries/workspaceappaudit.sql index 9ceeebfd0ab2c..596032d61343f 100644 --- a/coderd/database/queries/workspaceappaudit.sql +++ b/coderd/database/queries/workspaceappaudit.sql @@ -1,11 +1,16 @@ --- name: InsertWorkspaceAppAuditSession :one +-- name: UpsertWorkspaceAppAuditSession :one +-- +-- Insert a new workspace app audit session or update an existing one, if +-- started_at is updated, it means the session has been restarted. INSERT INTO workspace_app_audit_sessions ( agent_id, app_id, user_id, ip, + user_agent, slug_or_port, + status_code, started_at, updated_at ) @@ -17,25 +22,20 @@ VALUES $4, $5, $6, - $7 + $7, + $8, + $9 ) +ON CONFLICT + (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code) +DO + UPDATE + SET + started_at = CASE + WHEN workspace_app_audit_sessions.updated_at > NOW() - (@stale_interval_ms::bigint || ' ms')::interval + THEN workspace_app_audit_sessions.started_at + ELSE EXCLUDED.started_at + END, + updated_at = EXCLUDED.updated_at RETURNING - id; - --- name: UpdateWorkspaceAppAuditSession :many --- --- Return ID to determine if a row was updated or not. This table isn't strict --- about uniqueness, so we need to know if we updated an existing row or not. -UPDATE - workspace_app_audit_sessions -SET - updated_at = @updated_at -WHERE - agent_id = @agent_id - AND app_id IS NOT DISTINCT FROM @app_id - AND user_id IS NOT DISTINCT FROM @user_id - AND ip IS NOT DISTINCT FROM @ip - AND slug_or_port = @slug_or_port - AND updated_at > NOW() - (@stale_interval_ms::bigint || ' ms')::interval -RETURNING - id; + started_at; From 8d7a763ef5bcca06aee2a68c69bd43ae321eb315 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 17 Mar 2025 12:43:36 +0000 Subject: [PATCH 32/39] make gen for db --- coderd/database/dbauthz/dbauthz.go | 21 +-- coderd/database/dbauthz/dbauthz_test.go | 4 +- coderd/database/dbmem/dbmem.go | 117 ++++++------ coderd/database/dbmetrics/querymetrics.go | 21 +-- coderd/database/dbmock/dbmock.go | 45 ++--- coderd/database/dump.sql | 35 ++-- coderd/database/foreign_key_constraint.go | 2 - coderd/database/models.go | 18 +- coderd/database/querier.go | 9 +- coderd/database/queries.sql.go | 112 ++++-------- coderd/database/unique_constraint.go | 209 +++++++++++----------- 11 files changed, 261 insertions(+), 332 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 7007fae0ae82c..08e6f3346208a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3439,13 +3439,6 @@ func (q *querier) InsertWorkspaceApp(ctx context.Context, arg database.InsertWor return q.db.InsertWorkspaceApp(ctx, arg) } -func (q *querier) InsertWorkspaceAppAuditSession(ctx context.Context, arg database.InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { - if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil { - return uuid.Nil, err - } - return q.db.InsertWorkspaceAppAuditSession(ctx, arg) -} - func (q *querier) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error { if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil { return err @@ -4276,13 +4269,6 @@ func (q *querier) UpdateWorkspaceAgentStartupByID(ctx context.Context, arg datab return q.db.UpdateWorkspaceAgentStartupByID(ctx, arg) } -func (q *querier) UpdateWorkspaceAppAuditSession(ctx context.Context, arg database.UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { - return nil, err - } - return q.db.UpdateWorkspaceAppAuditSession(ctx, arg) -} - func (q *querier) UpdateWorkspaceAppHealthByID(ctx context.Context, arg database.UpdateWorkspaceAppHealthByIDParams) error { // TODO: This is a workspace agent operation. Should users be able to query this? workspace, err := q.db.GetWorkspaceByWorkspaceAppID(ctx, arg.ID) @@ -4621,6 +4607,13 @@ func (q *querier) UpsertWorkspaceAgentPortShare(ctx context.Context, arg databas return q.db.UpsertWorkspaceAgentPortShare(ctx, arg) } +func (q *querier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (time.Time, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { + return time.Time{}, err + } + return q.db.UpsertWorkspaceAppAuditSession(ctx, arg) +} + func (q *querier) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, _ rbac.PreparedAuthorized) ([]database.Template, error) { // TODO Delete this function, all GetTemplates should be authorized. For now just call getTemplates on the authz querier. return q.GetTemplatesWithFilter(ctx, arg) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e8294d3298768..0ea8a1fbd891c 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4039,13 +4039,13 @@ func (s *MethodTestSuite) TestSystemFunctions() { s.Run("InsertWorkspaceAppStats", s.Subtest(func(db database.Store, check *expects) { check.Args(database.InsertWorkspaceAppStatsParams{}).Asserts(rbac.ResourceSystem, policy.ActionCreate) })) - s.Run("InsertWorkspaceAppAuditSession", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpsertWorkspaceAppAuditSession", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) pj := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{}) res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: pj.ID}) agent := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) app := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: agent.ID}) - check.Args(database.InsertWorkspaceAppAuditSessionParams{ + check.Args(database.UpsertWorkspaceAppAuditSessionParams{ AgentID: agent.ID, AppID: uuid.NullUUID{UUID: app.ID, Valid: true}, UserID: uuid.NullUUID{UUID: u.ID, Valid: true}, diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 6b3009e4ae2d9..1a6762f9848ba 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -9251,30 +9251,6 @@ func (q *FakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertW return workspaceApp, nil } -func (q *FakeQuerier) InsertWorkspaceAppAuditSession(_ context.Context, arg database.InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { - err := validateDatabaseType(arg) - if err != nil { - return uuid.Nil, err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - id := uuid.New() - q.workspaceAppAuditSessions = append(q.workspaceAppAuditSessions, database.WorkspaceAppAuditSession{ - ID: id, - AgentID: arg.AgentID, - AppID: arg.AppID, - UserID: arg.UserID, - Ip: arg.Ip, - SlugOrPort: arg.SlugOrPort, - StartedAt: arg.StartedAt, - UpdatedAt: arg.UpdatedAt, - }) - - return id, nil -} - func (q *FakeQuerier) InsertWorkspaceAppStats(_ context.Context, arg database.InsertWorkspaceAppStatsParams) error { err := validateDatabaseType(arg) if err != nil { @@ -11021,42 +10997,6 @@ func (q *FakeQuerier) UpdateWorkspaceAgentStartupByID(_ context.Context, arg dat return sql.ErrNoRows } -func (q *FakeQuerier) UpdateWorkspaceAppAuditSession(_ context.Context, arg database.UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) { - err := validateDatabaseType(arg) - if err != nil { - return nil, err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - var updated []uuid.UUID - for i, s := range q.workspaceAppAuditSessions { - if s.AgentID != arg.AgentID { - continue - } - if s.AppID != arg.AppID { - continue - } - if s.UserID != arg.UserID { - continue - } - if s.Ip.IPNet.String() != arg.Ip.IPNet.String() { - continue - } - if s.SlugOrPort != arg.SlugOrPort { - continue - } - staleTime := dbtime.Now().Add(-(time.Duration(arg.StaleIntervalMS) * time.Millisecond)) - if !s.UpdatedAt.After(staleTime) { - continue - } - q.workspaceAppAuditSessions[i].UpdatedAt = arg.UpdatedAt - updated = append(updated, s.ID) - } - return updated, nil -} - func (q *FakeQuerier) UpdateWorkspaceAppHealthByID(_ context.Context, arg database.UpdateWorkspaceAppHealthByIDParams) error { if err := validateDatabaseType(arg); err != nil { return err @@ -12278,6 +12218,63 @@ func (q *FakeQuerier) UpsertWorkspaceAgentPortShare(_ context.Context, arg datab return psl, nil } +func (q *FakeQuerier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (time.Time, error) { + err := validateDatabaseType(arg) + if err != nil { + return time.Time{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, s := range q.workspaceAppAuditSessions { + if s.AgentID != arg.AgentID { + continue + } + if s.AppID != arg.AppID { + continue + } + if s.UserID != arg.UserID { + continue + } + if s.Ip.IPNet.String() != arg.Ip.IPNet.String() { + continue + } + if s.UserAgent != arg.UserAgent { + continue + } + if s.SlugOrPort != arg.SlugOrPort { + continue + } + if s.StatusCode != arg.StatusCode { + continue + } + + staleTime := dbtime.Now().Add(-(time.Duration(arg.StaleIntervalMS) * time.Millisecond)) + fresh := s.UpdatedAt.After(staleTime) + + q.workspaceAppAuditSessions[i].UpdatedAt = arg.UpdatedAt + if !fresh { + q.workspaceAppAuditSessions[i].StartedAt = arg.StartedAt + return arg.StartedAt, nil + } + return s.StartedAt, nil + } + + q.workspaceAppAuditSessions = append(q.workspaceAppAuditSessions, database.WorkspaceAppAuditSession{ + AgentID: arg.AgentID, + AppID: arg.AppID, + UserID: arg.UserID, + Ip: arg.Ip, + UserAgent: arg.UserAgent, + SlugOrPort: arg.SlugOrPort, + StatusCode: arg.StatusCode, + StartedAt: arg.StartedAt, + UpdatedAt: arg.UpdatedAt, + }) + return arg.StartedAt, nil +} + func (q *FakeQuerier) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, prepared rbac.PreparedAuthorized) ([]database.Template, error) { if err := validateDatabaseType(arg); err != nil { return nil, err diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 392c9b14d7811..db40b92f15385 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2187,13 +2187,6 @@ func (m queryMetricsStore) InsertWorkspaceApp(ctx context.Context, arg database. return app, err } -func (m queryMetricsStore) InsertWorkspaceAppAuditSession(ctx context.Context, arg database.InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { - start := time.Now() - r0, r1 := m.s.InsertWorkspaceAppAuditSession(ctx, arg) - m.queryLatencies.WithLabelValues("InsertWorkspaceAppAuditSession").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m queryMetricsStore) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error { start := time.Now() r0 := m.s.InsertWorkspaceAppStats(ctx, arg) @@ -2712,13 +2705,6 @@ func (m queryMetricsStore) UpdateWorkspaceAgentStartupByID(ctx context.Context, return err } -func (m queryMetricsStore) UpdateWorkspaceAppAuditSession(ctx context.Context, arg database.UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) { - start := time.Now() - r0, r1 := m.s.UpdateWorkspaceAppAuditSession(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateWorkspaceAppAuditSession").Observe(time.Since(start).Seconds()) - return r0, r1 -} - func (m queryMetricsStore) UpdateWorkspaceAppHealthByID(ctx context.Context, arg database.UpdateWorkspaceAppHealthByIDParams) error { start := time.Now() err := m.s.UpdateWorkspaceAppHealthByID(ctx, arg) @@ -2992,6 +2978,13 @@ func (m queryMetricsStore) UpsertWorkspaceAgentPortShare(ctx context.Context, ar return r0, r1 } +func (m queryMetricsStore) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (time.Time, error) { + start := time.Now() + r0, r1 := m.s.UpsertWorkspaceAppAuditSession(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertWorkspaceAppAuditSession").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetAuthorizedTemplates(ctx context.Context, arg database.GetTemplatesWithFilterParams, prepared rbac.PreparedAuthorized) ([]database.Template, error) { start := time.Now() templates, err := m.s.GetAuthorizedTemplates(ctx, arg, prepared) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index ab198e70ff435..0a59a442f3e40 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -4616,21 +4616,6 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceApp(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceApp", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceApp), ctx, arg) } -// InsertWorkspaceAppAuditSession mocks base method. -func (m *MockStore) InsertWorkspaceAppAuditSession(ctx context.Context, arg database.InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InsertWorkspaceAppAuditSession", ctx, arg) - ret0, _ := ret[0].(uuid.UUID) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// InsertWorkspaceAppAuditSession indicates an expected call of InsertWorkspaceAppAuditSession. -func (mr *MockStoreMockRecorder) InsertWorkspaceAppAuditSession(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAppAuditSession", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAppAuditSession), ctx, arg) -} - // InsertWorkspaceAppStats mocks base method. func (m *MockStore) InsertWorkspaceAppStats(ctx context.Context, arg database.InsertWorkspaceAppStatsParams) error { m.ctrl.T.Helper() @@ -5733,21 +5718,6 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceAgentStartupByID(ctx, arg any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAgentStartupByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAgentStartupByID), ctx, arg) } -// UpdateWorkspaceAppAuditSession mocks base method. -func (m *MockStore) UpdateWorkspaceAppAuditSession(ctx context.Context, arg database.UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWorkspaceAppAuditSession", ctx, arg) - ret0, _ := ret[0].([]uuid.UUID) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// UpdateWorkspaceAppAuditSession indicates an expected call of UpdateWorkspaceAppAuditSession. -func (mr *MockStoreMockRecorder) UpdateWorkspaceAppAuditSession(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAppAuditSession", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAppAuditSession), ctx, arg) -} - // UpdateWorkspaceAppHealthByID mocks base method. func (m *MockStore) UpdateWorkspaceAppHealthByID(ctx context.Context, arg database.UpdateWorkspaceAppHealthByIDParams) error { m.ctrl.T.Helper() @@ -6304,6 +6274,21 @@ func (mr *MockStoreMockRecorder) UpsertWorkspaceAgentPortShare(ctx, arg any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceAgentPortShare", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceAgentPortShare), ctx, arg) } +// UpsertWorkspaceAppAuditSession mocks base method. +func (m *MockStore) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (time.Time, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertWorkspaceAppAuditSession", ctx, arg) + ret0, _ := ret[0].(time.Time) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertWorkspaceAppAuditSession indicates an expected call of UpsertWorkspaceAppAuditSession. +func (mr *MockStoreMockRecorder) UpsertWorkspaceAppAuditSession(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceAppAuditSession", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceAppAuditSession), ctx, arg) +} + // Wrappers mocks base method. func (m *MockStore) Wrappers() []string { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 99bf728f923e3..5c1691d07974b 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1759,30 +1759,33 @@ COMMENT ON COLUMN workspace_agents.ready_at IS 'The time the agent entered the r COMMENT ON COLUMN workspace_agents.display_order IS 'Specifies the order in which to display agents in user interfaces.'; CREATE UNLOGGED TABLE workspace_app_audit_sessions ( - id uuid DEFAULT gen_random_uuid() NOT NULL, agent_id uuid NOT NULL, - app_id uuid, - user_id uuid, - ip inet, + app_id uuid NOT NULL, + user_id uuid NOT NULL, + ip inet NOT NULL, + user_agent text NOT NULL, slug_or_port text NOT NULL, + status_code integer NOT NULL, started_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL ); -COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, the data in this table is ephemeral and is used to track the current session of a user in a workspace app.'; - -COMMENT ON COLUMN workspace_app_audit_sessions.id IS 'Unique identifier for the workspace app audit session.'; +COMMENT ON TABLE workspace_app_audit_sessions IS 'Audit sessions for workspace apps, the data in this table is ephemeral and is used to deduplicate audit log entries for workspace apps. While a session is active, the same data will not be logged again. This table does not store historical data.'; -COMMENT ON COLUMN workspace_app_audit_sessions.agent_id IS 'The agent that is currently in the workspace app.'; +COMMENT ON COLUMN workspace_app_audit_sessions.agent_id IS 'The agent that the workspace app or port forward belongs to.'; -COMMENT ON COLUMN workspace_app_audit_sessions.app_id IS 'The app that is currently in the workspace app. This is nullable because ports are not associated with an app.'; +COMMENT ON COLUMN workspace_app_audit_sessions.app_id IS 'The app that is currently in the workspace app. This is may be uuid.Nil because ports are not associated with an app.'; -COMMENT ON COLUMN workspace_app_audit_sessions.user_id IS 'The user that is currently using the workspace app. This is nullable because the app may be '; +COMMENT ON COLUMN workspace_app_audit_sessions.user_id IS 'The user that is currently using the workspace app. This is may be uuid.Nil if we cannot determine the user.'; COMMENT ON COLUMN workspace_app_audit_sessions.ip IS 'The IP address of the user that is currently using the workspace app.'; +COMMENT ON COLUMN workspace_app_audit_sessions.user_agent IS 'The user agent of the user that is currently using the workspace app.'; + COMMENT ON COLUMN workspace_app_audit_sessions.slug_or_port IS 'The slug or port of the workspace app that the user is currently using.'; +COMMENT ON COLUMN workspace_app_audit_sessions.status_code IS 'The HTTP status produced by the token authorization. Defaults to 200 if no status is provided.'; + COMMENT ON COLUMN workspace_app_audit_sessions.started_at IS 'The time the user started the session.'; COMMENT ON COLUMN workspace_app_audit_sessions.updated_at IS 'The time the session was last updated.'; @@ -2274,7 +2277,7 @@ ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_pkey PRIMARY KEY (id); ALTER TABLE ONLY workspace_app_audit_sessions - ADD CONSTRAINT workspace_app_audit_sessions_pkey PRIMARY KEY (id); + ADD CONSTRAINT workspace_app_audit_sessions_agent_id_app_id_user_id_ip_use_key UNIQUE (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code); ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_pkey PRIMARY KEY (id); @@ -2414,9 +2417,9 @@ CREATE INDEX workspace_agents_auth_token_idx ON workspace_agents USING btree (au CREATE INDEX workspace_agents_resource_id_idx ON workspace_agents USING btree (resource_id); -CREATE INDEX workspace_app_audit_sessions_agent_id_app_id_slug_or_port ON workspace_app_audit_sessions USING btree (agent_id, app_id, slug_or_port); +CREATE UNIQUE INDEX workspace_app_audit_sessions_unique_index ON workspace_app_audit_sessions USING btree (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code); -COMMENT ON INDEX workspace_app_audit_sessions_agent_id_app_id_slug_or_port IS 'Index for the agent_id and app_id columns to perform updates.'; +COMMENT ON INDEX workspace_app_audit_sessions_unique_index IS 'Unique index to ensure that we do not allow duplicate entries from multiple transactions.'; CREATE INDEX workspace_app_stats_workspace_id_idx ON workspace_app_stats USING btree (workspace_id); @@ -2703,12 +2706,6 @@ ALTER TABLE ONLY workspace_agents ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; -ALTER TABLE ONLY workspace_app_audit_sessions - ADD CONSTRAINT workspace_app_audit_sessions_app_id_fkey FOREIGN KEY (app_id) REFERENCES workspace_apps(id) ON DELETE CASCADE; - -ALTER TABLE ONLY workspace_app_audit_sessions - ADD CONSTRAINT workspace_app_audit_sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index b231644443f2c..410c484ab96a2 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -67,8 +67,6 @@ const ( ForeignKeyWorkspaceAgentVolumeResourceMonitorsAgentID ForeignKeyConstraint = "workspace_agent_volume_resource_monitors_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_volume_resource_monitors ADD CONSTRAINT workspace_agent_volume_resource_monitors_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceAgentsResourceID ForeignKeyConstraint = "workspace_agents_resource_id_fkey" // ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_resource_id_fkey FOREIGN KEY (resource_id) REFERENCES workspace_resources(id) ON DELETE CASCADE; ForeignKeyWorkspaceAppAuditSessionsAgentID ForeignKeyConstraint = "workspace_app_audit_sessions_agent_id_fkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAppAuditSessionsAppID ForeignKeyConstraint = "workspace_app_audit_sessions_app_id_fkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_app_id_fkey FOREIGN KEY (app_id) REFERENCES workspace_apps(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAppAuditSessionsUserID ForeignKeyConstraint = "workspace_app_audit_sessions_user_id_fkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyWorkspaceAppStatsAgentID ForeignKeyConstraint = "workspace_app_stats_agent_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); ForeignKeyWorkspaceAppStatsUserID ForeignKeyConstraint = "workspace_app_stats_user_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); ForeignKeyWorkspaceAppStatsWorkspaceID ForeignKeyConstraint = "workspace_app_stats_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); diff --git a/coderd/database/models.go b/coderd/database/models.go index 9ece5a7902f4a..0ec8d70567a45 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3434,20 +3434,22 @@ type WorkspaceApp struct { OpenIn WorkspaceAppOpenIn `db:"open_in" json:"open_in"` } -// Audit sessions for workspace apps, the data in this table is ephemeral and is used to track the current session of a user in a workspace app. +// Audit sessions for workspace apps, the data in this table is ephemeral and is used to deduplicate audit log entries for workspace apps. While a session is active, the same data will not be logged again. This table does not store historical data. type WorkspaceAppAuditSession struct { - // Unique identifier for the workspace app audit session. - ID uuid.UUID `db:"id" json:"id"` - // The agent that is currently in the workspace app. + // The agent that the workspace app or port forward belongs to. AgentID uuid.UUID `db:"agent_id" json:"agent_id"` - // The app that is currently in the workspace app. This is nullable because ports are not associated with an app. - AppID uuid.NullUUID `db:"app_id" json:"app_id"` - // The user that is currently using the workspace app. This is nullable because the app may be - UserID uuid.NullUUID `db:"user_id" json:"user_id"` + // The app that is currently in the workspace app. This is may be uuid.Nil because ports are not associated with an app. + AppID uuid.UUID `db:"app_id" json:"app_id"` + // The user that is currently using the workspace app. This is may be uuid.Nil if we cannot determine the user. + UserID uuid.UUID `db:"user_id" json:"user_id"` // The IP address of the user that is currently using the workspace app. Ip pqtype.Inet `db:"ip" json:"ip"` + // The user agent of the user that is currently using the workspace app. + UserAgent string `db:"user_agent" json:"user_agent"` // The slug or port of the workspace app that the user is currently using. SlugOrPort string `db:"slug_or_port" json:"slug_or_port"` + // The HTTP status produced by the token authorization. Defaults to 200 if no status is provided. + StatusCode int32 `db:"status_code" json:"status_code"` // The time the user started the session. StartedAt time.Time `db:"started_at" json:"started_at"` // The time the session was last updated. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index dbf13b49c800d..d24548ff07993 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -459,7 +459,6 @@ type sqlcQuerier interface { InsertWorkspaceAgentScripts(ctx context.Context, arg InsertWorkspaceAgentScriptsParams) ([]WorkspaceAgentScript, error) InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) - InsertWorkspaceAppAuditSession(ctx context.Context, arg InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) InsertWorkspaceAppStats(ctx context.Context, arg InsertWorkspaceAppStatsParams) error InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error InsertWorkspaceBuildParameters(ctx context.Context, arg InsertWorkspaceBuildParametersParams) error @@ -545,10 +544,6 @@ type sqlcQuerier interface { UpdateWorkspaceAgentLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentLogOverflowByIDParams) error UpdateWorkspaceAgentMetadata(ctx context.Context, arg UpdateWorkspaceAgentMetadataParams) error UpdateWorkspaceAgentStartupByID(ctx context.Context, arg UpdateWorkspaceAgentStartupByIDParams) error - // - // Return ID to determine if a row was updated or not. This table isn't strict - // about uniqueness, so we need to know if we updated an existing row or not. - UpdateWorkspaceAppAuditSession(ctx context.Context, arg UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg UpdateWorkspaceAutomaticUpdatesParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error @@ -597,6 +592,10 @@ type sqlcQuerier interface { // combination. The result is stored in the template_usage_stats table. UpsertTemplateUsageStats(ctx context.Context) error UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error) + // + // Insert a new workspace app audit session or update an existing one, if + // started_at is updated, it means the session has been restarted. + UpsertWorkspaceAppAuditSession(ctx context.Context, arg UpsertWorkspaceAppAuditSessionParams) (time.Time, error) } var _ sqlcQuerier = (*sqlQuerier)(nil) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 8fd8da17be3fb..fc9aeb1054c1b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -14560,14 +14560,16 @@ func (q *sqlQuerier) InsertWorkspaceAgentStats(ctx context.Context, arg InsertWo return err } -const insertWorkspaceAppAuditSession = `-- name: InsertWorkspaceAppAuditSession :one +const upsertWorkspaceAppAuditSession = `-- name: UpsertWorkspaceAppAuditSession :one INSERT INTO workspace_app_audit_sessions ( agent_id, app_id, user_id, ip, + user_agent, slug_or_port, + status_code, started_at, updated_at ) @@ -14579,94 +14581,56 @@ VALUES $4, $5, $6, - $7 + $7, + $8, + $9 ) +ON CONFLICT + (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code) +DO + UPDATE + SET + started_at = CASE + WHEN workspace_app_audit_sessions.updated_at > NOW() - ($10::bigint || ' ms')::interval + THEN workspace_app_audit_sessions.started_at + ELSE EXCLUDED.started_at + END, + updated_at = EXCLUDED.updated_at RETURNING - id + started_at ` -type InsertWorkspaceAppAuditSessionParams struct { - AgentID uuid.UUID `db:"agent_id" json:"agent_id"` - AppID uuid.NullUUID `db:"app_id" json:"app_id"` - UserID uuid.NullUUID `db:"user_id" json:"user_id"` - Ip pqtype.Inet `db:"ip" json:"ip"` - SlugOrPort string `db:"slug_or_port" json:"slug_or_port"` - StartedAt time.Time `db:"started_at" json:"started_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +type UpsertWorkspaceAppAuditSessionParams struct { + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + AppID uuid.UUID `db:"app_id" json:"app_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + Ip pqtype.Inet `db:"ip" json:"ip"` + UserAgent string `db:"user_agent" json:"user_agent"` + SlugOrPort string `db:"slug_or_port" json:"slug_or_port"` + StatusCode int32 `db:"status_code" json:"status_code"` + StartedAt time.Time `db:"started_at" json:"started_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + StaleIntervalMS int64 `db:"stale_interval_ms" json:"stale_interval_ms"` } -func (q *sqlQuerier) InsertWorkspaceAppAuditSession(ctx context.Context, arg InsertWorkspaceAppAuditSessionParams) (uuid.UUID, error) { - row := q.db.QueryRowContext(ctx, insertWorkspaceAppAuditSession, +// Insert a new workspace app audit session or update an existing one, if +// started_at is updated, it means the session has been restarted. +func (q *sqlQuerier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg UpsertWorkspaceAppAuditSessionParams) (time.Time, error) { + row := q.db.QueryRowContext(ctx, upsertWorkspaceAppAuditSession, arg.AgentID, arg.AppID, arg.UserID, arg.Ip, + arg.UserAgent, arg.SlugOrPort, + arg.StatusCode, arg.StartedAt, arg.UpdatedAt, - ) - var id uuid.UUID - err := row.Scan(&id) - return id, err -} - -const updateWorkspaceAppAuditSession = `-- name: UpdateWorkspaceAppAuditSession :many -UPDATE - workspace_app_audit_sessions -SET - updated_at = $1 -WHERE - agent_id = $2 - AND app_id IS NOT DISTINCT FROM $3 - AND user_id IS NOT DISTINCT FROM $4 - AND ip IS NOT DISTINCT FROM $5 - AND slug_or_port = $6 - AND updated_at > NOW() - ($7::bigint || ' ms')::interval -RETURNING - id -` - -type UpdateWorkspaceAppAuditSessionParams struct { - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - AgentID uuid.UUID `db:"agent_id" json:"agent_id"` - AppID uuid.NullUUID `db:"app_id" json:"app_id"` - UserID uuid.NullUUID `db:"user_id" json:"user_id"` - Ip pqtype.Inet `db:"ip" json:"ip"` - SlugOrPort string `db:"slug_or_port" json:"slug_or_port"` - StaleIntervalMS int64 `db:"stale_interval_ms" json:"stale_interval_ms"` -} - -// Return ID to determine if a row was updated or not. This table isn't strict -// about uniqueness, so we need to know if we updated an existing row or not. -func (q *sqlQuerier) UpdateWorkspaceAppAuditSession(ctx context.Context, arg UpdateWorkspaceAppAuditSessionParams) ([]uuid.UUID, error) { - rows, err := q.db.QueryContext(ctx, updateWorkspaceAppAuditSession, - arg.UpdatedAt, - arg.AgentID, - arg.AppID, - arg.UserID, - arg.Ip, - arg.SlugOrPort, arg.StaleIntervalMS, ) - if err != nil { - return nil, err - } - defer rows.Close() - var items []uuid.UUID - for rows.Next() { - var id uuid.UUID - if err := rows.Scan(&id); err != nil { - return nil, err - } - items = append(items, id) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil + var started_at time.Time + err := row.Scan(&started_at) + return started_at, err } const getWorkspaceAppByAgentIDAndSlug = `-- name: GetWorkspaceAppByAgentIDAndSlug :one diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 10a6b4c77386b..5e12bd9825c8b 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -6,108 +6,109 @@ type UniqueConstraint string // UniqueConstraint enums. const ( - UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_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); - UniqueCryptoKeysPkey UniqueConstraint = "crypto_keys_pkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); - UniqueCustomRolesUniqueKey UniqueConstraint = "custom_roles_unique_key" // ALTER TABLE ONLY custom_roles ADD CONSTRAINT custom_roles_unique_key UNIQUE (name, organization_id); - UniqueDbcryptKeysActiveKeyDigestKey UniqueConstraint = "dbcrypt_keys_active_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); - UniqueDbcryptKeysPkey UniqueConstraint = "dbcrypt_keys_pkey" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_pkey PRIMARY KEY (number); - UniqueDbcryptKeysRevokedKeyDigestKey UniqueConstraint = "dbcrypt_keys_revoked_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_revoked_key_digest_key UNIQUE (revoked_key_digest); - UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); - UniqueFilesPkey UniqueConstraint = "files_pkey" // ALTER TABLE ONLY files ADD CONSTRAINT files_pkey PRIMARY KEY (id); - UniqueGitAuthLinksProviderIDUserIDKey UniqueConstraint = "git_auth_links_provider_id_user_id_key" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); - UniqueGitSSHKeysPkey UniqueConstraint = "gitsshkeys_pkey" // ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id); - UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); - UniqueGroupsNameOrganizationIDKey UniqueConstraint = "groups_name_organization_id_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id); - UniqueGroupsPkey UniqueConstraint = "groups_pkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_pkey PRIMARY KEY (id); - UniqueInboxNotificationsPkey UniqueConstraint = "inbox_notifications_pkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_pkey PRIMARY KEY (id); - UniqueJfrogXrayScansPkey UniqueConstraint = "jfrog_xray_scans_pkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_pkey PRIMARY KEY (agent_id, workspace_id); - UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); - UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); - UniqueNotificationMessagesPkey UniqueConstraint = "notification_messages_pkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_pkey PRIMARY KEY (id); - UniqueNotificationPreferencesPkey UniqueConstraint = "notification_preferences_pkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_pkey PRIMARY KEY (user_id, notification_template_id); - UniqueNotificationReportGeneratorLogsPkey UniqueConstraint = "notification_report_generator_logs_pkey" // ALTER TABLE ONLY notification_report_generator_logs ADD CONSTRAINT notification_report_generator_logs_pkey PRIMARY KEY (notification_template_id); - UniqueNotificationTemplatesNameKey UniqueConstraint = "notification_templates_name_key" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_name_key UNIQUE (name); - UniqueNotificationTemplatesPkey UniqueConstraint = "notification_templates_pkey" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_pkey PRIMARY KEY (id); - UniqueOauth2ProviderAppCodesPkey UniqueConstraint = "oauth2_provider_app_codes_pkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_pkey PRIMARY KEY (id); - UniqueOauth2ProviderAppCodesSecretPrefixKey UniqueConstraint = "oauth2_provider_app_codes_secret_prefix_key" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_secret_prefix_key UNIQUE (secret_prefix); - UniqueOauth2ProviderAppSecretsPkey UniqueConstraint = "oauth2_provider_app_secrets_pkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_pkey PRIMARY KEY (id); - UniqueOauth2ProviderAppSecretsSecretPrefixKey UniqueConstraint = "oauth2_provider_app_secrets_secret_prefix_key" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_secret_prefix_key UNIQUE (secret_prefix); - UniqueOauth2ProviderAppTokensHashPrefixKey UniqueConstraint = "oauth2_provider_app_tokens_hash_prefix_key" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_hash_prefix_key UNIQUE (hash_prefix); - UniqueOauth2ProviderAppTokensPkey UniqueConstraint = "oauth2_provider_app_tokens_pkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_pkey PRIMARY KEY (id); - UniqueOauth2ProviderAppsNameKey UniqueConstraint = "oauth2_provider_apps_name_key" // ALTER TABLE ONLY oauth2_provider_apps ADD CONSTRAINT oauth2_provider_apps_name_key UNIQUE (name); - UniqueOauth2ProviderAppsPkey UniqueConstraint = "oauth2_provider_apps_pkey" // ALTER TABLE ONLY oauth2_provider_apps ADD CONSTRAINT oauth2_provider_apps_pkey PRIMARY KEY (id); - UniqueOrganizationMembersPkey UniqueConstraint = "organization_members_pkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_pkey PRIMARY KEY (organization_id, user_id); - UniqueOrganizationsPkey UniqueConstraint = "organizations_pkey" // ALTER TABLE ONLY organizations ADD CONSTRAINT organizations_pkey PRIMARY KEY (id); - UniqueParameterSchemasJobIDNameKey UniqueConstraint = "parameter_schemas_job_id_name_key" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_name_key UNIQUE (job_id, name); - UniqueParameterSchemasPkey UniqueConstraint = "parameter_schemas_pkey" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_pkey PRIMARY KEY (id); - UniqueParameterValuesPkey UniqueConstraint = "parameter_values_pkey" // ALTER TABLE ONLY parameter_values ADD CONSTRAINT parameter_values_pkey PRIMARY KEY (id); - UniqueParameterValuesScopeIDNameKey UniqueConstraint = "parameter_values_scope_id_name_key" // ALTER TABLE ONLY parameter_values ADD CONSTRAINT parameter_values_scope_id_name_key UNIQUE (scope_id, name); - UniqueProvisionerDaemonsPkey UniqueConstraint = "provisioner_daemons_pkey" // ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_pkey PRIMARY KEY (id); - UniqueProvisionerJobLogsPkey UniqueConstraint = "provisioner_job_logs_pkey" // ALTER TABLE ONLY provisioner_job_logs ADD CONSTRAINT provisioner_job_logs_pkey PRIMARY KEY (id); - UniqueProvisionerJobsPkey UniqueConstraint = "provisioner_jobs_pkey" // ALTER TABLE ONLY provisioner_jobs ADD CONSTRAINT provisioner_jobs_pkey PRIMARY KEY (id); - UniqueProvisionerKeysPkey UniqueConstraint = "provisioner_keys_pkey" // ALTER TABLE ONLY provisioner_keys ADD CONSTRAINT provisioner_keys_pkey PRIMARY KEY (id); - UniqueSiteConfigsKeyKey UniqueConstraint = "site_configs_key_key" // ALTER TABLE ONLY site_configs ADD CONSTRAINT site_configs_key_key UNIQUE (key); - UniqueTailnetAgentsPkey UniqueConstraint = "tailnet_agents_pkey" // ALTER TABLE ONLY tailnet_agents ADD CONSTRAINT tailnet_agents_pkey PRIMARY KEY (id, coordinator_id); - UniqueTailnetClientSubscriptionsPkey UniqueConstraint = "tailnet_client_subscriptions_pkey" // ALTER TABLE ONLY tailnet_client_subscriptions ADD CONSTRAINT tailnet_client_subscriptions_pkey PRIMARY KEY (client_id, coordinator_id, agent_id); - UniqueTailnetClientsPkey UniqueConstraint = "tailnet_clients_pkey" // ALTER TABLE ONLY tailnet_clients ADD CONSTRAINT tailnet_clients_pkey PRIMARY KEY (id, coordinator_id); - UniqueTailnetCoordinatorsPkey UniqueConstraint = "tailnet_coordinators_pkey" // ALTER TABLE ONLY tailnet_coordinators ADD CONSTRAINT tailnet_coordinators_pkey PRIMARY KEY (id); - UniqueTailnetPeersPkey UniqueConstraint = "tailnet_peers_pkey" // ALTER TABLE ONLY tailnet_peers ADD CONSTRAINT tailnet_peers_pkey PRIMARY KEY (id, coordinator_id); - UniqueTailnetTunnelsPkey UniqueConstraint = "tailnet_tunnels_pkey" // ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_pkey PRIMARY KEY (coordinator_id, src_id, dst_id); - UniqueTelemetryItemsPkey UniqueConstraint = "telemetry_items_pkey" // ALTER TABLE ONLY telemetry_items ADD CONSTRAINT telemetry_items_pkey PRIMARY KEY (key); - UniqueTemplateUsageStatsPkey UniqueConstraint = "template_usage_stats_pkey" // ALTER TABLE ONLY template_usage_stats ADD CONSTRAINT template_usage_stats_pkey PRIMARY KEY (start_time, template_id, user_id); - UniqueTemplateVersionParametersTemplateVersionIDNameKey UniqueConstraint = "template_version_parameters_template_version_id_name_key" // ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_name_key UNIQUE (template_version_id, name); - UniqueTemplateVersionPresetParametersPkey UniqueConstraint = "template_version_preset_parameters_pkey" // ALTER TABLE ONLY template_version_preset_parameters ADD CONSTRAINT template_version_preset_parameters_pkey PRIMARY KEY (id); - UniqueTemplateVersionPresetsPkey UniqueConstraint = "template_version_presets_pkey" // ALTER TABLE ONLY template_version_presets ADD CONSTRAINT template_version_presets_pkey PRIMARY KEY (id); - UniqueTemplateVersionVariablesTemplateVersionIDNameKey UniqueConstraint = "template_version_variables_template_version_id_name_key" // ALTER TABLE ONLY template_version_variables ADD CONSTRAINT template_version_variables_template_version_id_name_key UNIQUE (template_version_id, name); - UniqueTemplateVersionWorkspaceTagsTemplateVersionIDKeyKey UniqueConstraint = "template_version_workspace_tags_template_version_id_key_key" // ALTER TABLE ONLY template_version_workspace_tags ADD CONSTRAINT template_version_workspace_tags_template_version_id_key_key UNIQUE (template_version_id, key); - UniqueTemplateVersionsPkey UniqueConstraint = "template_versions_pkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_pkey PRIMARY KEY (id); - UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); - UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); - UniqueUserConfigsPkey UniqueConstraint = "user_configs_pkey" // ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_pkey PRIMARY KEY (user_id, key); - UniqueUserDeletedPkey UniqueConstraint = "user_deleted_pkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_pkey PRIMARY KEY (id); - UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); - UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); - UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); - UniqueWorkspaceAgentLogSourcesPkey UniqueConstraint = "workspace_agent_log_sources_pkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_pkey PRIMARY KEY (workspace_agent_id, id); - UniqueWorkspaceAgentMemoryResourceMonitorsPkey UniqueConstraint = "workspace_agent_memory_resource_monitors_pkey" // ALTER TABLE ONLY workspace_agent_memory_resource_monitors ADD CONSTRAINT workspace_agent_memory_resource_monitors_pkey PRIMARY KEY (agent_id); - UniqueWorkspaceAgentMetadataPkey UniqueConstraint = "workspace_agent_metadata_pkey" // ALTER TABLE ONLY workspace_agent_metadata ADD CONSTRAINT workspace_agent_metadata_pkey PRIMARY KEY (workspace_agent_id, key); - UniqueWorkspaceAgentPortSharePkey UniqueConstraint = "workspace_agent_port_share_pkey" // ALTER TABLE ONLY workspace_agent_port_share ADD CONSTRAINT workspace_agent_port_share_pkey PRIMARY KEY (workspace_id, agent_name, port); - UniqueWorkspaceAgentScriptTimingsScriptIDStartedAtKey UniqueConstraint = "workspace_agent_script_timings_script_id_started_at_key" // ALTER TABLE ONLY workspace_agent_script_timings ADD CONSTRAINT workspace_agent_script_timings_script_id_started_at_key UNIQUE (script_id, started_at); - UniqueWorkspaceAgentScriptsIDKey UniqueConstraint = "workspace_agent_scripts_id_key" // ALTER TABLE ONLY workspace_agent_scripts ADD CONSTRAINT workspace_agent_scripts_id_key UNIQUE (id); - UniqueWorkspaceAgentStartupLogsPkey UniqueConstraint = "workspace_agent_startup_logs_pkey" // ALTER TABLE ONLY workspace_agent_logs ADD CONSTRAINT workspace_agent_startup_logs_pkey PRIMARY KEY (id); - UniqueWorkspaceAgentVolumeResourceMonitorsPkey UniqueConstraint = "workspace_agent_volume_resource_monitors_pkey" // ALTER TABLE ONLY workspace_agent_volume_resource_monitors ADD CONSTRAINT workspace_agent_volume_resource_monitors_pkey PRIMARY KEY (agent_id, path); - UniqueWorkspaceAgentsPkey UniqueConstraint = "workspace_agents_pkey" // ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_pkey PRIMARY KEY (id); - UniqueWorkspaceAppAuditSessionsPkey UniqueConstraint = "workspace_app_audit_sessions_pkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_pkey PRIMARY KEY (id); - UniqueWorkspaceAppStatsPkey UniqueConstraint = "workspace_app_stats_pkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_pkey PRIMARY KEY (id); - UniqueWorkspaceAppStatsUserIDAgentIDSessionIDKey UniqueConstraint = "workspace_app_stats_user_id_agent_id_session_id_key" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_agent_id_session_id_key UNIQUE (user_id, agent_id, session_id); - UniqueWorkspaceAppsAgentIDSlugIndex UniqueConstraint = "workspace_apps_agent_id_slug_idx" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug); - UniqueWorkspaceAppsPkey UniqueConstraint = "workspace_apps_pkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_pkey PRIMARY KEY (id); - UniqueWorkspaceBuildParametersWorkspaceBuildIDNameKey UniqueConstraint = "workspace_build_parameters_workspace_build_id_name_key" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_name_key UNIQUE (workspace_build_id, name); - UniqueWorkspaceBuildsJobIDKey UniqueConstraint = "workspace_builds_job_id_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_key UNIQUE (job_id); - UniqueWorkspaceBuildsPkey UniqueConstraint = "workspace_builds_pkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_pkey PRIMARY KEY (id); - UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey UniqueConstraint = "workspace_builds_workspace_id_build_number_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number); - UniqueWorkspaceProxiesPkey UniqueConstraint = "workspace_proxies_pkey" // ALTER TABLE ONLY workspace_proxies ADD CONSTRAINT workspace_proxies_pkey PRIMARY KEY (id); - UniqueWorkspaceProxiesRegionIDUnique UniqueConstraint = "workspace_proxies_region_id_unique" // ALTER TABLE ONLY workspace_proxies ADD CONSTRAINT workspace_proxies_region_id_unique UNIQUE (region_id); - UniqueWorkspaceResourceMetadataName UniqueConstraint = "workspace_resource_metadata_name" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_name UNIQUE (workspace_resource_id, key); - UniqueWorkspaceResourceMetadataPkey UniqueConstraint = "workspace_resource_metadata_pkey" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_pkey PRIMARY KEY (id); - UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id); - UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); - UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); - UniqueIndexCustomRolesNameLower UniqueConstraint = "idx_custom_roles_name_lower" // CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); - UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)) WHERE (deleted = false); - UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text))); - UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false); - UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false); - UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash); - UniqueOrganizationsSingleDefaultOrg UniqueConstraint = "organizations_single_default_org" // CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true); - UniqueProvisionerKeysOrganizationIDNameIndex UniqueConstraint = "provisioner_keys_organization_id_name_idx" // CREATE UNIQUE INDEX provisioner_keys_organization_id_name_idx ON provisioner_keys USING btree (organization_id, lower((name)::text)); - UniqueTemplateUsageStatsStartTimeTemplateIDUserIDIndex UniqueConstraint = "template_usage_stats_start_time_template_id_user_id_idx" // CREATE UNIQUE INDEX template_usage_stats_start_time_template_id_user_id_idx ON template_usage_stats USING btree (start_time, template_id, user_id); - UniqueTemplatesOrganizationIDNameIndex UniqueConstraint = "templates_organization_id_name_idx" // CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, lower((name)::text)) WHERE (deleted = false); - UniqueUserLinksLinkedIDLoginTypeIndex UniqueConstraint = "user_links_linked_id_login_type_idx" // CREATE UNIQUE INDEX user_links_linked_id_login_type_idx ON user_links USING btree (linked_id, login_type) WHERE (linked_id <> ''::text); - UniqueUsersEmailLowerIndex UniqueConstraint = "users_email_lower_idx" // CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE (deleted = false); - UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false); - UniqueWorkspaceProxiesLowerNameIndex UniqueConstraint = "workspace_proxies_lower_name_idx" // CREATE UNIQUE INDEX workspace_proxies_lower_name_idx ON workspace_proxies USING btree (lower(name)) WHERE (deleted = false); - UniqueWorkspacesOwnerIDLowerIndex UniqueConstraint = "workspaces_owner_id_lower_idx" // CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false); + UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_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); + UniqueCryptoKeysPkey UniqueConstraint = "crypto_keys_pkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); + UniqueCustomRolesUniqueKey UniqueConstraint = "custom_roles_unique_key" // ALTER TABLE ONLY custom_roles ADD CONSTRAINT custom_roles_unique_key UNIQUE (name, organization_id); + UniqueDbcryptKeysActiveKeyDigestKey UniqueConstraint = "dbcrypt_keys_active_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); + UniqueDbcryptKeysPkey UniqueConstraint = "dbcrypt_keys_pkey" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_pkey PRIMARY KEY (number); + UniqueDbcryptKeysRevokedKeyDigestKey UniqueConstraint = "dbcrypt_keys_revoked_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_revoked_key_digest_key UNIQUE (revoked_key_digest); + UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); + UniqueFilesPkey UniqueConstraint = "files_pkey" // ALTER TABLE ONLY files ADD CONSTRAINT files_pkey PRIMARY KEY (id); + UniqueGitAuthLinksProviderIDUserIDKey UniqueConstraint = "git_auth_links_provider_id_user_id_key" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); + UniqueGitSSHKeysPkey UniqueConstraint = "gitsshkeys_pkey" // ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id); + UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); + UniqueGroupsNameOrganizationIDKey UniqueConstraint = "groups_name_organization_id_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id); + UniqueGroupsPkey UniqueConstraint = "groups_pkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_pkey PRIMARY KEY (id); + UniqueInboxNotificationsPkey UniqueConstraint = "inbox_notifications_pkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_pkey PRIMARY KEY (id); + UniqueJfrogXrayScansPkey UniqueConstraint = "jfrog_xray_scans_pkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_pkey PRIMARY KEY (agent_id, workspace_id); + UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); + UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); + UniqueNotificationMessagesPkey UniqueConstraint = "notification_messages_pkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_pkey PRIMARY KEY (id); + UniqueNotificationPreferencesPkey UniqueConstraint = "notification_preferences_pkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_pkey PRIMARY KEY (user_id, notification_template_id); + UniqueNotificationReportGeneratorLogsPkey UniqueConstraint = "notification_report_generator_logs_pkey" // ALTER TABLE ONLY notification_report_generator_logs ADD CONSTRAINT notification_report_generator_logs_pkey PRIMARY KEY (notification_template_id); + UniqueNotificationTemplatesNameKey UniqueConstraint = "notification_templates_name_key" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_name_key UNIQUE (name); + UniqueNotificationTemplatesPkey UniqueConstraint = "notification_templates_pkey" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_pkey PRIMARY KEY (id); + UniqueOauth2ProviderAppCodesPkey UniqueConstraint = "oauth2_provider_app_codes_pkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_pkey PRIMARY KEY (id); + UniqueOauth2ProviderAppCodesSecretPrefixKey UniqueConstraint = "oauth2_provider_app_codes_secret_prefix_key" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_secret_prefix_key UNIQUE (secret_prefix); + UniqueOauth2ProviderAppSecretsPkey UniqueConstraint = "oauth2_provider_app_secrets_pkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_pkey PRIMARY KEY (id); + UniqueOauth2ProviderAppSecretsSecretPrefixKey UniqueConstraint = "oauth2_provider_app_secrets_secret_prefix_key" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_secret_prefix_key UNIQUE (secret_prefix); + UniqueOauth2ProviderAppTokensHashPrefixKey UniqueConstraint = "oauth2_provider_app_tokens_hash_prefix_key" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_hash_prefix_key UNIQUE (hash_prefix); + UniqueOauth2ProviderAppTokensPkey UniqueConstraint = "oauth2_provider_app_tokens_pkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_pkey PRIMARY KEY (id); + UniqueOauth2ProviderAppsNameKey UniqueConstraint = "oauth2_provider_apps_name_key" // ALTER TABLE ONLY oauth2_provider_apps ADD CONSTRAINT oauth2_provider_apps_name_key UNIQUE (name); + UniqueOauth2ProviderAppsPkey UniqueConstraint = "oauth2_provider_apps_pkey" // ALTER TABLE ONLY oauth2_provider_apps ADD CONSTRAINT oauth2_provider_apps_pkey PRIMARY KEY (id); + UniqueOrganizationMembersPkey UniqueConstraint = "organization_members_pkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_pkey PRIMARY KEY (organization_id, user_id); + UniqueOrganizationsPkey UniqueConstraint = "organizations_pkey" // ALTER TABLE ONLY organizations ADD CONSTRAINT organizations_pkey PRIMARY KEY (id); + UniqueParameterSchemasJobIDNameKey UniqueConstraint = "parameter_schemas_job_id_name_key" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_name_key UNIQUE (job_id, name); + UniqueParameterSchemasPkey UniqueConstraint = "parameter_schemas_pkey" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_pkey PRIMARY KEY (id); + UniqueParameterValuesPkey UniqueConstraint = "parameter_values_pkey" // ALTER TABLE ONLY parameter_values ADD CONSTRAINT parameter_values_pkey PRIMARY KEY (id); + UniqueParameterValuesScopeIDNameKey UniqueConstraint = "parameter_values_scope_id_name_key" // ALTER TABLE ONLY parameter_values ADD CONSTRAINT parameter_values_scope_id_name_key UNIQUE (scope_id, name); + UniqueProvisionerDaemonsPkey UniqueConstraint = "provisioner_daemons_pkey" // ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_pkey PRIMARY KEY (id); + UniqueProvisionerJobLogsPkey UniqueConstraint = "provisioner_job_logs_pkey" // ALTER TABLE ONLY provisioner_job_logs ADD CONSTRAINT provisioner_job_logs_pkey PRIMARY KEY (id); + UniqueProvisionerJobsPkey UniqueConstraint = "provisioner_jobs_pkey" // ALTER TABLE ONLY provisioner_jobs ADD CONSTRAINT provisioner_jobs_pkey PRIMARY KEY (id); + UniqueProvisionerKeysPkey UniqueConstraint = "provisioner_keys_pkey" // ALTER TABLE ONLY provisioner_keys ADD CONSTRAINT provisioner_keys_pkey PRIMARY KEY (id); + UniqueSiteConfigsKeyKey UniqueConstraint = "site_configs_key_key" // ALTER TABLE ONLY site_configs ADD CONSTRAINT site_configs_key_key UNIQUE (key); + UniqueTailnetAgentsPkey UniqueConstraint = "tailnet_agents_pkey" // ALTER TABLE ONLY tailnet_agents ADD CONSTRAINT tailnet_agents_pkey PRIMARY KEY (id, coordinator_id); + UniqueTailnetClientSubscriptionsPkey UniqueConstraint = "tailnet_client_subscriptions_pkey" // ALTER TABLE ONLY tailnet_client_subscriptions ADD CONSTRAINT tailnet_client_subscriptions_pkey PRIMARY KEY (client_id, coordinator_id, agent_id); + UniqueTailnetClientsPkey UniqueConstraint = "tailnet_clients_pkey" // ALTER TABLE ONLY tailnet_clients ADD CONSTRAINT tailnet_clients_pkey PRIMARY KEY (id, coordinator_id); + UniqueTailnetCoordinatorsPkey UniqueConstraint = "tailnet_coordinators_pkey" // ALTER TABLE ONLY tailnet_coordinators ADD CONSTRAINT tailnet_coordinators_pkey PRIMARY KEY (id); + UniqueTailnetPeersPkey UniqueConstraint = "tailnet_peers_pkey" // ALTER TABLE ONLY tailnet_peers ADD CONSTRAINT tailnet_peers_pkey PRIMARY KEY (id, coordinator_id); + UniqueTailnetTunnelsPkey UniqueConstraint = "tailnet_tunnels_pkey" // ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_pkey PRIMARY KEY (coordinator_id, src_id, dst_id); + UniqueTelemetryItemsPkey UniqueConstraint = "telemetry_items_pkey" // ALTER TABLE ONLY telemetry_items ADD CONSTRAINT telemetry_items_pkey PRIMARY KEY (key); + UniqueTemplateUsageStatsPkey UniqueConstraint = "template_usage_stats_pkey" // ALTER TABLE ONLY template_usage_stats ADD CONSTRAINT template_usage_stats_pkey PRIMARY KEY (start_time, template_id, user_id); + UniqueTemplateVersionParametersTemplateVersionIDNameKey UniqueConstraint = "template_version_parameters_template_version_id_name_key" // ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_name_key UNIQUE (template_version_id, name); + UniqueTemplateVersionPresetParametersPkey UniqueConstraint = "template_version_preset_parameters_pkey" // ALTER TABLE ONLY template_version_preset_parameters ADD CONSTRAINT template_version_preset_parameters_pkey PRIMARY KEY (id); + UniqueTemplateVersionPresetsPkey UniqueConstraint = "template_version_presets_pkey" // ALTER TABLE ONLY template_version_presets ADD CONSTRAINT template_version_presets_pkey PRIMARY KEY (id); + UniqueTemplateVersionVariablesTemplateVersionIDNameKey UniqueConstraint = "template_version_variables_template_version_id_name_key" // ALTER TABLE ONLY template_version_variables ADD CONSTRAINT template_version_variables_template_version_id_name_key UNIQUE (template_version_id, name); + UniqueTemplateVersionWorkspaceTagsTemplateVersionIDKeyKey UniqueConstraint = "template_version_workspace_tags_template_version_id_key_key" // ALTER TABLE ONLY template_version_workspace_tags ADD CONSTRAINT template_version_workspace_tags_template_version_id_key_key UNIQUE (template_version_id, key); + UniqueTemplateVersionsPkey UniqueConstraint = "template_versions_pkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_pkey PRIMARY KEY (id); + UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); + UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); + UniqueUserConfigsPkey UniqueConstraint = "user_configs_pkey" // ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_pkey PRIMARY KEY (user_id, key); + UniqueUserDeletedPkey UniqueConstraint = "user_deleted_pkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_pkey PRIMARY KEY (id); + UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); + UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); + UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); + UniqueWorkspaceAgentLogSourcesPkey UniqueConstraint = "workspace_agent_log_sources_pkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_pkey PRIMARY KEY (workspace_agent_id, id); + UniqueWorkspaceAgentMemoryResourceMonitorsPkey UniqueConstraint = "workspace_agent_memory_resource_monitors_pkey" // ALTER TABLE ONLY workspace_agent_memory_resource_monitors ADD CONSTRAINT workspace_agent_memory_resource_monitors_pkey PRIMARY KEY (agent_id); + UniqueWorkspaceAgentMetadataPkey UniqueConstraint = "workspace_agent_metadata_pkey" // ALTER TABLE ONLY workspace_agent_metadata ADD CONSTRAINT workspace_agent_metadata_pkey PRIMARY KEY (workspace_agent_id, key); + UniqueWorkspaceAgentPortSharePkey UniqueConstraint = "workspace_agent_port_share_pkey" // ALTER TABLE ONLY workspace_agent_port_share ADD CONSTRAINT workspace_agent_port_share_pkey PRIMARY KEY (workspace_id, agent_name, port); + UniqueWorkspaceAgentScriptTimingsScriptIDStartedAtKey UniqueConstraint = "workspace_agent_script_timings_script_id_started_at_key" // ALTER TABLE ONLY workspace_agent_script_timings ADD CONSTRAINT workspace_agent_script_timings_script_id_started_at_key UNIQUE (script_id, started_at); + UniqueWorkspaceAgentScriptsIDKey UniqueConstraint = "workspace_agent_scripts_id_key" // ALTER TABLE ONLY workspace_agent_scripts ADD CONSTRAINT workspace_agent_scripts_id_key UNIQUE (id); + UniqueWorkspaceAgentStartupLogsPkey UniqueConstraint = "workspace_agent_startup_logs_pkey" // ALTER TABLE ONLY workspace_agent_logs ADD CONSTRAINT workspace_agent_startup_logs_pkey PRIMARY KEY (id); + UniqueWorkspaceAgentVolumeResourceMonitorsPkey UniqueConstraint = "workspace_agent_volume_resource_monitors_pkey" // ALTER TABLE ONLY workspace_agent_volume_resource_monitors ADD CONSTRAINT workspace_agent_volume_resource_monitors_pkey PRIMARY KEY (agent_id, path); + UniqueWorkspaceAgentsPkey UniqueConstraint = "workspace_agents_pkey" // ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_pkey PRIMARY KEY (id); + UniqueWorkspaceAppAuditSessionsAgentIDAppIDUserIDIpUseKey UniqueConstraint = "workspace_app_audit_sessions_agent_id_app_id_user_id_ip_use_key" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_agent_id_app_id_user_id_ip_use_key UNIQUE (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code); + UniqueWorkspaceAppStatsPkey UniqueConstraint = "workspace_app_stats_pkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_pkey PRIMARY KEY (id); + UniqueWorkspaceAppStatsUserIDAgentIDSessionIDKey UniqueConstraint = "workspace_app_stats_user_id_agent_id_session_id_key" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_agent_id_session_id_key UNIQUE (user_id, agent_id, session_id); + UniqueWorkspaceAppsAgentIDSlugIndex UniqueConstraint = "workspace_apps_agent_id_slug_idx" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug); + UniqueWorkspaceAppsPkey UniqueConstraint = "workspace_apps_pkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_pkey PRIMARY KEY (id); + UniqueWorkspaceBuildParametersWorkspaceBuildIDNameKey UniqueConstraint = "workspace_build_parameters_workspace_build_id_name_key" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_name_key UNIQUE (workspace_build_id, name); + UniqueWorkspaceBuildsJobIDKey UniqueConstraint = "workspace_builds_job_id_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_key UNIQUE (job_id); + UniqueWorkspaceBuildsPkey UniqueConstraint = "workspace_builds_pkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_pkey PRIMARY KEY (id); + UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey UniqueConstraint = "workspace_builds_workspace_id_build_number_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number); + UniqueWorkspaceProxiesPkey UniqueConstraint = "workspace_proxies_pkey" // ALTER TABLE ONLY workspace_proxies ADD CONSTRAINT workspace_proxies_pkey PRIMARY KEY (id); + UniqueWorkspaceProxiesRegionIDUnique UniqueConstraint = "workspace_proxies_region_id_unique" // ALTER TABLE ONLY workspace_proxies ADD CONSTRAINT workspace_proxies_region_id_unique UNIQUE (region_id); + UniqueWorkspaceResourceMetadataName UniqueConstraint = "workspace_resource_metadata_name" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_name UNIQUE (workspace_resource_id, key); + UniqueWorkspaceResourceMetadataPkey UniqueConstraint = "workspace_resource_metadata_pkey" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_pkey PRIMARY KEY (id); + UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id); + UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); + UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); + UniqueIndexCustomRolesNameLower UniqueConstraint = "idx_custom_roles_name_lower" // CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); + UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)) WHERE (deleted = false); + UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text))); + UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false); + UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false); + UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash); + UniqueOrganizationsSingleDefaultOrg UniqueConstraint = "organizations_single_default_org" // CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true); + UniqueProvisionerKeysOrganizationIDNameIndex UniqueConstraint = "provisioner_keys_organization_id_name_idx" // CREATE UNIQUE INDEX provisioner_keys_organization_id_name_idx ON provisioner_keys USING btree (organization_id, lower((name)::text)); + UniqueTemplateUsageStatsStartTimeTemplateIDUserIDIndex UniqueConstraint = "template_usage_stats_start_time_template_id_user_id_idx" // CREATE UNIQUE INDEX template_usage_stats_start_time_template_id_user_id_idx ON template_usage_stats USING btree (start_time, template_id, user_id); + UniqueTemplatesOrganizationIDNameIndex UniqueConstraint = "templates_organization_id_name_idx" // CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, lower((name)::text)) WHERE (deleted = false); + UniqueUserLinksLinkedIDLoginTypeIndex UniqueConstraint = "user_links_linked_id_login_type_idx" // CREATE UNIQUE INDEX user_links_linked_id_login_type_idx ON user_links USING btree (linked_id, login_type) WHERE (linked_id <> ''::text); + UniqueUsersEmailLowerIndex UniqueConstraint = "users_email_lower_idx" // CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE (deleted = false); + UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false); + UniqueWorkspaceAppAuditSessionsUniqueIndex UniqueConstraint = "workspace_app_audit_sessions_unique_index" // CREATE UNIQUE INDEX workspace_app_audit_sessions_unique_index ON workspace_app_audit_sessions USING btree (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code); + UniqueWorkspaceProxiesLowerNameIndex UniqueConstraint = "workspace_proxies_lower_name_idx" // CREATE UNIQUE INDEX workspace_proxies_lower_name_idx ON workspace_proxies USING btree (lower(name)) WHERE (deleted = false); + UniqueWorkspacesOwnerIDLowerIndex UniqueConstraint = "workspaces_owner_id_lower_idx" // CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false); ) From 0f162b1c8a1dde0a1d02177920f65420c515dc7a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 17 Mar 2025 12:44:33 +0000 Subject: [PATCH 33/39] simplify auditInitRequest in workspaceapps --- coderd/workspaceapps/db.go | 110 +++++++++++++------------------------ 1 file changed, 39 insertions(+), 71 deletions(-) diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 8e873d995a7f4..9c1959f792095 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -401,27 +401,22 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW } committed = true - if sw.Status == http.StatusSeeOther { - // Redirects aren't interesting as we will capture the audit - // log after the redirect. - // - // There's a case where we call httpmw.RedirectToLogin for - // path-based apps the user doesn't have access to, in which - // case the dashboard login redirect is used and we end up - // not hitting the workspaceapps API again due to dashboard - // showing 404. (Bug?) - return - } - if aReq.dbReq == nil { // App doesn't exist, there's information in the Request // struct but we need UUIDs for audit logging. return } - userID := uuid.NullUUID{} + userID := uuid.Nil if aReq.apiKey != nil { - userID = uuid.NullUUID{Valid: true, UUID: aReq.apiKey.UserID} + userID = aReq.apiKey.UserID + } + userAgent := r.UserAgent() + + // Approximation of the status code. + statusCode := sw.Status + if statusCode == 0 { + statusCode = http.StatusOK } type additionalFields struct { @@ -443,50 +438,34 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW appInfo.SlugOrPort = aReq.dbReq.AppSlugOrPort } + // If we end up logging, ensure relevant fields are set. logger := p.Logger.With( slog.F("workspace_id", aReq.dbReq.Workspace.ID), slog.F("agent_id", aReq.dbReq.Agent.ID), slog.F("app_id", aReq.dbReq.App.ID), - slog.F("user_id", userID.UUID), + slog.F("user_id", userID), + slog.F("user_agent", userAgent), slog.F("app_slug_or_port", appInfo.SlugOrPort), + slog.F("status_code", statusCode), ) - appInfoBytes, err := json.Marshal(appInfo) - if err != nil { - logger.Error(ctx, "marshal additional fields failed", slog.Error(err)) - } - - var ( - updatedIDs []uuid.UUID - sessionID = uuid.Nil - ) - err = p.Database.InTx(func(tx database.Store) error { + var startedAt time.Time + err := p.Database.InTx(func(tx database.Store) (err error) { // nolint:gocritic // System context is needed to write audit sessions. dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx) - updatedIDs, err = tx.UpdateWorkspaceAppAuditSession(dangerousSystemCtx, database.UpdateWorkspaceAppAuditSessionParams{ - AgentID: aReq.dbReq.Agent.ID, - AppID: uuid.NullUUID{Valid: aReq.dbReq.App.ID != uuid.Nil, UUID: aReq.dbReq.App.ID}, - UserID: userID, - Ip: aReq.ip, - SlugOrPort: appInfo.SlugOrPort, - UpdatedAt: aReq.time, + startedAt, err = tx.UpsertWorkspaceAppAuditSession(dangerousSystemCtx, database.UpsertWorkspaceAppAuditSessionParams{ + // Config. StaleIntervalMS: p.WorkspaceAppAuditSessionTimeout.Milliseconds(), - }) - if err != nil { - return xerrors.Errorf("update workspace app audit session: %w", err) - } - if len(updatedIDs) > 0 { - // Session is valid and got updated, no need to create a new audit log. - return nil - } - sessionID, err = tx.InsertWorkspaceAppAuditSession(dangerousSystemCtx, database.InsertWorkspaceAppAuditSessionParams{ + // Data. AgentID: aReq.dbReq.Agent.ID, - AppID: uuid.NullUUID{Valid: aReq.dbReq.App.ID != uuid.Nil, UUID: aReq.dbReq.App.ID}, - UserID: userID, + AppID: aReq.dbReq.App.ID, // Can be unset, in which case uuid.Nil is fine. + UserID: userID, // Can be unset, in which case uuid.Nil is fine. Ip: aReq.ip, + UserAgent: userAgent, SlugOrPort: appInfo.SlugOrPort, + StatusCode: int32(statusCode), StartedAt: aReq.time, UpdatedAt: aReq.time, }) @@ -504,34 +483,23 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW return } - if sessionID == uuid.Nil { - if sw.Status < 400 { - // Session was updated and no error occurred, no need to - // create a new audit log. - return - } - if len(updatedIDs) > 0 { - // Session was updated but an error occurred, we need to - // create a new audit log. - sessionID = updatedIDs[0] - } else { - // This shouldn't happen, but fall-back to request so it - // can be correlated to _something_. - sessionID = httpmw.RequestID(r) - } + if !startedAt.Equal(aReq.time) { + // If the unique session wasn't renewed, we don't want to log a new + // audit event for it. + return } - // Mimic the behavior of a HTTP status writer - // by defaulting to 200 if the status is 0. - status := sw.Status - if status == 0 { - status = http.StatusOK + // Marshal additional fields only if we're writing an audit log entry. + appInfoBytes, err := json.Marshal(appInfo) + if err != nil { + logger.Error(ctx, "marshal additional fields failed", slog.Error(err)) } // We use the background audit function instead of init request // here because we don't know the resource type ahead of time. // This also allows us to log unauthenticated access. auditor := *p.Auditor.Load() + requestID := httpmw.RequestID(r) switch { case aReq.dbReq.App.ID != uuid.Nil: audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceApp]{ @@ -540,12 +508,12 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW Action: database.AuditActionOpen, OrganizationID: aReq.dbReq.Workspace.OrganizationID, - UserID: userID.UUID, - RequestID: sessionID, + UserID: userID, + RequestID: requestID, Time: aReq.time, - Status: status, + Status: statusCode, IP: aReq.ip.IPNet.IP.String(), - UserAgent: r.UserAgent(), + UserAgent: userAgent, New: aReq.dbReq.App, AdditionalFields: appInfoBytes, }) @@ -557,12 +525,12 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW Action: database.AuditActionOpen, OrganizationID: aReq.dbReq.Workspace.OrganizationID, - UserID: userID.UUID, - RequestID: sessionID, + UserID: userID, + RequestID: requestID, Time: aReq.time, - Status: status, + Status: statusCode, IP: aReq.ip.IPNet.IP.String(), - UserAgent: r.UserAgent(), + UserAgent: userAgent, New: aReq.dbReq.Agent, AdditionalFields: appInfoBytes, }) From 22ea58c2c8a88b3397d17b832a1e2aa8a622c7e1 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 17 Mar 2025 12:44:43 +0000 Subject: [PATCH 34/39] update tests --- coderd/workspaceapps/db_test.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/coderd/workspaceapps/db_test.go b/coderd/workspaceapps/db_test.go index d7db00653c1ff..597d1daadfa54 100644 --- a/coderd/workspaceapps/db_test.go +++ b/coderd/workspaceapps/db_test.go @@ -454,7 +454,8 @@ func Test_ResolveRequest(t *testing.T) { require.NotZero(t, rw.Code) require.NotEqual(t, http.StatusOK, rw.Code) - require.Len(t, auditor.AuditLogs(), 0, "no audit logs for unauthenticated requests") + assertAuditApp(t, rw, r, auditor, appsBySlug[app], uuid.Nil, nil) + require.Len(t, auditor.AuditLogs(), 1, "audit log for unauthenticated requests") } else { if !assert.True(t, ok) { dump, err := httputil.DumpResponse(w, true) @@ -971,7 +972,10 @@ func Test_ResolveRequest(t *testing.T) { w := rw.Result() defer w.Body.Close() require.Equal(t, http.StatusSeeOther, w.StatusCode) - require.Len(t, auditor.AuditLogs(), 0, "no audit logs for redirect requests") + // Note that we don't capture the owner UUID here because the apiKey + // check/authorization exits early. + assertAuditApp(t, rw, r, auditor, appsBySlug[appNameOwner], uuid.Nil, nil) + require.Len(t, auditor.AuditLogs(), 1, "autit log entry for redirect") loc, err := w.Location() require.NoError(t, err) @@ -1254,7 +1258,9 @@ func workspaceappsResolveRequest(t testing.TB, auditor audit.Auditor, w http.Res } tracing.StatusWriterMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token, ok = workspaceapps.ResolveRequest(w, r, opts) + httpmw.AttachRequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok = workspaceapps.ResolveRequest(w, r, opts) + })).ServeHTTP(w, r) })).ServeHTTP(w, r) return token, ok From 119cf033224410a3ba74c9629ebfedb362b8c64b Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 17 Mar 2025 12:47:32 +0000 Subject: [PATCH 35/39] fix fixtures --- .../fixtures/000301_add_workspace_app_audit_sessions.up.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql b/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql index 46ded469a7463..bd335ff1cdea3 100644 --- a/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql +++ b/coderd/database/migrations/testdata/fixtures/000301_add_workspace_app_audit_sessions.up.sql @@ -2,5 +2,5 @@ INSERT INTO workspace_app_audit_sessions (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code, started_at, updated_at) VALUES ('45e89705-e09d-4850-bcec-f9a937f5d78d', '36b65d0c-042b-4653-863a-655ee739861c', '30095c71-380b-457a-8995-97b8ee6e5307', '127.0.0.1', 'curl', '', 200, '2025-03-04 15:08:38.579772+02', '2025-03-04 15:06:48.755158+02'), - ('45e89705-e09d-4850-bcec-f9a937f5d78d', '36b65d0c-042b-4653-863a-655ee739861c', NULL, '127.0.0.1', 'curl', '', 200, '2025-03-04 15:08:44.411389+02', '2025-03-04 15:08:44.411389+02'), - ('45e89705-e09d-4850-bcec-f9a937f5d78d', NULL, NULL, '::1', 'curl', 'terminal', 0, '2025-03-04 15:25:55.555306+02', '2025-03-04 15:25:55.555306+02'); + ('45e89705-e09d-4850-bcec-f9a937f5d78d', '36b65d0c-042b-4653-863a-655ee739861c', '00000000-0000-0000-0000-000000000000', '127.0.0.1', 'curl', '', 200, '2025-03-04 15:08:44.411389+02', '2025-03-04 15:08:44.411389+02'), + ('45e89705-e09d-4850-bcec-f9a937f5d78d', '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000', '::1', 'curl', 'terminal', 0, '2025-03-04 15:25:55.555306+02', '2025-03-04 15:25:55.555306+02'); From 16ae5773849cd4b6d1cb2251f3685f03cd33d923 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 17 Mar 2025 12:51:03 +0000 Subject: [PATCH 36/39] fix dbauthz --- coderd/database/dbauthz/dbauthz_test.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 0ea8a1fbd891c..7c2c810f3d67d 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4047,12 +4047,9 @@ func (s *MethodTestSuite) TestSystemFunctions() { app := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: agent.ID}) check.Args(database.UpsertWorkspaceAppAuditSessionParams{ AgentID: agent.ID, - AppID: uuid.NullUUID{UUID: app.ID, Valid: true}, - UserID: uuid.NullUUID{UUID: u.ID, Valid: true}, - }).Asserts(rbac.ResourceSystem, policy.ActionCreate) - })) - s.Run("UpdateWorkspaceAppAuditSession", s.Subtest(func(db database.Store, check *expects) { - check.Args(database.UpdateWorkspaceAppAuditSessionParams{}).Asserts(rbac.ResourceSystem, policy.ActionUpdate) + AppID: app.ID, + UserID: u.ID, + }).Asserts(rbac.ResourceSystem, policy.ActionUpdate) })) s.Run("InsertWorkspaceAgentScriptTimings", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) From c1ae295df84e06fa568a6b015a506963344e8c78 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 17 Mar 2025 13:09:07 +0000 Subject: [PATCH 37/39] fix ip nullability --- coderd/database/dbauthz/dbauthz_test.go | 1 + coderd/database/dbmem/dbmem.go | 2 +- coderd/database/dump.sql | 2 +- ...01_add_workspace_app_audit_sessions.up.sql | 2 +- coderd/database/models.go | 2 +- coderd/database/queries.sql.go | 20 +++++++++---------- coderd/workspaceapps/db.go | 10 ++++------ 7 files changed, 19 insertions(+), 20 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 569217be98541..2c089d287594b 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4075,6 +4075,7 @@ func (s *MethodTestSuite) TestSystemFunctions() { AgentID: agent.ID, AppID: app.ID, UserID: u.ID, + Ip: "127.0.0.1", }).Asserts(rbac.ResourceSystem, policy.ActionUpdate) })) s.Run("InsertWorkspaceAgentScriptTimings", s.Subtest(func(db database.Store, check *expects) { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 305995c1d7d06..c3d5bace4e0af 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -12284,7 +12284,7 @@ func (q *FakeQuerier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg da if s.UserID != arg.UserID { continue } - if s.Ip.IPNet.String() != arg.Ip.IPNet.String() { + if s.Ip != arg.Ip { continue } if s.UserAgent != arg.UserAgent { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 5c1691d07974b..d3a460e0c2f1b 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1762,7 +1762,7 @@ CREATE UNLOGGED TABLE workspace_app_audit_sessions ( agent_id uuid NOT NULL, app_id uuid NOT NULL, user_id uuid NOT NULL, - ip inet NOT NULL, + ip text NOT NULL, user_agent text NOT NULL, slug_or_port text NOT NULL, status_code integer NOT NULL, diff --git a/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql b/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql index 7108f9f048370..a9ffdb4fd6211 100644 --- a/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql +++ b/coderd/database/migrations/000301_add_workspace_app_audit_sessions.up.sql @@ -4,7 +4,7 @@ CREATE UNLOGGED TABLE workspace_app_audit_sessions ( agent_id UUID NOT NULL, app_id UUID NOT NULL, -- Can be NULL, but must be uuid.Nil. user_id UUID NOT NULL, -- Can be NULL, but must be uuid.Nil. - ip inet NOT NULL, + ip TEXT NOT NULL, user_agent TEXT NOT NULL, slug_or_port TEXT NOT NULL, status_code int4 NOT NULL, diff --git a/coderd/database/models.go b/coderd/database/models.go index 0ec8d70567a45..0d427c9dde02d 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3443,7 +3443,7 @@ type WorkspaceAppAuditSession struct { // The user that is currently using the workspace app. This is may be uuid.Nil if we cannot determine the user. UserID uuid.UUID `db:"user_id" json:"user_id"` // The IP address of the user that is currently using the workspace app. - Ip pqtype.Inet `db:"ip" json:"ip"` + Ip string `db:"ip" json:"ip"` // The user agent of the user that is currently using the workspace app. UserAgent string `db:"user_agent" json:"user_agent"` // The slug or port of the workspace app that the user is currently using. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 41cfc80df0998..b4e2795bc031a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -14676,16 +14676,16 @@ RETURNING ` type UpsertWorkspaceAppAuditSessionParams struct { - AgentID uuid.UUID `db:"agent_id" json:"agent_id"` - AppID uuid.UUID `db:"app_id" json:"app_id"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - Ip pqtype.Inet `db:"ip" json:"ip"` - UserAgent string `db:"user_agent" json:"user_agent"` - SlugOrPort string `db:"slug_or_port" json:"slug_or_port"` - StatusCode int32 `db:"status_code" json:"status_code"` - StartedAt time.Time `db:"started_at" json:"started_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - StaleIntervalMS int64 `db:"stale_interval_ms" json:"stale_interval_ms"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + AppID uuid.UUID `db:"app_id" json:"app_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + Ip string `db:"ip" json:"ip"` + UserAgent string `db:"user_agent" json:"user_agent"` + SlugOrPort string `db:"slug_or_port" json:"slug_or_port"` + StatusCode int32 `db:"status_code" json:"status_code"` + StartedAt time.Time `db:"started_at" json:"started_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + StaleIntervalMS int64 `db:"stale_interval_ms" json:"stale_interval_ms"` } // Insert a new workspace app audit session or update an existing one, if diff --git a/coderd/workspaceapps/db.go b/coderd/workspaceapps/db.go index 9c1959f792095..b26bf4b42a32c 100644 --- a/coderd/workspaceapps/db.go +++ b/coderd/workspaceapps/db.go @@ -15,7 +15,6 @@ import ( "github.com/go-jose/go-jose/v4/jwt" "github.com/google/uuid" - "github.com/sqlc-dev/pqtype" "golang.org/x/xerrors" "cdr.dev/slog" @@ -367,7 +366,6 @@ func (p *DBTokenProvider) authorizeRequest(ctx context.Context, roles *rbac.Subj type auditRequest struct { time time.Time - ip pqtype.Inet apiKey *database.APIKey dbReq *databaseRequest } @@ -389,7 +387,6 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW aReq = &auditRequest{ time: dbtime.Now(), - ip: audit.ParseIP(r.RemoteAddr), } // Set the commit function on the status writer to create an audit @@ -412,6 +409,7 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW userID = aReq.apiKey.UserID } userAgent := r.UserAgent() + ip := r.RemoteAddr // Approximation of the status code. statusCode := sw.Status @@ -462,7 +460,7 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW AgentID: aReq.dbReq.Agent.ID, AppID: aReq.dbReq.App.ID, // Can be unset, in which case uuid.Nil is fine. UserID: userID, // Can be unset, in which case uuid.Nil is fine. - Ip: aReq.ip, + Ip: ip, UserAgent: userAgent, SlugOrPort: appInfo.SlugOrPort, StatusCode: int32(statusCode), @@ -512,7 +510,7 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW RequestID: requestID, Time: aReq.time, Status: statusCode, - IP: aReq.ip.IPNet.IP.String(), + IP: ip, UserAgent: userAgent, New: aReq.dbReq.App, AdditionalFields: appInfoBytes, @@ -529,7 +527,7 @@ func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseW RequestID: requestID, Time: aReq.time, Status: statusCode, - IP: aReq.ip.IPNet.IP.String(), + IP: ip, UserAgent: userAgent, New: aReq.dbReq.Agent, AdditionalFields: appInfoBytes, From 1ee8441d385833c58465bed62af99f9908a7dfe6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 17 Mar 2025 13:58:17 +0000 Subject: [PATCH 38/39] add exception for redirect in audit log --- coderd/audit.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/coderd/audit.go b/coderd/audit.go index 75b711bf74ec9..4e99cbf1e0b58 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -282,10 +282,14 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow) string { _, _ = b.WriteString("{user} ") } - if alog.AuditLog.StatusCode >= 400 { + switch { + case alog.AuditLog.StatusCode == int32(http.StatusSeeOther): + _, _ = b.WriteString("was redirected attempting to ") + _, _ = b.WriteString(string(alog.AuditLog.Action)) + case alog.AuditLog.StatusCode >= 400: _, _ = b.WriteString("unsuccessfully attempted to ") _, _ = b.WriteString(string(alog.AuditLog.Action)) - } else { + default: _, _ = b.WriteString(codersdk.AuditAction(alog.AuditLog.Action).Friendly()) } From 5b3b122f9ec0dfc1c4c64a11eb6af9fdb5830716 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 17 Mar 2025 13:58:54 +0000 Subject: [PATCH 39/39] unused arg --- coderd/database/dbmem/dbmem.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index c3d5bace4e0af..1d65f355783ae 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -12265,7 +12265,7 @@ func (q *FakeQuerier) UpsertWorkspaceAgentPortShare(_ context.Context, arg datab return psl, nil } -func (q *FakeQuerier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (time.Time, error) { +func (q *FakeQuerier) UpsertWorkspaceAppAuditSession(_ context.Context, arg database.UpsertWorkspaceAppAuditSessionParams) (time.Time, error) { err := validateDatabaseType(arg) if err != nil { return time.Time{}, err