From 796d61504847441edc9bc4ce724aba528369c28e Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 31 Jul 2024 12:46:47 -0500 Subject: [PATCH 1/8] chore: add sql filter to fetching audit logs --- coderd/database/dbauthz/dbauthz.go | 4 ++ coderd/database/dbmem/dbmem.go | 4 ++ coderd/database/dbmetrics/dbmetrics.go | 7 ++ coderd/database/dbmock/dbmock.go | 15 +++++ coderd/database/modelqueries.go | 88 ++++++++++++++++++++++++++ coderd/database/queries.sql.go | 3 + coderd/database/queries/auditlogs.sql | 3 + coderd/rbac/regosql/configs.go | 15 +++++ 8 files changed, 139 insertions(+) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d12b9aba23863..bd368d9db75d2 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3852,3 +3852,7 @@ func (q *querier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersP // GetUsers is authenticated. return q.GetUsers(ctx, arg) } + +func (q *querier) GetAuthorizedAuditLogsOffset(ctx context.Context, arg database.GetAuditLogsOffsetParams, prepared rbac.PreparedAuthorized) ([]database.GetAuditLogsOffsetRow, error) { + panic("not implemented") +} diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 8d1088616f6bc..bce909af282bd 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -10080,3 +10080,7 @@ func (q *FakeQuerier) GetAuthorizedUsers(ctx context.Context, arg database.GetUs } return filteredUsers, nil } + +func (q *FakeQuerier) GetAuthorizedAuditLogsOffset(ctx context.Context, arg database.GetAuditLogsOffsetParams, prepared rbac.PreparedAuthorized) ([]database.GetAuditLogsOffsetRow, error) { + panic("not implemented") +} diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index f987d0505653b..2b25591568f8c 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -2467,3 +2467,10 @@ func (m metricsStore) GetAuthorizedUsers(ctx context.Context, arg database.GetUs m.queryLatencies.WithLabelValues("GetAuthorizedUsers").Observe(time.Since(start).Seconds()) return r0, r1 } + +func (m metricsStore) GetAuthorizedAuditLogsOffset(ctx context.Context, arg database.GetAuditLogsOffsetParams, prepared rbac.PreparedAuthorized) ([]database.GetAuditLogsOffsetRow, error) { + start := time.Now() + r0, r1 := m.s.GetAuthorizedAuditLogsOffset(ctx, arg, prepared) + m.queryLatencies.WithLabelValues("GetAuthorizedAuditLogsOffset").Observe(time.Since(start).Seconds()) + return r0, r1 +} diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 78cd95a69cde5..b91ba6c8bd5d8 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -955,6 +955,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizationUserRoles(arg0, arg1 any) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizationUserRoles", reflect.TypeOf((*MockStore)(nil).GetAuthorizationUserRoles), arg0, arg1) } +// GetAuthorizedAuditLogsOffset mocks base method. +func (m *MockStore) GetAuthorizedAuditLogsOffset(arg0 context.Context, arg1 database.GetAuditLogsOffsetParams, arg2 rbac.PreparedAuthorized) ([]database.GetAuditLogsOffsetRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAuthorizedAuditLogsOffset", arg0, arg1, arg2) + ret0, _ := ret[0].([]database.GetAuditLogsOffsetRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAuthorizedAuditLogsOffset indicates an expected call of GetAuthorizedAuditLogsOffset. +func (mr *MockStoreMockRecorder) GetAuthorizedAuditLogsOffset(arg0, arg1, arg2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedAuditLogsOffset", reflect.TypeOf((*MockStore)(nil).GetAuthorizedAuditLogsOffset), arg0, arg1, arg2) +} + // GetAuthorizedTemplates mocks base method. func (m *MockStore) GetAuthorizedTemplates(arg0 context.Context, arg1 database.GetTemplatesWithFilterParams, arg2 rbac.PreparedAuthorized) ([]database.Template, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 7ee4f4f676eea..b84229f285956 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -48,6 +48,7 @@ type customQuerier interface { templateQuerier workspaceQuerier userQuerier + auditLogQuerier } type templateQuerier interface { @@ -375,6 +376,93 @@ func (q *sqlQuerier) GetAuthorizedUsers(ctx context.Context, arg GetUsersParams, return items, nil } +type auditLogQuerier interface { + GetAuthorizedAuditLogsOffset(ctx context.Context, arg GetAuditLogsOffsetParams, prepared rbac.PreparedAuthorized) ([]GetAuditLogsOffsetRow, error) +} + +func (q *sqlQuerier) GetAuthorizedAuditLogsOffset(ctx context.Context, arg GetAuditLogsOffsetParams, prepared rbac.PreparedAuthorized) ([]GetAuditLogsOffsetRow, error) { + authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{ + VariableConverter: regosql.AuditLogConverter(), + }) + if err != nil { + return nil, xerrors.Errorf("compile authorized filter: %w", err) + } + + filtered, err := insertAuthorizedFilter(getAuditLogsOffset, fmt.Sprintf(" AND %s", authorizedFilter)) + if err != nil { + return nil, xerrors.Errorf("insert authorized filter: %w", err) + } + + query := fmt.Sprintf("-- name: GetAuthorizedAuditLogsOffset :many\n%s", filtered) + rows, err := q.db.QueryContext(ctx, query, + arg.ResourceType, + arg.ResourceID, + arg.OrganizationID, + arg.ResourceTarget, + arg.Action, + arg.UserID, + arg.Username, + arg.Email, + arg.DateFrom, + arg.DateTo, + arg.BuildReason, + arg.OffsetOpt, + arg.LimitOpt, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAuditLogsOffsetRow + for rows.Next() { + var i GetAuditLogsOffsetRow + if err := rows.Scan( + &i.ID, + &i.Time, + &i.UserID, + &i.OrganizationID, + &i.Ip, + &i.UserAgent, + &i.ResourceType, + &i.ResourceID, + &i.ResourceTarget, + &i.Action, + &i.Diff, + &i.StatusCode, + &i.AdditionalFields, + &i.RequestID, + &i.ResourceIcon, + &i.UserUsername, + &i.UserName, + &i.UserEmail, + &i.UserCreatedAt, + &i.UserUpdatedAt, + &i.UserLastSeenAt, + &i.UserStatus, + &i.UserLoginType, + &i.UserRoles, + &i.UserAvatarUrl, + &i.UserDeleted, + &i.UserThemePreference, + &i.UserQuietHoursSchedule, + &i.OrganizationName, + &i.OrganizationDisplayName, + &i.OrganizationIcon, + &i.Count, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + func insertAuthorizedFilter(query string, replaceWith string) (string, error) { if !strings.Contains(query, authorizedQueryPlaceholder) { return "", xerrors.Errorf("query does not contain authorized replace string, this is not an authorized query") diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2e3a5c9892d40..2a4e4f4a0dbbd 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -558,6 +558,9 @@ WHERE workspace_builds.reason::text = $11 ELSE true END + + -- Authorize Filter clause will be injected below in GetAuthorizedAuditLogsOffset + -- @authorize_filter ORDER BY "time" DESC LIMIT diff --git a/coderd/database/queries/auditlogs.sql b/coderd/database/queries/auditlogs.sql index d8ef38a82120e..4b209c00c9f5b 100644 --- a/coderd/database/queries/auditlogs.sql +++ b/coderd/database/queries/auditlogs.sql @@ -117,6 +117,9 @@ WHERE workspace_builds.reason::text = @build_reason ELSE true END + + -- Authorize Filter clause will be injected below in GetAuthorizedAuditLogsOffset + -- @authorize_filter ORDER BY "time" DESC LIMIT diff --git a/coderd/rbac/regosql/configs.go b/coderd/rbac/regosql/configs.go index e50d6d5fbe817..9e331d6945026 100644 --- a/coderd/rbac/regosql/configs.go +++ b/coderd/rbac/regosql/configs.go @@ -36,6 +36,21 @@ func TemplateConverter() *sqltypes.VariableConverter { return matcher } +func AuditLogConverter() *sqltypes.VariableConverter { + matcher := sqltypes.NewVariableConverter().RegisterMatcher( + resourceIDMatcher(), + organizationOwnerMatcher(), + // Templates have no user owner, only owner by an organization. + sqltypes.AlwaysFalse(userOwnerMatcher()), + ) + matcher.RegisterMatcher( + // No ACLs on the user type + sqltypes.AlwaysFalse(groupACLMatcher(matcher)), + sqltypes.AlwaysFalse(userACLMatcher(matcher)), + ) + return matcher +} + func UserConverter() *sqltypes.VariableConverter { matcher := sqltypes.NewVariableConverter().RegisterMatcher( resourceIDMatcher(), From c99cc31d79d8098db7819eff0f7159ca6cdbbf1b Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 31 Jul 2024 12:57:01 -0500 Subject: [PATCH 2/8] use sqlc.embed for audit logs --- coderd/audit.go | 86 +++++----- coderd/audit_internal_test.go | 40 +++-- coderd/database/dbauthz/dbauthz.go | 4 +- coderd/database/dbmem/dbmem.go | 227 +++++++++++++------------- coderd/database/modelmethods.go | 14 ++ coderd/database/modelqueries.go | 30 ++-- coderd/database/queries.sql.go | 80 ++++----- coderd/database/queries/auditlogs.sql | 2 +- 8 files changed, 248 insertions(+), 235 deletions(-) diff --git a/coderd/audit.go b/coderd/audit.go index f7dfb118d20bc..6d9a23ad217a5 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -182,17 +182,17 @@ func (api *API) convertAuditLogs(ctx context.Context, dblogs []database.GetAudit } func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogsOffsetRow) codersdk.AuditLog { - ip, _ := netip.AddrFromSlice(dblog.Ip.IPNet.IP) + ip, _ := netip.AddrFromSlice(dblog.AuditLog.Ip.IPNet.IP) diff := codersdk.AuditDiff{} - _ = json.Unmarshal(dblog.Diff, &diff) + _ = json.Unmarshal(dblog.AuditLog.Diff, &diff) var user *codersdk.User if dblog.UserUsername.Valid { // Leaving the organization IDs blank for now; not sure they are useful for // the audit query anyway? sdkUser := db2sdk.User(database.User{ - ID: dblog.UserID, + ID: dblog.AuditLog.UserID, Email: dblog.UserEmail.String, Username: dblog.UserUsername.String, CreatedAt: dblog.UserCreatedAt.Time, @@ -211,7 +211,7 @@ func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogs } var ( - additionalFieldsBytes = []byte(dblog.AdditionalFields) + additionalFieldsBytes = []byte(dblog.AuditLog.AdditionalFields) additionalFields audit.AdditionalFields err = json.Unmarshal(additionalFieldsBytes, &additionalFields) ) @@ -224,7 +224,7 @@ func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogs WorkspaceOwner: "unknown", } - dblog.AdditionalFields, err = json.Marshal(resourceInfo) + dblog.AuditLog.AdditionalFields, err = json.Marshal(resourceInfo) api.Logger.Error(ctx, "marshal additional fields", slog.Error(err)) } @@ -239,30 +239,30 @@ func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogs } alog := codersdk.AuditLog{ - ID: dblog.ID, - RequestID: dblog.RequestID, - Time: dblog.Time, + ID: dblog.AuditLog.ID, + RequestID: dblog.AuditLog.RequestID, + Time: dblog.AuditLog.Time, // OrganizationID is deprecated. - OrganizationID: dblog.OrganizationID, + OrganizationID: dblog.AuditLog.OrganizationID, IP: ip, - UserAgent: dblog.UserAgent.String, - ResourceType: codersdk.ResourceType(dblog.ResourceType), - ResourceID: dblog.ResourceID, - ResourceTarget: dblog.ResourceTarget, - ResourceIcon: dblog.ResourceIcon, - Action: codersdk.AuditAction(dblog.Action), + UserAgent: dblog.AuditLog.UserAgent.String, + ResourceType: codersdk.ResourceType(dblog.AuditLog.ResourceType), + ResourceID: dblog.AuditLog.ResourceID, + ResourceTarget: dblog.AuditLog.ResourceTarget, + ResourceIcon: dblog.AuditLog.ResourceIcon, + Action: codersdk.AuditAction(dblog.AuditLog.Action), Diff: diff, - StatusCode: dblog.StatusCode, - AdditionalFields: dblog.AdditionalFields, + StatusCode: dblog.AuditLog.StatusCode, + AdditionalFields: dblog.AuditLog.AdditionalFields, User: user, Description: auditLogDescription(dblog), ResourceLink: resourceLink, IsDeleted: isDeleted, } - if dblog.OrganizationID != uuid.Nil { + if dblog.AuditLog.OrganizationID != uuid.Nil { alog.Organization = &codersdk.MinimalOrganization{ - ID: dblog.OrganizationID, + ID: dblog.AuditLog.OrganizationID, Name: dblog.OrganizationName, DisplayName: dblog.OrganizationDisplayName, Icon: dblog.OrganizationIcon, @@ -276,32 +276,32 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow) string { b := strings.Builder{} // NOTE: WriteString always returns a nil error, so we never check it _, _ = b.WriteString("{user} ") - if alog.StatusCode >= 400 { + if alog.AuditLog.StatusCode >= 400 { _, _ = b.WriteString("unsuccessfully attempted to ") - _, _ = b.WriteString(string(alog.Action)) + _, _ = b.WriteString(string(alog.AuditLog.Action)) } else { - _, _ = b.WriteString(codersdk.AuditAction(alog.Action).Friendly()) + _, _ = b.WriteString(codersdk.AuditAction(alog.AuditLog.Action).Friendly()) } // API Key resources (used for authentication) do not have targets and follow the below format: // "User {logged in | logged out | registered}" - if alog.ResourceType == database.ResourceTypeApiKey && - (alog.Action == database.AuditActionLogin || alog.Action == database.AuditActionLogout || alog.Action == database.AuditActionRegister) { + if alog.AuditLog.ResourceType == database.ResourceTypeApiKey && + (alog.AuditLog.Action == database.AuditActionLogin || alog.AuditLog.Action == database.AuditActionLogout || alog.AuditLog.Action == database.AuditActionRegister) { return b.String() } // We don't display the name (target) for git ssh keys. It's fairly long and doesn't // make too much sense to display. - if alog.ResourceType == database.ResourceTypeGitSshKey { + if alog.AuditLog.ResourceType == database.ResourceTypeGitSshKey { _, _ = b.WriteString(" the ") - _, _ = b.WriteString(codersdk.ResourceType(alog.ResourceType).FriendlyString()) + _, _ = b.WriteString(codersdk.ResourceType(alog.AuditLog.ResourceType).FriendlyString()) return b.String() } _, _ = b.WriteString(" ") - _, _ = b.WriteString(codersdk.ResourceType(alog.ResourceType).FriendlyString()) + _, _ = b.WriteString(codersdk.ResourceType(alog.AuditLog.ResourceType).FriendlyString()) - if alog.ResourceType == database.ResourceTypeConvertLogin { + if alog.AuditLog.ResourceType == database.ResourceTypeConvertLogin { _, _ = b.WriteString(" to") } @@ -311,9 +311,9 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow) string { } func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.GetAuditLogsOffsetRow) bool { - switch alog.ResourceType { + switch alog.AuditLog.ResourceType { case database.ResourceTypeTemplate: - template, err := api.Database.GetTemplateByID(ctx, alog.ResourceID) + template, err := api.Database.GetTemplateByID(ctx, alog.AuditLog.ResourceID) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { return true @@ -322,7 +322,7 @@ func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.Get } return template.Deleted case database.ResourceTypeUser: - user, err := api.Database.GetUserByID(ctx, alog.ResourceID) + user, err := api.Database.GetUserByID(ctx, alog.AuditLog.ResourceID) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { return true @@ -331,7 +331,7 @@ func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.Get } return user.Deleted case database.ResourceTypeWorkspace: - workspace, err := api.Database.GetWorkspaceByID(ctx, alog.ResourceID) + workspace, err := api.Database.GetWorkspaceByID(ctx, alog.AuditLog.ResourceID) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { return true @@ -340,7 +340,7 @@ func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.Get } return workspace.Deleted case database.ResourceTypeWorkspaceBuild: - workspaceBuild, err := api.Database.GetWorkspaceBuildByID(ctx, alog.ResourceID) + workspaceBuild, err := api.Database.GetWorkspaceBuildByID(ctx, alog.AuditLog.ResourceID) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { return true @@ -357,7 +357,7 @@ func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.Get } return workspace.Deleted case database.ResourceTypeOauth2ProviderApp: - _, err := api.Database.GetOAuth2ProviderAppByID(ctx, alog.ResourceID) + _, err := api.Database.GetOAuth2ProviderAppByID(ctx, alog.AuditLog.ResourceID) if xerrors.Is(err, sql.ErrNoRows) { return true } else if err != nil { @@ -365,7 +365,7 @@ func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.Get } return false case database.ResourceTypeOauth2ProviderAppSecret: - _, err := api.Database.GetOAuth2ProviderAppSecretByID(ctx, alog.ResourceID) + _, err := api.Database.GetOAuth2ProviderAppSecretByID(ctx, alog.AuditLog.ResourceID) if xerrors.Is(err, sql.ErrNoRows) { return true } else if err != nil { @@ -378,17 +378,17 @@ func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.Get } func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAuditLogsOffsetRow, additionalFields audit.AdditionalFields) string { - switch alog.ResourceType { + switch alog.AuditLog.ResourceType { case database.ResourceTypeTemplate: return fmt.Sprintf("/templates/%s", - alog.ResourceTarget) + alog.AuditLog.ResourceTarget) case database.ResourceTypeUser: return fmt.Sprintf("/users?filter=%s", - alog.ResourceTarget) + alog.AuditLog.ResourceTarget) case database.ResourceTypeWorkspace: - workspace, getWorkspaceErr := api.Database.GetWorkspaceByID(ctx, alog.ResourceID) + workspace, getWorkspaceErr := api.Database.GetWorkspaceByID(ctx, alog.AuditLog.ResourceID) if getWorkspaceErr != nil { return "" } @@ -397,13 +397,13 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit return "" } return fmt.Sprintf("/@%s/%s", - workspaceOwner.Username, alog.ResourceTarget) + workspaceOwner.Username, alog.AuditLog.ResourceTarget) case database.ResourceTypeWorkspaceBuild: if len(additionalFields.WorkspaceName) == 0 || len(additionalFields.BuildNumber) == 0 { return "" } - workspaceBuild, getWorkspaceBuildErr := api.Database.GetWorkspaceBuildByID(ctx, alog.ResourceID) + workspaceBuild, getWorkspaceBuildErr := api.Database.GetWorkspaceBuildByID(ctx, alog.AuditLog.ResourceID) if getWorkspaceBuildErr != nil { return "" } @@ -419,10 +419,10 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit workspaceOwner.Username, additionalFields.WorkspaceName, additionalFields.BuildNumber) case database.ResourceTypeOauth2ProviderApp: - return fmt.Sprintf("/deployment/oauth2-provider/apps/%s", alog.ResourceID) + return fmt.Sprintf("/deployment/oauth2-provider/apps/%s", alog.AuditLog.ResourceID) case database.ResourceTypeOauth2ProviderAppSecret: - secret, err := api.Database.GetOAuth2ProviderAppSecretByID(ctx, alog.ResourceID) + secret, err := api.Database.GetOAuth2ProviderAppSecretByID(ctx, alog.AuditLog.ResourceID) if err != nil { return "" } diff --git a/coderd/audit_internal_test.go b/coderd/audit_internal_test.go index 9d9cea01a522a..f3d3b160d6388 100644 --- a/coderd/audit_internal_test.go +++ b/coderd/audit_internal_test.go @@ -18,45 +18,55 @@ func TestAuditLogDescription(t *testing.T) { { name: "mainline", alog: database.GetAuditLogsOffsetRow{ - Action: database.AuditActionCreate, - StatusCode: 200, - ResourceType: database.ResourceTypeWorkspace, + AuditLog: database.AuditLog{ + Action: database.AuditActionCreate, + StatusCode: 200, + ResourceType: database.ResourceTypeWorkspace, + }, }, want: "{user} created workspace {target}", }, { name: "unsuccessful", alog: database.GetAuditLogsOffsetRow{ - Action: database.AuditActionCreate, - StatusCode: 400, - ResourceType: database.ResourceTypeWorkspace, + AuditLog: database.AuditLog{ + Action: database.AuditActionCreate, + StatusCode: 400, + ResourceType: database.ResourceTypeWorkspace, + }, }, want: "{user} unsuccessfully attempted to create workspace {target}", }, { name: "login", alog: database.GetAuditLogsOffsetRow{ - Action: database.AuditActionLogin, - StatusCode: 200, - ResourceType: database.ResourceTypeApiKey, + AuditLog: database.AuditLog{ + Action: database.AuditActionLogin, + StatusCode: 200, + ResourceType: database.ResourceTypeApiKey, + }, }, want: "{user} logged in", }, { name: "unsuccessful_login", alog: database.GetAuditLogsOffsetRow{ - Action: database.AuditActionLogin, - StatusCode: 401, - ResourceType: database.ResourceTypeApiKey, + AuditLog: database.AuditLog{ + Action: database.AuditActionLogin, + StatusCode: 401, + ResourceType: database.ResourceTypeApiKey, + }, }, want: "{user} unsuccessfully attempted to login", }, { name: "gitsshkey", alog: database.GetAuditLogsOffsetRow{ - Action: database.AuditActionDelete, - StatusCode: 200, - ResourceType: database.ResourceTypeGitSshKey, + AuditLog: database.AuditLog{ + Action: database.AuditActionDelete, + StatusCode: 200, + ResourceType: database.ResourceTypeGitSshKey, + }, }, want: "{user} deleted the git ssh key", }, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index bd368d9db75d2..1e0e3da9f0a56 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3853,6 +3853,6 @@ func (q *querier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersP return q.GetUsers(ctx, arg) } -func (q *querier) GetAuthorizedAuditLogsOffset(ctx context.Context, arg database.GetAuditLogsOffsetParams, prepared rbac.PreparedAuthorized) ([]database.GetAuditLogsOffsetRow, error) { - panic("not implemented") +func (q *querier) GetAuthorizedAuditLogsOffset(ctx context.Context, arg database.GetAuditLogsOffsetParams, _ rbac.PreparedAuthorized) ([]database.GetAuditLogsOffsetRow, error) { + return q.GetAuditLogsOffset(ctx, arg) } diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index bce909af282bd..f32de78b72714 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -2092,117 +2092,8 @@ func (q *FakeQuerier) GetApplicationName(_ context.Context) (string, error) { return q.applicationName, nil } -func (q *FakeQuerier) GetAuditLogsOffset(_ context.Context, arg database.GetAuditLogsOffsetParams) ([]database.GetAuditLogsOffsetRow, error) { - if err := validateDatabaseType(arg); err != nil { - return nil, err - } - - q.mutex.RLock() - defer q.mutex.RUnlock() - - if arg.LimitOpt == 0 { - // Default to 100 is set in the SQL query. - arg.LimitOpt = 100 - } - - logs := make([]database.GetAuditLogsOffsetRow, 0, arg.LimitOpt) - - // q.auditLogs are already sorted by time DESC, so no need to sort after the fact. - for _, alog := range q.auditLogs { - if arg.OffsetOpt > 0 { - arg.OffsetOpt-- - continue - } - if arg.OrganizationID != uuid.Nil && arg.OrganizationID != alog.OrganizationID { - continue - } - if arg.Action != "" && !strings.Contains(string(alog.Action), arg.Action) { - continue - } - if arg.ResourceType != "" && !strings.Contains(string(alog.ResourceType), arg.ResourceType) { - continue - } - if arg.ResourceID != uuid.Nil && alog.ResourceID != arg.ResourceID { - continue - } - if arg.Username != "" { - user, err := q.getUserByIDNoLock(alog.UserID) - if err == nil && !strings.EqualFold(arg.Username, user.Username) { - continue - } - } - if arg.Email != "" { - user, err := q.getUserByIDNoLock(alog.UserID) - if err == nil && !strings.EqualFold(arg.Email, user.Email) { - continue - } - } - if !arg.DateFrom.IsZero() { - if alog.Time.Before(arg.DateFrom) { - continue - } - } - if !arg.DateTo.IsZero() { - if alog.Time.After(arg.DateTo) { - continue - } - } - if arg.BuildReason != "" { - workspaceBuild, err := q.getWorkspaceBuildByIDNoLock(context.Background(), alog.ResourceID) - if err == nil && !strings.EqualFold(arg.BuildReason, string(workspaceBuild.Reason)) { - continue - } - } - - user, err := q.getUserByIDNoLock(alog.UserID) - userValid := err == nil - - org, _ := q.getOrganizationByIDNoLock(alog.OrganizationID) - - logs = append(logs, database.GetAuditLogsOffsetRow{ - ID: alog.ID, - RequestID: alog.RequestID, - OrganizationID: alog.OrganizationID, - OrganizationName: org.Name, - OrganizationDisplayName: org.DisplayName, - OrganizationIcon: org.Icon, - Ip: alog.Ip, - UserAgent: alog.UserAgent, - ResourceType: alog.ResourceType, - ResourceID: alog.ResourceID, - ResourceTarget: alog.ResourceTarget, - ResourceIcon: alog.ResourceIcon, - Action: alog.Action, - Diff: alog.Diff, - StatusCode: alog.StatusCode, - AdditionalFields: alog.AdditionalFields, - UserID: alog.UserID, - UserUsername: sql.NullString{String: user.Username, Valid: userValid}, - UserName: sql.NullString{String: user.Name, Valid: userValid}, - UserEmail: sql.NullString{String: user.Email, Valid: userValid}, - UserCreatedAt: sql.NullTime{Time: user.CreatedAt, Valid: userValid}, - UserUpdatedAt: sql.NullTime{Time: user.UpdatedAt, Valid: userValid}, - UserLastSeenAt: sql.NullTime{Time: user.LastSeenAt, Valid: userValid}, - UserLoginType: database.NullLoginType{LoginType: user.LoginType, Valid: userValid}, - UserDeleted: sql.NullBool{Bool: user.Deleted, Valid: userValid}, - UserThemePreference: sql.NullString{String: user.ThemePreference, Valid: userValid}, - UserQuietHoursSchedule: sql.NullString{String: user.QuietHoursSchedule, Valid: userValid}, - UserStatus: database.NullUserStatus{UserStatus: user.Status, Valid: userValid}, - UserRoles: user.RBACRoles, - Count: 0, - }) - - if len(logs) >= int(arg.LimitOpt) { - break - } - } - - count := int64(len(logs)) - for i := range logs { - logs[i].Count = count - } - - return logs, nil +func (q *FakeQuerier) GetAuditLogsOffset(ctx context.Context, arg database.GetAuditLogsOffsetParams) ([]database.GetAuditLogsOffsetRow, error) { + return q.GetAuthorizedAuditLogsOffset(ctx, arg, nil) } func (q *FakeQuerier) GetAuthorizationUserRoles(_ context.Context, userID uuid.UUID) (database.GetAuthorizationUserRolesRow, error) { @@ -10082,5 +9973,117 @@ func (q *FakeQuerier) GetAuthorizedUsers(ctx context.Context, arg database.GetUs } func (q *FakeQuerier) GetAuthorizedAuditLogsOffset(ctx context.Context, arg database.GetAuditLogsOffsetParams, prepared rbac.PreparedAuthorized) ([]database.GetAuditLogsOffsetRow, error) { - panic("not implemented") + if err := validateDatabaseType(arg); err != nil { + return nil, err + } + + // Call this to match the same function calls as the SQL implementation. + // It functionally does nothing for filtering. + if prepared != nil { + _, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{ + VariableConverter: regosql.AuditLogConverter(), + }) + if err != nil { + return nil, err + } + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + if arg.LimitOpt == 0 { + // Default to 100 is set in the SQL query. + arg.LimitOpt = 100 + } + + logs := make([]database.GetAuditLogsOffsetRow, 0, arg.LimitOpt) + + // q.auditLogs are already sorted by time DESC, so no need to sort after the fact. + for _, alog := range q.auditLogs { + if arg.OffsetOpt > 0 { + arg.OffsetOpt-- + continue + } + if arg.OrganizationID != uuid.Nil && arg.OrganizationID != alog.OrganizationID { + continue + } + if arg.Action != "" && !strings.Contains(string(alog.Action), arg.Action) { + continue + } + if arg.ResourceType != "" && !strings.Contains(string(alog.ResourceType), arg.ResourceType) { + continue + } + if arg.ResourceID != uuid.Nil && alog.ResourceID != arg.ResourceID { + continue + } + if arg.Username != "" { + user, err := q.getUserByIDNoLock(alog.UserID) + if err == nil && !strings.EqualFold(arg.Username, user.Username) { + continue + } + } + if arg.Email != "" { + user, err := q.getUserByIDNoLock(alog.UserID) + if err == nil && !strings.EqualFold(arg.Email, user.Email) { + continue + } + } + if !arg.DateFrom.IsZero() { + if alog.Time.Before(arg.DateFrom) { + continue + } + } + if !arg.DateTo.IsZero() { + if alog.Time.After(arg.DateTo) { + continue + } + } + if arg.BuildReason != "" { + workspaceBuild, err := q.getWorkspaceBuildByIDNoLock(context.Background(), alog.ResourceID) + if err == nil && !strings.EqualFold(arg.BuildReason, string(workspaceBuild.Reason)) { + continue + } + } + // If the filter exists, ensure the object is authorized. + if prepared != nil && prepared.Authorize(ctx, alog.RBACObject()) != nil { + continue + } + + user, err := q.getUserByIDNoLock(alog.UserID) + userValid := err == nil + + org, _ := q.getOrganizationByIDNoLock(alog.OrganizationID) + + cpy := alog + logs = append(logs, database.GetAuditLogsOffsetRow{ + AuditLog: cpy, + OrganizationName: org.Name, + OrganizationDisplayName: org.DisplayName, + OrganizationIcon: org.Icon, + UserUsername: sql.NullString{String: user.Username, Valid: userValid}, + UserName: sql.NullString{String: user.Name, Valid: userValid}, + UserEmail: sql.NullString{String: user.Email, Valid: userValid}, + UserCreatedAt: sql.NullTime{Time: user.CreatedAt, Valid: userValid}, + UserUpdatedAt: sql.NullTime{Time: user.UpdatedAt, Valid: userValid}, + UserLastSeenAt: sql.NullTime{Time: user.LastSeenAt, Valid: userValid}, + UserLoginType: database.NullLoginType{LoginType: user.LoginType, Valid: userValid}, + UserDeleted: sql.NullBool{Bool: user.Deleted, Valid: userValid}, + UserThemePreference: sql.NullString{String: user.ThemePreference, Valid: userValid}, + UserQuietHoursSchedule: sql.NullString{String: user.QuietHoursSchedule, Valid: userValid}, + UserStatus: database.NullUserStatus{UserStatus: user.Status, Valid: userValid}, + UserRoles: user.RBACRoles, + Count: 0, + }) + + if len(logs) >= int(arg.LimitOpt) { + break + } + } + + count := int64(len(logs)) + for i := range logs { + logs[i].Count = count + } + + return logs, nil } diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index d92a6048baf22..775000ac6ba05 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -5,6 +5,7 @@ import ( "strconv" "time" + "github.com/google/uuid" "golang.org/x/exp/maps" "golang.org/x/oauth2" "golang.org/x/xerrors" @@ -101,6 +102,19 @@ func (g Group) Auditable(users []User) AuditableGroup { const EveryoneGroup = "Everyone" +func (w GetAuditLogsOffsetRow) RBACObject() rbac.Object { + return w.AuditLog.RBACObject() +} + +func (w AuditLog) RBACObject() rbac.Object { + obj := rbac.ResourceAuditLog.WithID(w.ID) + if w.OrganizationID != uuid.Nil { + obj = obj.InOrg(w.OrganizationID) + } + + return obj +} + func (s APIKeyScope) ToRBAC() rbac.ScopeName { switch s { case APIKeyScopeAll: diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index b84229f285956..78826ea7cc8b5 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -417,21 +417,21 @@ func (q *sqlQuerier) GetAuthorizedAuditLogsOffset(ctx context.Context, arg GetAu for rows.Next() { var i GetAuditLogsOffsetRow if err := rows.Scan( - &i.ID, - &i.Time, - &i.UserID, - &i.OrganizationID, - &i.Ip, - &i.UserAgent, - &i.ResourceType, - &i.ResourceID, - &i.ResourceTarget, - &i.Action, - &i.Diff, - &i.StatusCode, - &i.AdditionalFields, - &i.RequestID, - &i.ResourceIcon, + &i.AuditLog.ID, + &i.AuditLog.Time, + &i.AuditLog.UserID, + &i.AuditLog.OrganizationID, + &i.AuditLog.Ip, + &i.AuditLog.UserAgent, + &i.AuditLog.ResourceType, + &i.AuditLog.ResourceID, + &i.AuditLog.ResourceTarget, + &i.AuditLog.Action, + &i.AuditLog.Diff, + &i.AuditLog.StatusCode, + &i.AuditLog.AdditionalFields, + &i.AuditLog.RequestID, + &i.AuditLog.ResourceIcon, &i.UserUsername, &i.UserName, &i.UserEmail, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2a4e4f4a0dbbd..904f304bd25a9 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -589,38 +589,24 @@ type GetAuditLogsOffsetParams struct { } type GetAuditLogsOffsetRow struct { - ID uuid.UUID `db:"id" json:"id"` - Time time.Time `db:"time" json:"time"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - Ip pqtype.Inet `db:"ip" json:"ip"` - UserAgent sql.NullString `db:"user_agent" json:"user_agent"` - ResourceType ResourceType `db:"resource_type" json:"resource_type"` - ResourceID uuid.UUID `db:"resource_id" json:"resource_id"` - ResourceTarget string `db:"resource_target" json:"resource_target"` - Action AuditAction `db:"action" json:"action"` - Diff json.RawMessage `db:"diff" json:"diff"` - StatusCode int32 `db:"status_code" json:"status_code"` - AdditionalFields json.RawMessage `db:"additional_fields" json:"additional_fields"` - RequestID uuid.UUID `db:"request_id" json:"request_id"` - ResourceIcon string `db:"resource_icon" json:"resource_icon"` - UserUsername sql.NullString `db:"user_username" json:"user_username"` - UserName sql.NullString `db:"user_name" json:"user_name"` - UserEmail sql.NullString `db:"user_email" json:"user_email"` - UserCreatedAt sql.NullTime `db:"user_created_at" json:"user_created_at"` - UserUpdatedAt sql.NullTime `db:"user_updated_at" json:"user_updated_at"` - UserLastSeenAt sql.NullTime `db:"user_last_seen_at" json:"user_last_seen_at"` - UserStatus NullUserStatus `db:"user_status" json:"user_status"` - UserLoginType NullLoginType `db:"user_login_type" json:"user_login_type"` - UserRoles pq.StringArray `db:"user_roles" json:"user_roles"` - UserAvatarUrl sql.NullString `db:"user_avatar_url" json:"user_avatar_url"` - UserDeleted sql.NullBool `db:"user_deleted" json:"user_deleted"` - UserThemePreference sql.NullString `db:"user_theme_preference" json:"user_theme_preference"` - UserQuietHoursSchedule sql.NullString `db:"user_quiet_hours_schedule" json:"user_quiet_hours_schedule"` - OrganizationName string `db:"organization_name" json:"organization_name"` - OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` - OrganizationIcon string `db:"organization_icon" json:"organization_icon"` - Count int64 `db:"count" json:"count"` + AuditLog AuditLog `db:"audit_log" json:"audit_log"` + UserUsername sql.NullString `db:"user_username" json:"user_username"` + UserName sql.NullString `db:"user_name" json:"user_name"` + UserEmail sql.NullString `db:"user_email" json:"user_email"` + UserCreatedAt sql.NullTime `db:"user_created_at" json:"user_created_at"` + UserUpdatedAt sql.NullTime `db:"user_updated_at" json:"user_updated_at"` + UserLastSeenAt sql.NullTime `db:"user_last_seen_at" json:"user_last_seen_at"` + UserStatus NullUserStatus `db:"user_status" json:"user_status"` + UserLoginType NullLoginType `db:"user_login_type" json:"user_login_type"` + UserRoles pq.StringArray `db:"user_roles" json:"user_roles"` + UserAvatarUrl sql.NullString `db:"user_avatar_url" json:"user_avatar_url"` + UserDeleted sql.NullBool `db:"user_deleted" json:"user_deleted"` + UserThemePreference sql.NullString `db:"user_theme_preference" json:"user_theme_preference"` + UserQuietHoursSchedule sql.NullString `db:"user_quiet_hours_schedule" json:"user_quiet_hours_schedule"` + OrganizationName string `db:"organization_name" json:"organization_name"` + OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` + OrganizationIcon string `db:"organization_icon" json:"organization_icon"` + Count int64 `db:"count" json:"count"` } // GetAuditLogsBefore retrieves `row_limit` number of audit logs before the provided @@ -649,21 +635,21 @@ func (q *sqlQuerier) GetAuditLogsOffset(ctx context.Context, arg GetAuditLogsOff for rows.Next() { var i GetAuditLogsOffsetRow if err := rows.Scan( - &i.ID, - &i.Time, - &i.UserID, - &i.OrganizationID, - &i.Ip, - &i.UserAgent, - &i.ResourceType, - &i.ResourceID, - &i.ResourceTarget, - &i.Action, - &i.Diff, - &i.StatusCode, - &i.AdditionalFields, - &i.RequestID, - &i.ResourceIcon, + &i.AuditLog.ID, + &i.AuditLog.Time, + &i.AuditLog.UserID, + &i.AuditLog.OrganizationID, + &i.AuditLog.Ip, + &i.AuditLog.UserAgent, + &i.AuditLog.ResourceType, + &i.AuditLog.ResourceID, + &i.AuditLog.ResourceTarget, + &i.AuditLog.Action, + &i.AuditLog.Diff, + &i.AuditLog.StatusCode, + &i.AuditLog.AdditionalFields, + &i.AuditLog.RequestID, + &i.AuditLog.ResourceIcon, &i.UserUsername, &i.UserName, &i.UserEmail, diff --git a/coderd/database/queries/auditlogs.sql b/coderd/database/queries/auditlogs.sql index 4b209c00c9f5b..115bdcd4c8f6f 100644 --- a/coderd/database/queries/auditlogs.sql +++ b/coderd/database/queries/auditlogs.sql @@ -2,7 +2,7 @@ -- ID. -- name: GetAuditLogsOffset :many SELECT - audit_logs.*, + sqlc.embed(audit_logs), -- sqlc.embed(users) would be nice but it does not seem to play well with -- left joins. users.username AS user_username, From 4c9902a5e27b6e390e26a4847a3ce38840959211 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 31 Jul 2024 13:26:17 -0500 Subject: [PATCH 3/8] fix sql query matcher --- coderd/database/dbauthz/dbauthz.go | 32 ++++++++++++++++++------------ 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 1e0e3da9f0a56..19a886c1d8d17 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1248,22 +1248,28 @@ func (q *querier) GetApplicationName(ctx context.Context) (string, error) { } func (q *querier) GetAuditLogsOffset(ctx context.Context, arg database.GetAuditLogsOffsetParams) ([]database.GetAuditLogsOffsetRow, error) { - // To optimize the authz checks for audit logs, do not run an authorize - // check on each individual audit log row. In practice, audit logs are either - // fetched from a global or an organization scope. - // Applying a SQL filter would slow down the query for no benefit on how this query is - // actually used. - - object := rbac.ResourceAuditLog - if arg.OrganizationID != uuid.Nil { - object = object.InOrg(arg.OrganizationID) + //// To optimize the authz checks for audit logs, do not run an authorize + //// check on each individual audit log row. In practice, audit logs are either + //// fetched from a global or an organization scope. + //// Applying a SQL filter would slow down the query for no benefit on how this query is + //// actually used. + // + //object := rbac.ResourceAuditLog + //if arg.OrganizationID != uuid.Nil { + // object = object.InOrg(arg.OrganizationID) + //} + // + //if err := q.authorizeContext(ctx, policy.ActionRead, object); err != nil { + // return nil, err + //} + + prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAuditLog.Type) + if err != nil { + return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) } - if err := q.authorizeContext(ctx, policy.ActionRead, object); err != nil { - return nil, err - } - return q.db.GetAuditLogsOffset(ctx, arg) + return q.db.GetAuthorizedAuditLogsOffset(ctx, arg, prep) } func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (database.GetAuthorizationUserRolesRow, error) { From dee0ca851cb4f167288e8079d348765ba8c5d1c8 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 31 Jul 2024 13:53:03 -0500 Subject: [PATCH 4/8] Add unit testS --- coderd/database/dbauthz/dbauthz.go | 16 ---- coderd/database/dbgen/dbgen.go | 9 +- coderd/database/querier_test.go | 143 +++++++++++++++++++++++++++++ coderd/rbac/regosql/configs.go | 2 +- 4 files changed, 149 insertions(+), 21 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 19a886c1d8d17..b7cff64e2a57b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1248,27 +1248,11 @@ func (q *querier) GetApplicationName(ctx context.Context) (string, error) { } func (q *querier) GetAuditLogsOffset(ctx context.Context, arg database.GetAuditLogsOffsetParams) ([]database.GetAuditLogsOffsetRow, error) { - //// To optimize the authz checks for audit logs, do not run an authorize - //// check on each individual audit log row. In practice, audit logs are either - //// fetched from a global or an organization scope. - //// Applying a SQL filter would slow down the query for no benefit on how this query is - //// actually used. - // - //object := rbac.ResourceAuditLog - //if arg.OrganizationID != uuid.Nil { - // object = object.InOrg(arg.OrganizationID) - //} - // - //if err := q.authorizeContext(ctx, policy.ActionRead, object); err != nil { - // return nil, err - //} - prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAuditLog.Type) if err != nil { return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) } - return q.db.GetAuthorizedAuditLogsOffset(ctx, arg, prep) } diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index d2e41ddccd841..a6ca57662e28d 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -40,10 +40,11 @@ var genCtx = dbauthz.As(context.Background(), rbac.Subject{ func AuditLog(t testing.TB, db database.Store, seed database.AuditLog) database.AuditLog { log, err := db.InsertAuditLog(genCtx, database.InsertAuditLogParams{ - ID: takeFirst(seed.ID, uuid.New()), - Time: takeFirst(seed.Time, dbtime.Now()), - UserID: takeFirst(seed.UserID, uuid.New()), - OrganizationID: takeFirst(seed.OrganizationID, uuid.New()), + ID: takeFirst(seed.ID, uuid.New()), + Time: takeFirst(seed.Time, dbtime.Now()), + UserID: takeFirst(seed.UserID, uuid.New()), + // Default to the nil uuid. So by default audit logs are not org scoped. + OrganizationID: takeFirst(seed.OrganizationID), Ip: pqtype.Inet{ IPNet: takeFirstIP(seed.Ip.IPNet, net.IPNet{}), Valid: takeFirst(seed.Ip.Valid, false), diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 544f7e55ed2c5..1a710db2a1353 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -12,13 +12,19 @@ import ( "time" "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/migrations" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/testutil" ) @@ -767,6 +773,143 @@ func TestReadCustomRoles(t *testing.T) { } } +func TestAuthorizedAuditLogs(t *testing.T) { + t.Parallel() + + var allLogs []database.AuditLog + db, _ := dbtestutil.NewDB(t) + authz := rbac.NewAuthorizer(prometheus.NewRegistry()) + db = dbauthz.New(db, authz, slogtest.Make(t, &slogtest.Options{}), coderdtest.AccessControlStorePointer()) + + siteWideIDs := []uuid.UUID{uuid.New(), uuid.New()} + for _, id := range siteWideIDs { + allLogs = append(allLogs, dbgen.AuditLog(t, db, database.AuditLog{ + ID: id, + OrganizationID: uuid.Nil, + })) + + } + + orgAuditLogs := map[uuid.UUID][]uuid.UUID{ + uuid.New(): {uuid.New(), uuid.New()}, + uuid.New(): {uuid.New(), uuid.New()}, + } + orgIDs := make([]uuid.UUID, 0, len(orgAuditLogs)) + for orgID := range orgAuditLogs { + orgIDs = append(orgIDs, orgID) + } + for orgID, ids := range orgAuditLogs { + dbgen.Organization(t, db, database.Organization{ + ID: orgID, + }) + for _, id := range ids { + allLogs = append(allLogs, dbgen.AuditLog(t, db, database.AuditLog{ + ID: id, + OrganizationID: orgID, + })) + } + } + + // Now fetch all the logs + ctx := testutil.Context(t, testutil.WaitLong) + auditorRole, err := rbac.RoleByName(rbac.RoleAuditor()) + require.NoError(t, err) + + memberRole, err := rbac.RoleByName(rbac.RoleMember()) + require.NoError(t, err) + + orgAuditorRoles := func(t *testing.T, orgID uuid.UUID) rbac.Role { + t.Helper() + + role, err := rbac.RoleByName(rbac.ScopedRoleOrgAuditor(orgID)) + require.NoError(t, err) + return role + } + + t.Run("NoAccess", func(t *testing.T) { + siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ + FriendlyName: "member", + ID: uuid.NewString(), + Roles: rbac.Roles{memberRole}, + Scope: rbac.ScopeAll, + }) + + logs, err := db.GetAuditLogsOffset(siteAuditorCtx, database.GetAuditLogsOffsetParams{}) + require.NoError(t, err) + require.Len(t, logs, 0, "no logs should be returned") + }) + + t.Run("SiteWideAuditor", func(t *testing.T) { + siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ + FriendlyName: "owner", + ID: uuid.NewString(), + Roles: rbac.Roles{auditorRole}, + Scope: rbac.ScopeAll, + }) + + logs, err := db.GetAuditLogsOffset(siteAuditorCtx, database.GetAuditLogsOffsetParams{}) + require.NoError(t, err) + require.ElementsMatch(t, auditOnlyIDs(allLogs), auditOnlyIDs(logs)) + }) + + t.Run("SingleOrgAuditor", func(t *testing.T) { + orgID := orgIDs[0] + siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ + FriendlyName: "org-auditor", + ID: uuid.NewString(), + Roles: rbac.Roles{orgAuditorRoles(t, orgID)}, + Scope: rbac.ScopeAll, + }) + + logs, err := db.GetAuditLogsOffset(siteAuditorCtx, database.GetAuditLogsOffsetParams{}) + require.NoError(t, err) + require.ElementsMatch(t, orgAuditLogs[orgID], auditOnlyIDs(logs)) + }) + + t.Run("TwoOrgAuditors", func(t *testing.T) { + first := orgIDs[0] + second := orgIDs[1] + siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ + FriendlyName: "org-auditor", + ID: uuid.NewString(), + Roles: rbac.Roles{orgAuditorRoles(t, first), orgAuditorRoles(t, second)}, + Scope: rbac.ScopeAll, + }) + + logs, err := db.GetAuditLogsOffset(siteAuditorCtx, database.GetAuditLogsOffsetParams{}) + require.NoError(t, err) + require.ElementsMatch(t, append(orgAuditLogs[first], orgAuditLogs[second]...), auditOnlyIDs(logs)) + }) + + t.Run("ErroneousOrg", func(t *testing.T) { + siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ + FriendlyName: "org-auditor", + ID: uuid.NewString(), + Roles: rbac.Roles{orgAuditorRoles(t, uuid.New())}, + Scope: rbac.ScopeAll, + }) + + logs, err := db.GetAuditLogsOffset(siteAuditorCtx, database.GetAuditLogsOffsetParams{}) + require.NoError(t, err) + require.Len(t, logs, 0, "no logs should be returned") + }) +} + +func auditOnlyIDs[T database.AuditLog | database.GetAuditLogsOffsetRow](logs []T) []uuid.UUID { + ids := make([]uuid.UUID, 0, len(logs)) + for _, log := range logs { + switch log := any(log).(type) { + case database.AuditLog: + ids = append(ids, log.ID) + case database.GetAuditLogsOffsetRow: + ids = append(ids, log.AuditLog.ID) + default: + panic("unreachable") + } + } + return ids +} + type tvArgs struct { Status database.ProvisionerJobStatus // CreateWorkspace is true if we should create a workspace for the template version diff --git a/coderd/rbac/regosql/configs.go b/coderd/rbac/regosql/configs.go index 9e331d6945026..fc170cc9f92c7 100644 --- a/coderd/rbac/regosql/configs.go +++ b/coderd/rbac/regosql/configs.go @@ -39,7 +39,7 @@ func TemplateConverter() *sqltypes.VariableConverter { func AuditLogConverter() *sqltypes.VariableConverter { matcher := sqltypes.NewVariableConverter().RegisterMatcher( resourceIDMatcher(), - organizationOwnerMatcher(), + sqltypes.StringVarMatcher("COALESCE(audit_logs.organization_id :: text, '')", []string{"input", "object", "org_owner"}), // Templates have no user owner, only owner by an organization. sqltypes.AlwaysFalse(userOwnerMatcher()), ) From 38280892a4852521302e280046375121a672cf1b Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 31 Jul 2024 16:17:20 -0500 Subject: [PATCH 5/8] linting --- coderd/database/dbauthz/dbauthz_test.go | 9 ++++++++- coderd/database/querier_test.go | 11 ++++++++++- enterprise/audit/backends/postgres_test.go | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 0ec7d2b17fb9c..876c0d797f64a 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -266,7 +266,14 @@ func (s *MethodTestSuite) TestAuditLogs() { _ = dbgen.AuditLog(s.T(), db, database.AuditLog{}) check.Args(database.GetAuditLogsOffsetParams{ LimitOpt: 10, - }).Asserts(rbac.ResourceAuditLog, policy.ActionRead) + }).Asserts() + })) + s.Run("GetAuthorizedAuditLogsOffset", s.Subtest(func(db database.Store, check *expects) { + _ = dbgen.AuditLog(s.T(), db, database.AuditLog{}) + _ = dbgen.AuditLog(s.T(), db, database.AuditLog{}) + check.Args(database.GetAuditLogsOffsetParams{ + LimitOpt: 10, + }, emptyPreparedAuthorized{}).Asserts() })) } diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 1a710db2a1353..ea53d6be87bec 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -787,7 +787,6 @@ func TestAuthorizedAuditLogs(t *testing.T) { ID: id, OrganizationID: uuid.Nil, })) - } orgAuditLogs := map[uuid.UUID][]uuid.UUID{ @@ -827,6 +826,8 @@ func TestAuthorizedAuditLogs(t *testing.T) { } t.Run("NoAccess", func(t *testing.T) { + t.Parallel() + siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ FriendlyName: "member", ID: uuid.NewString(), @@ -840,6 +841,8 @@ func TestAuthorizedAuditLogs(t *testing.T) { }) t.Run("SiteWideAuditor", func(t *testing.T) { + t.Parallel() + siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ FriendlyName: "owner", ID: uuid.NewString(), @@ -853,6 +856,8 @@ func TestAuthorizedAuditLogs(t *testing.T) { }) t.Run("SingleOrgAuditor", func(t *testing.T) { + t.Parallel() + orgID := orgIDs[0] siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ FriendlyName: "org-auditor", @@ -867,6 +872,8 @@ func TestAuthorizedAuditLogs(t *testing.T) { }) t.Run("TwoOrgAuditors", func(t *testing.T) { + t.Parallel() + first := orgIDs[0] second := orgIDs[1] siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ @@ -882,6 +889,8 @@ func TestAuthorizedAuditLogs(t *testing.T) { }) t.Run("ErroneousOrg", func(t *testing.T) { + t.Parallel() + siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ FriendlyName: "org-auditor", ID: uuid.NewString(), diff --git a/enterprise/audit/backends/postgres_test.go b/enterprise/audit/backends/postgres_test.go index 6621b6f175ca9..d9a517ca62eaf 100644 --- a/enterprise/audit/backends/postgres_test.go +++ b/enterprise/audit/backends/postgres_test.go @@ -35,6 +35,6 @@ func TestPostgresBackend(t *testing.T) { }) require.NoError(t, err) require.Len(t, got, 1) - require.Equal(t, alog.ID, got[0].ID) + require.Equal(t, alog.ID, got[0].AuditLog.ID) }) } From f45156c062bfb16a7371407c3fedc4515e87957c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 31 Jul 2024 16:26:42 -0500 Subject: [PATCH 6/8] chore: fixup unit tests about audit logs --- coderd/audit_test.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/coderd/audit_test.go b/coderd/audit_test.go index 80b8ff911db6b..96820ec3f3997 100644 --- a/coderd/audit_test.go +++ b/coderd/audit_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "net/http" "strconv" "testing" "time" @@ -163,19 +162,18 @@ func TestAuditLogs(t *testing.T) { }) require.NoError(t, err) - // Fetching audit logs without an organization selector should fail - _, err = orgAdmin.AuditLogs(ctx, codersdk.AuditLogsRequest{ + // Fetching audit logs without an organization selector should only + // return organization audit logs. + alogs, err := orgAdmin.AuditLogs(ctx, codersdk.AuditLogsRequest{ Pagination: codersdk.Pagination{ Limit: 5, }, }) - var sdkError *codersdk.Error - require.Error(t, err) - require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") - require.Equal(t, http.StatusForbidden, sdkError.StatusCode()) + require.NoError(t, err) + require.Len(t, alogs.AuditLogs, 1) // Using the organization selector allows the org admin to fetch audit logs - alogs, err := orgAdmin.AuditLogs(ctx, codersdk.AuditLogsRequest{ + alogs, err = orgAdmin.AuditLogs(ctx, codersdk.AuditLogsRequest{ SearchQuery: fmt.Sprintf("organization:%s", owner.OrganizationID.String()), Pagination: codersdk.Pagination{ Limit: 5, From e10c54598f584aaf6f34074eb9cbebf88b3abba3 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 1 Aug 2024 08:24:47 -0500 Subject: [PATCH 7/8] PR comments --- coderd/database/querier_test.go | 34 +++++++++++++++++++++++++-------- coderd/rbac/regosql/configs.go | 3 +-- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index ea53d6be87bec..54225859b3fb9 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -789,6 +789,9 @@ func TestAuthorizedAuditLogs(t *testing.T) { })) } + // This map is a simple way to insert a given number of organizations + // and audit logs for each organization. + // map[orgID][]AuditLogID orgAuditLogs := map[uuid.UUID][]uuid.UUID{ uuid.New(): {uuid.New(), uuid.New()}, uuid.New(): {uuid.New(), uuid.New()}, @@ -828,21 +831,25 @@ func TestAuthorizedAuditLogs(t *testing.T) { t.Run("NoAccess", func(t *testing.T) { t.Parallel() - siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ + // Given: A user who is a member of 0 organizations + memberCtx := dbauthz.As(ctx, rbac.Subject{ FriendlyName: "member", ID: uuid.NewString(), Roles: rbac.Roles{memberRole}, Scope: rbac.ScopeAll, }) - logs, err := db.GetAuditLogsOffset(siteAuditorCtx, database.GetAuditLogsOffsetParams{}) + // When: The user queries for audit logs + logs, err := db.GetAuditLogsOffset(memberCtx, database.GetAuditLogsOffsetParams{}) require.NoError(t, err) + // Then: No logs returned require.Len(t, logs, 0, "no logs should be returned") }) t.Run("SiteWideAuditor", func(t *testing.T) { t.Parallel() + // Given: A site wide auditor siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ FriendlyName: "owner", ID: uuid.NewString(), @@ -850,8 +857,10 @@ func TestAuthorizedAuditLogs(t *testing.T) { Scope: rbac.ScopeAll, }) + // When: the auditor queries for audit logs logs, err := db.GetAuditLogsOffset(siteAuditorCtx, database.GetAuditLogsOffsetParams{}) require.NoError(t, err) + // Then: All logs are returned require.ElementsMatch(t, auditOnlyIDs(allLogs), auditOnlyIDs(logs)) }) @@ -859,15 +868,18 @@ func TestAuthorizedAuditLogs(t *testing.T) { t.Parallel() orgID := orgIDs[0] - siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ + // Given: An organization scoped auditor + orgAuditCtx := dbauthz.As(ctx, rbac.Subject{ FriendlyName: "org-auditor", ID: uuid.NewString(), Roles: rbac.Roles{orgAuditorRoles(t, orgID)}, Scope: rbac.ScopeAll, }) - logs, err := db.GetAuditLogsOffset(siteAuditorCtx, database.GetAuditLogsOffsetParams{}) + // When: The auditor queries for audit logs + logs, err := db.GetAuditLogsOffset(orgAuditCtx, database.GetAuditLogsOffsetParams{}) require.NoError(t, err) + // Then: Only the logs for the organization are returned require.ElementsMatch(t, orgAuditLogs[orgID], auditOnlyIDs(logs)) }) @@ -876,30 +888,36 @@ func TestAuthorizedAuditLogs(t *testing.T) { first := orgIDs[0] second := orgIDs[1] - siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ + // Given: A user who is an auditor for two organizations + multiOrgAuditCtx := dbauthz.As(ctx, rbac.Subject{ FriendlyName: "org-auditor", ID: uuid.NewString(), Roles: rbac.Roles{orgAuditorRoles(t, first), orgAuditorRoles(t, second)}, Scope: rbac.ScopeAll, }) - logs, err := db.GetAuditLogsOffset(siteAuditorCtx, database.GetAuditLogsOffsetParams{}) + // When: The user queries for audit logs + logs, err := db.GetAuditLogsOffset(multiOrgAuditCtx, database.GetAuditLogsOffsetParams{}) require.NoError(t, err) + // Then: All logs for both organizations are returned require.ElementsMatch(t, append(orgAuditLogs[first], orgAuditLogs[second]...), auditOnlyIDs(logs)) }) t.Run("ErroneousOrg", func(t *testing.T) { t.Parallel() - siteAuditorCtx := dbauthz.As(ctx, rbac.Subject{ + // Given: A user who is an auditor for an organization that has 0 logs + userCtx := dbauthz.As(ctx, rbac.Subject{ FriendlyName: "org-auditor", ID: uuid.NewString(), Roles: rbac.Roles{orgAuditorRoles(t, uuid.New())}, Scope: rbac.ScopeAll, }) - logs, err := db.GetAuditLogsOffset(siteAuditorCtx, database.GetAuditLogsOffsetParams{}) + // When: The user queries for audit logs + logs, err := db.GetAuditLogsOffset(userCtx, database.GetAuditLogsOffsetParams{}) require.NoError(t, err) + // Then: No logs are returned require.Len(t, logs, 0, "no logs should be returned") }) } diff --git a/coderd/rbac/regosql/configs.go b/coderd/rbac/regosql/configs.go index fc170cc9f92c7..4ccd1cb3bbaef 100644 --- a/coderd/rbac/regosql/configs.go +++ b/coderd/rbac/regosql/configs.go @@ -40,11 +40,10 @@ func AuditLogConverter() *sqltypes.VariableConverter { matcher := sqltypes.NewVariableConverter().RegisterMatcher( resourceIDMatcher(), sqltypes.StringVarMatcher("COALESCE(audit_logs.organization_id :: text, '')", []string{"input", "object", "org_owner"}), - // Templates have no user owner, only owner by an organization. + // Aduit logs have no user owner, only owner by an organization. sqltypes.AlwaysFalse(userOwnerMatcher()), ) matcher.RegisterMatcher( - // No ACLs on the user type sqltypes.AlwaysFalse(groupACLMatcher(matcher)), sqltypes.AlwaysFalse(userACLMatcher(matcher)), ) From 2709d4b2397b0be411515d0afbfe8a85d3daecd8 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 1 Aug 2024 08:31:42 -0500 Subject: [PATCH 8/8] update comment --- coderd/audit_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/audit_test.go b/coderd/audit_test.go index 96820ec3f3997..922e2b359b506 100644 --- a/coderd/audit_test.go +++ b/coderd/audit_test.go @@ -163,7 +163,7 @@ func TestAuditLogs(t *testing.T) { require.NoError(t, err) // Fetching audit logs without an organization selector should only - // return organization audit logs. + // return organization audit logs the org admin is an admin of. alogs, err := orgAdmin.AuditLogs(ctx, codersdk.AuditLogsRequest{ Pagination: codersdk.Pagination{ Limit: 5,