chore: add sql filter to fetching audit logs (#14070)

* chore: add sql filter to fetching audit logs
* use sqlc.embed for audit logs
* fix sql query matcher
This commit is contained in:
Steven Masley
2024-08-01 12:07:19 -05:00
committed by GitHub
parent d23670ad53
commit a27ac30e11
16 changed files with 562 additions and 245 deletions
+43 -43
View File
@@ -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 ""
}
+25 -15
View File
@@ -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",
},
+6 -8
View File
@@ -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,
+8 -14
View File
@@ -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)
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) {
@@ -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)
}
+8 -1
View File
@@ -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()
}))
}
+5 -4
View File
@@ -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),
+118 -111
View File
@@ -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
}
+7
View File
@@ -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
}
+15
View File
@@ -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()
+14
View File
@@ -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:
+88
View File
@@ -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")
+170
View File
@@ -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
+36 -47
View File
@@ -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,
+4 -1
View File
@@ -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
+14
View File
@@ -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(),
+1 -1
View File
@@ -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)
})
}