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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy