mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08: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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user