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/audit_test.go b/coderd/audit_test.go index 80b8ff911db6b..922e2b359b506 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 the org admin is an admin of. + 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, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d12b9aba23863..b7cff64e2a57b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1248,22 +1248,12 @@ 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.GetAuditLogsOffset(ctx, arg) + return q.db.GetAuthorizedAuditLogsOffset(ctx, arg, prep) } func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (database.GetAuthorizationUserRolesRow, error) { @@ -3852,3 +3842,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, _ rbac.PreparedAuthorized) ([]database.GetAuditLogsOffsetRow, error) { + return q.GetAuditLogsOffset(ctx, arg) +} 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/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/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 8d1088616f6bc..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) { @@ -10080,3 +9971,119 @@ 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) { + 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/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/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 7ee4f4f676eea..78826ea7cc8b5 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.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, + &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/querier_test.go b/coderd/database/querier_test.go index 544f7e55ed2c5..54225859b3fb9 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,170 @@ 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, + })) + } + + // 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()}, + } + 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) { + t.Parallel() + + // 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, + }) + + // 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(), + Roles: rbac.Roles{auditorRole}, + 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)) + }) + + t.Run("SingleOrgAuditor", func(t *testing.T) { + t.Parallel() + + orgID := orgIDs[0] + // 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, + }) + + // 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)) + }) + + t.Run("TwoOrgAuditors", func(t *testing.T) { + t.Parallel() + + first := orgIDs[0] + second := orgIDs[1] + // 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, + }) + + // 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() + + // 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, + }) + + // 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") + }) +} + +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/database/queries.sql.go b/coderd/database/queries.sql.go index 2e3a5c9892d40..904f304bd25a9 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 @@ -586,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 @@ -646,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 d8ef38a82120e..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, @@ -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..4ccd1cb3bbaef 100644 --- a/coderd/rbac/regosql/configs.go +++ b/coderd/rbac/regosql/configs.go @@ -36,6 +36,20 @@ func TemplateConverter() *sqltypes.VariableConverter { return matcher } +func AuditLogConverter() *sqltypes.VariableConverter { + matcher := sqltypes.NewVariableConverter().RegisterMatcher( + resourceIDMatcher(), + sqltypes.StringVarMatcher("COALESCE(audit_logs.organization_id :: text, '')", []string{"input", "object", "org_owner"}), + // Aduit logs have no user owner, only owner by an organization. + sqltypes.AlwaysFalse(userOwnerMatcher()), + ) + matcher.RegisterMatcher( + sqltypes.AlwaysFalse(groupACLMatcher(matcher)), + sqltypes.AlwaysFalse(userACLMatcher(matcher)), + ) + return matcher +} + func UserConverter() *sqltypes.VariableConverter { matcher := sqltypes.NewVariableConverter().RegisterMatcher( resourceIDMatcher(), 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) }) }
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: