mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
+43
-43
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user