mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
bddb808b25
Fixes all our Go file imports to match the preferred spec that we've _mostly_ been using. For example: ``` import ( "context" "time" "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" "gopkg.in/natefinch/lumberjack.v2" "cdr.dev/slog/v3" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/serpent" ) ``` 3 groups: standard library, 3rd partly libs, Coder libs. This PR makes the change across the codebase. The PR in the stack above modifies our formatting to maintain this state of affairs, and is a separate PR so it's possible to review that one in detail.
522 lines
16 KiB
Go
522 lines
16 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/sqlc-dev/pqtype"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"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/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/searchquery"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// @Summary Get audit logs
|
|
// @ID get-audit-logs
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Audit
|
|
// @Param q query string false "Search query"
|
|
// @Param limit query int true "Page limit"
|
|
// @Param offset query int false "Page offset"
|
|
// @Success 200 {object} codersdk.AuditLogResponse
|
|
// @Router /audit [get]
|
|
func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
apiKey := httpmw.APIKey(r)
|
|
|
|
page, ok := ParsePagination(rw, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
queryStr := r.URL.Query().Get("q")
|
|
filter, countFilter, errs := searchquery.AuditLogs(ctx, api.Database, queryStr)
|
|
if len(errs) > 0 {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid audit search query.",
|
|
Validations: errs,
|
|
})
|
|
return
|
|
}
|
|
// #nosec G115 - Safe conversion as pagination offset is expected to be within int32 range
|
|
filter.OffsetOpt = int32(page.Offset)
|
|
// #nosec G115 - Safe conversion as pagination limit is expected to be within int32 range
|
|
filter.LimitOpt = int32(page.Limit)
|
|
|
|
if filter.Username == "me" {
|
|
filter.UserID = apiKey.UserID
|
|
filter.Username = ""
|
|
countFilter.UserID = apiKey.UserID
|
|
countFilter.Username = ""
|
|
}
|
|
|
|
// Use the same filters to count the number of audit logs
|
|
count, err := api.Database.CountAuditLogs(ctx, countFilter)
|
|
if dbauthz.IsNotAuthorizedError(err) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
// If count is 0, then we don't need to query audit logs
|
|
if count == 0 {
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AuditLogResponse{
|
|
AuditLogs: []codersdk.AuditLog{},
|
|
Count: 0,
|
|
})
|
|
return
|
|
}
|
|
|
|
dblogs, err := api.Database.GetAuditLogsOffset(ctx, filter)
|
|
if dbauthz.IsNotAuthorizedError(err) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AuditLogResponse{
|
|
AuditLogs: api.convertAuditLogs(ctx, dblogs),
|
|
Count: count,
|
|
})
|
|
}
|
|
|
|
// @Summary Generate fake audit log
|
|
// @ID generate-fake-audit-log
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Tags Audit
|
|
// @Param request body codersdk.CreateTestAuditLogRequest true "Audit log request"
|
|
// @Success 204
|
|
// @Router /audit/testgenerate [post]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) generateFakeAuditLog(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
key := httpmw.APIKey(r)
|
|
user, err := api.Database.GetUserByID(ctx, key.UserID)
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
diff, err := json.Marshal(codersdk.AuditDiff{
|
|
"foo": codersdk.AuditDiffField{Old: "bar", New: "baz"},
|
|
})
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
ip := net.ParseIP(r.RemoteAddr)
|
|
ipNet := pqtype.Inet{}
|
|
if ip != nil {
|
|
ipNet = pqtype.Inet{
|
|
IPNet: net.IPNet{
|
|
IP: ip,
|
|
Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
|
|
},
|
|
Valid: true,
|
|
}
|
|
}
|
|
|
|
var params codersdk.CreateTestAuditLogRequest
|
|
if !httpapi.Read(ctx, rw, r, ¶ms) {
|
|
return
|
|
}
|
|
if params.Action == "" {
|
|
params.Action = codersdk.AuditActionWrite
|
|
}
|
|
if params.ResourceType == "" {
|
|
params.ResourceType = codersdk.ResourceTypeUser
|
|
}
|
|
if params.ResourceID == uuid.Nil {
|
|
params.ResourceID = uuid.New()
|
|
}
|
|
if params.Time.IsZero() {
|
|
params.Time = time.Now()
|
|
}
|
|
if len(params.AdditionalFields) == 0 {
|
|
params.AdditionalFields = json.RawMessage("{}")
|
|
}
|
|
|
|
_, err = api.Database.InsertAuditLog(ctx, database.InsertAuditLogParams{
|
|
ID: uuid.New(),
|
|
Time: params.Time,
|
|
UserID: user.ID,
|
|
Ip: ipNet,
|
|
UserAgent: sql.NullString{String: r.UserAgent(), Valid: true},
|
|
ResourceType: database.ResourceType(params.ResourceType),
|
|
ResourceID: params.ResourceID,
|
|
ResourceTarget: user.Username,
|
|
Action: database.AuditAction(params.Action),
|
|
Diff: diff,
|
|
StatusCode: http.StatusOK,
|
|
AdditionalFields: params.AdditionalFields,
|
|
RequestID: params.RequestID,
|
|
ResourceIcon: "",
|
|
OrganizationID: params.OrganizationID,
|
|
})
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (api *API) convertAuditLogs(ctx context.Context, dblogs []database.GetAuditLogsOffsetRow) []codersdk.AuditLog {
|
|
alogs := make([]codersdk.AuditLog, 0, len(dblogs))
|
|
|
|
for _, dblog := range dblogs {
|
|
alogs = append(alogs, api.convertAuditLog(ctx, dblog))
|
|
}
|
|
|
|
return alogs
|
|
}
|
|
|
|
func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogsOffsetRow) codersdk.AuditLog {
|
|
ip, _ := netip.AddrFromSlice(dblog.AuditLog.Ip.IPNet.IP)
|
|
|
|
diff := codersdk.AuditDiff{}
|
|
_ = 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.AuditLog.UserID,
|
|
Email: dblog.UserEmail.String,
|
|
Username: dblog.UserUsername.String,
|
|
CreatedAt: dblog.UserCreatedAt.Time,
|
|
UpdatedAt: dblog.UserUpdatedAt.Time,
|
|
Status: dblog.UserStatus.UserStatus,
|
|
RBACRoles: dblog.UserRoles,
|
|
LoginType: dblog.UserLoginType.LoginType,
|
|
AvatarURL: dblog.UserAvatarUrl.String,
|
|
Deleted: dblog.UserDeleted.Bool,
|
|
LastSeenAt: dblog.UserLastSeenAt.Time,
|
|
QuietHoursSchedule: dblog.UserQuietHoursSchedule.String,
|
|
Name: dblog.UserName.String,
|
|
}, []uuid.UUID{})
|
|
user = &sdkUser
|
|
}
|
|
|
|
var (
|
|
additionalFieldsBytes = []byte(dblog.AuditLog.AdditionalFields)
|
|
additionalFields audit.AdditionalFields
|
|
err = json.Unmarshal(additionalFieldsBytes, &additionalFields)
|
|
)
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "unmarshal additional fields", slog.Error(err))
|
|
resourceInfo := audit.AdditionalFields{
|
|
WorkspaceName: "unknown",
|
|
BuildNumber: "unknown",
|
|
BuildReason: "unknown",
|
|
WorkspaceOwner: "unknown",
|
|
}
|
|
|
|
dblog.AuditLog.AdditionalFields, err = json.Marshal(resourceInfo)
|
|
api.Logger.Error(ctx, "marshal additional fields", slog.Error(err))
|
|
}
|
|
|
|
var (
|
|
isDeleted = api.auditLogIsResourceDeleted(ctx, dblog)
|
|
resourceLink string
|
|
)
|
|
if isDeleted {
|
|
resourceLink = ""
|
|
} else {
|
|
resourceLink = api.auditLogResourceLink(ctx, dblog, additionalFields)
|
|
}
|
|
|
|
alog := codersdk.AuditLog{
|
|
ID: dblog.AuditLog.ID,
|
|
RequestID: dblog.AuditLog.RequestID,
|
|
Time: dblog.AuditLog.Time,
|
|
// OrganizationID is deprecated.
|
|
OrganizationID: dblog.AuditLog.OrganizationID,
|
|
IP: ip,
|
|
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.AuditLog.StatusCode,
|
|
AdditionalFields: dblog.AuditLog.AdditionalFields,
|
|
User: user,
|
|
Description: auditLogDescription(dblog),
|
|
ResourceLink: resourceLink,
|
|
IsDeleted: isDeleted,
|
|
}
|
|
|
|
if dblog.AuditLog.OrganizationID != uuid.Nil {
|
|
alog.Organization = &codersdk.MinimalOrganization{
|
|
ID: dblog.AuditLog.OrganizationID,
|
|
Name: dblog.OrganizationName,
|
|
DisplayName: dblog.OrganizationDisplayName,
|
|
Icon: dblog.OrganizationIcon,
|
|
}
|
|
}
|
|
|
|
return alog
|
|
}
|
|
|
|
func auditLogDescription(alog database.GetAuditLogsOffsetRow) string {
|
|
b := strings.Builder{}
|
|
|
|
// NOTE: WriteString always returns a nil error, so we never check it
|
|
|
|
// Requesting a password reset can be performed by anyone that knows the email
|
|
// of a user so saying the user performed this action might be slightly misleading.
|
|
if alog.AuditLog.Action != database.AuditActionRequestPasswordReset {
|
|
_, _ = b.WriteString("{user} ")
|
|
}
|
|
|
|
switch {
|
|
case alog.AuditLog.StatusCode == int32(http.StatusSeeOther):
|
|
_, _ = b.WriteString("was redirected attempting to ")
|
|
_, _ = b.WriteString(string(alog.AuditLog.Action))
|
|
case alog.AuditLog.StatusCode >= 400:
|
|
_, _ = b.WriteString("unsuccessfully attempted to ")
|
|
_, _ = b.WriteString(string(alog.AuditLog.Action))
|
|
default:
|
|
_, _ = 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.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.AuditLog.ResourceType == database.ResourceTypeGitSshKey {
|
|
_, _ = b.WriteString(" the ")
|
|
_, _ = b.WriteString(codersdk.ResourceType(alog.AuditLog.ResourceType).FriendlyString())
|
|
return b.String()
|
|
}
|
|
|
|
if alog.AuditLog.Action == database.AuditActionRequestPasswordReset {
|
|
_, _ = b.WriteString(" for")
|
|
} else {
|
|
_, _ = b.WriteString(" ")
|
|
_, _ = b.WriteString(codersdk.ResourceType(alog.AuditLog.ResourceType).FriendlyString())
|
|
}
|
|
|
|
if alog.AuditLog.ResourceType == database.ResourceTypeConvertLogin {
|
|
_, _ = b.WriteString(" to")
|
|
}
|
|
|
|
_, _ = b.WriteString(" {target}")
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.GetAuditLogsOffsetRow) bool {
|
|
switch alog.AuditLog.ResourceType {
|
|
case database.ResourceTypeTemplate:
|
|
template, err := api.Database.GetTemplateByID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "unable to fetch template", slog.Error(err))
|
|
}
|
|
return template.Deleted
|
|
case database.ResourceTypeUser:
|
|
user, err := api.Database.GetUserByID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "unable to fetch user", slog.Error(err))
|
|
}
|
|
return user.Deleted
|
|
case database.ResourceTypeWorkspace:
|
|
workspace, err := api.Database.GetWorkspaceByID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "unable to fetch workspace", slog.Error(err))
|
|
}
|
|
return workspace.Deleted
|
|
case database.ResourceTypeWorkspaceBuild:
|
|
workspaceBuild, err := api.Database.GetWorkspaceBuildByID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "unable to fetch workspace build", slog.Error(err))
|
|
}
|
|
// We use workspace as a proxy for workspace build here
|
|
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "unable to fetch workspace", slog.Error(err))
|
|
}
|
|
return workspace.Deleted
|
|
case database.ResourceTypeWorkspaceAgent:
|
|
// We use workspace as a proxy for workspace agents.
|
|
workspace, err := api.Database.GetWorkspaceByAgentID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "unable to fetch workspace", slog.Error(err))
|
|
}
|
|
return workspace.Deleted
|
|
case database.ResourceTypeWorkspaceApp:
|
|
// We use workspace as a proxy for workspace apps.
|
|
workspace, err := api.Database.GetWorkspaceByWorkspaceAppID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "unable to fetch workspace", slog.Error(err))
|
|
}
|
|
return workspace.Deleted
|
|
case database.ResourceTypeOauth2ProviderApp:
|
|
_, err := api.Database.GetOAuth2ProviderAppByID(ctx, alog.AuditLog.ResourceID)
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
} else if err != nil {
|
|
api.Logger.Error(ctx, "unable to fetch oauth2 app", slog.Error(err))
|
|
}
|
|
return false
|
|
case database.ResourceTypeOauth2ProviderAppSecret:
|
|
_, err := api.Database.GetOAuth2ProviderAppSecretByID(ctx, alog.AuditLog.ResourceID)
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
} else if err != nil {
|
|
api.Logger.Error(ctx, "unable to fetch oauth2 app secret", slog.Error(err))
|
|
}
|
|
return false
|
|
case database.ResourceTypeTask:
|
|
task, err := api.Database.GetTaskByID(ctx, alog.AuditLog.ResourceID)
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
} else if err != nil {
|
|
api.Logger.Error(ctx, "unable to fetch task", slog.Error(err))
|
|
}
|
|
return task.DeletedAt.Valid && task.DeletedAt.Time.Before(time.Now())
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAuditLogsOffsetRow, additionalFields audit.AdditionalFields) string {
|
|
switch alog.AuditLog.ResourceType {
|
|
case database.ResourceTypeTemplate:
|
|
return fmt.Sprintf("/templates/%s",
|
|
alog.AuditLog.ResourceTarget)
|
|
|
|
case database.ResourceTypeUser:
|
|
return fmt.Sprintf("/users?filter=%s",
|
|
alog.AuditLog.ResourceTarget)
|
|
|
|
case database.ResourceTypeWorkspace:
|
|
workspace, getWorkspaceErr := api.Database.GetWorkspaceByID(ctx, alog.AuditLog.ResourceID)
|
|
if getWorkspaceErr != nil {
|
|
return ""
|
|
}
|
|
workspaceOwner, getWorkspaceOwnerErr := api.Database.GetUserByID(ctx, workspace.OwnerID)
|
|
if getWorkspaceOwnerErr != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/@%s/%s",
|
|
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.AuditLog.ResourceID)
|
|
if getWorkspaceBuildErr != nil {
|
|
return ""
|
|
}
|
|
workspace, getWorkspaceErr := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
|
|
if getWorkspaceErr != nil {
|
|
return ""
|
|
}
|
|
workspaceOwner, getWorkspaceOwnerErr := api.Database.GetUserByID(ctx, workspace.OwnerID)
|
|
if getWorkspaceOwnerErr != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/@%s/%s/builds/%s",
|
|
workspaceOwner.Username, additionalFields.WorkspaceName, additionalFields.BuildNumber)
|
|
|
|
case database.ResourceTypeWorkspaceAgent:
|
|
if additionalFields.WorkspaceOwner != "" && additionalFields.WorkspaceName != "" {
|
|
return fmt.Sprintf("/@%s/%s", additionalFields.WorkspaceOwner, additionalFields.WorkspaceName)
|
|
}
|
|
workspace, getWorkspaceErr := api.Database.GetWorkspaceByAgentID(ctx, alog.AuditLog.ResourceID)
|
|
if getWorkspaceErr != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/@%s/%s", workspace.OwnerName, workspace.Name)
|
|
|
|
case database.ResourceTypeWorkspaceApp:
|
|
if additionalFields.WorkspaceOwner != "" && additionalFields.WorkspaceName != "" {
|
|
return fmt.Sprintf("/@%s/%s", additionalFields.WorkspaceOwner, additionalFields.WorkspaceName)
|
|
}
|
|
workspace, getWorkspaceErr := api.Database.GetWorkspaceByWorkspaceAppID(ctx, alog.AuditLog.ResourceID)
|
|
if getWorkspaceErr != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/@%s/%s", workspace.OwnerName, workspace.Name)
|
|
|
|
case database.ResourceTypeOauth2ProviderApp:
|
|
return fmt.Sprintf("/deployment/oauth2-provider/apps/%s", alog.AuditLog.ResourceID)
|
|
|
|
case database.ResourceTypeOauth2ProviderAppSecret:
|
|
secret, err := api.Database.GetOAuth2ProviderAppSecretByID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/deployment/oauth2-provider/apps/%s", secret.AppID)
|
|
|
|
case database.ResourceTypeTask:
|
|
task, err := api.Database.GetTaskByID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
user, err := api.Database.GetUserByID(ctx, task.OwnerID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/tasks/%s/%s", user.Username, task.ID)
|
|
|
|
default:
|
|
return ""
|
|
}
|
|
}
|