mirror of https://github.com/coder/coder.git
504 lines
15 KiB
Go
504 lines
15 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/tabbed/pqtype"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog"
|
|
"github.com/coder/coder/coderd/database"
|
|
"github.com/coder/coder/coderd/httpapi"
|
|
"github.com/coder/coder/coderd/httpmw"
|
|
"github.com/coder/coder/coderd/rbac"
|
|
"github.com/coder/coder/codersdk"
|
|
)
|
|
|
|
// @Summary Get audit logs
|
|
// @ID get-audit-logs
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Audit
|
|
// @Param q query string true "Search query"
|
|
// @Param after_id query string false "After ID" format(uuid)
|
|
// @Param limit query int false "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()
|
|
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceAuditLog) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
|
|
page, ok := parsePagination(rw, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
queryStr := r.URL.Query().Get("q")
|
|
filter, errs := auditSearchQuery(queryStr)
|
|
if len(errs) > 0 {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid audit search query.",
|
|
Validations: errs,
|
|
})
|
|
return
|
|
}
|
|
|
|
dblogs, err := api.Database.GetAuditLogsOffset(ctx, database.GetAuditLogsOffsetParams{
|
|
Offset: int32(page.Offset),
|
|
Limit: int32(page.Limit),
|
|
ResourceType: filter.ResourceType,
|
|
ResourceID: filter.ResourceID,
|
|
Action: filter.Action,
|
|
Username: filter.Username,
|
|
Email: filter.Email,
|
|
DateFrom: filter.DateFrom,
|
|
DateTo: filter.DateTo,
|
|
BuildReason: filter.BuildReason,
|
|
})
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
// GetAuditLogsOffset does not return ErrNoRows because it uses a window function to get the count.
|
|
// So we need to check if the dblogs is empty and return an empty array if so.
|
|
if len(dblogs) == 0 {
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AuditLogResponse{
|
|
AuditLogs: []codersdk.AuditLog{},
|
|
Count: 0,
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AuditLogResponse{
|
|
AuditLogs: api.convertAuditLogs(ctx, dblogs),
|
|
Count: dblogs[0].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]
|
|
func (api *API) generateFakeAuditLog(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceAuditLog) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
_, 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: []byte("{}"),
|
|
})
|
|
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
|
|
}
|
|
|
|
type AdditionalFields struct {
|
|
WorkspaceName string `json:"workspace_name"`
|
|
BuildNumber string `json:"build_number"`
|
|
BuildReason database.BuildReason `json:"build_reason"`
|
|
}
|
|
|
|
func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogsOffsetRow) codersdk.AuditLog {
|
|
ip, _ := netip.AddrFromSlice(dblog.Ip.IPNet.IP)
|
|
|
|
diff := codersdk.AuditDiff{}
|
|
_ = json.Unmarshal(dblog.Diff, &diff)
|
|
|
|
var user *codersdk.User
|
|
|
|
if dblog.UserUsername.Valid {
|
|
user = &codersdk.User{
|
|
ID: dblog.UserID,
|
|
Username: dblog.UserUsername.String,
|
|
Email: dblog.UserEmail.String,
|
|
CreatedAt: dblog.UserCreatedAt.Time,
|
|
Status: codersdk.UserStatus(dblog.UserStatus.UserStatus),
|
|
Roles: []codersdk.Role{},
|
|
AvatarURL: dblog.UserAvatarUrl.String,
|
|
}
|
|
|
|
for _, roleName := range dblog.UserRoles {
|
|
rbacRole, _ := rbac.RoleByName(roleName)
|
|
user.Roles = append(user.Roles, convertRole(rbacRole))
|
|
}
|
|
}
|
|
|
|
var (
|
|
additionalFieldsBytes = []byte(dblog.AdditionalFields)
|
|
additionalFields AdditionalFields
|
|
err = json.Unmarshal(additionalFieldsBytes, &additionalFields)
|
|
)
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "unmarshal additional fields", slog.Error(err))
|
|
resourceInfo := map[string]string{
|
|
"workspaceName": "unknown",
|
|
"buildNumber": "unknown",
|
|
"buildReason": "unknown",
|
|
}
|
|
dblog.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)
|
|
}
|
|
|
|
return codersdk.AuditLog{
|
|
ID: dblog.ID,
|
|
RequestID: dblog.RequestID,
|
|
Time: dblog.Time,
|
|
OrganizationID: dblog.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),
|
|
Diff: diff,
|
|
StatusCode: dblog.StatusCode,
|
|
AdditionalFields: dblog.AdditionalFields,
|
|
User: user,
|
|
Description: auditLogDescription(dblog, additionalFields),
|
|
ResourceLink: resourceLink,
|
|
IsDeleted: isDeleted,
|
|
}
|
|
}
|
|
|
|
func auditLogDescription(alog database.GetAuditLogsOffsetRow, additionalFields AdditionalFields) string {
|
|
str := fmt.Sprintf("{user} %s",
|
|
codersdk.AuditAction(alog.Action).FriendlyString(),
|
|
)
|
|
|
|
// Strings for starting/stopping workspace builds follow the below format:
|
|
// "{user | 'Coder automatically'} started build #{build_number} for workspace {target}"
|
|
// where target is a workspace (name) instead of a workspace build
|
|
// passed in on the FE via AuditLog.AdditionalFields rather than derived in request.go:35
|
|
if alog.ResourceType == database.ResourceTypeWorkspaceBuild && alog.Action != database.AuditActionDelete {
|
|
if len(additionalFields.BuildNumber) == 0 {
|
|
str += " build for"
|
|
} else {
|
|
str += fmt.Sprintf(" build #%s for",
|
|
additionalFields.BuildNumber)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
str += fmt.Sprintf(" the %s",
|
|
codersdk.ResourceType(alog.ResourceType).FriendlyString())
|
|
return str
|
|
}
|
|
|
|
str += fmt.Sprintf(" %s",
|
|
codersdk.ResourceType(alog.ResourceType).FriendlyString())
|
|
|
|
str += " {target}"
|
|
|
|
return str
|
|
}
|
|
|
|
func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.GetAuditLogsOffsetRow) bool {
|
|
switch alog.ResourceType {
|
|
case database.ResourceTypeTemplate:
|
|
template, err := api.Database.GetTemplateByID(ctx, alog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "fetch template", slog.Error(err))
|
|
}
|
|
return template.Deleted
|
|
case database.ResourceTypeUser:
|
|
user, err := api.Database.GetUserByID(ctx, alog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "fetch user", slog.Error(err))
|
|
}
|
|
return user.Deleted
|
|
case database.ResourceTypeWorkspace:
|
|
workspace, err := api.Database.GetWorkspaceByID(ctx, alog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "fetch workspace", slog.Error(err))
|
|
}
|
|
return workspace.Deleted
|
|
case database.ResourceTypeWorkspaceBuild:
|
|
workspaceBuild, err := api.Database.GetWorkspaceBuildByID(ctx, alog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "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, "fetch workspace", slog.Error(err))
|
|
}
|
|
return workspace.Deleted
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAuditLogsOffsetRow, additionalFields AdditionalFields) string {
|
|
switch alog.ResourceType {
|
|
case database.ResourceTypeTemplate:
|
|
return fmt.Sprintf("/templates/%s",
|
|
alog.ResourceTarget)
|
|
case database.ResourceTypeUser:
|
|
return fmt.Sprintf("/users?filter=%s",
|
|
alog.ResourceTarget)
|
|
case database.ResourceTypeWorkspace:
|
|
workspace, getWorkspaceErr := api.Database.GetWorkspaceByID(ctx, alog.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.ResourceTarget)
|
|
case database.ResourceTypeWorkspaceBuild:
|
|
if len(additionalFields.WorkspaceName) == 0 || len(additionalFields.BuildNumber) == 0 {
|
|
return ""
|
|
}
|
|
workspaceBuild, getWorkspaceBuildErr := api.Database.GetWorkspaceBuildByID(ctx, alog.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)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// auditSearchQuery takes a query string and returns the auditLog filter.
|
|
// It also can return the list of validation errors to return to the api.
|
|
func auditSearchQuery(query string) (database.GetAuditLogsOffsetParams, []codersdk.ValidationError) {
|
|
searchParams := make(url.Values)
|
|
if query == "" {
|
|
// No filter
|
|
return database.GetAuditLogsOffsetParams{}, nil
|
|
}
|
|
query = strings.ToLower(query)
|
|
// Because we do this in 2 passes, we want to maintain quotes on the first
|
|
// pass.Further splitting occurs on the second pass and quotes will be
|
|
// dropped.
|
|
elements := splitQueryParameterByDelimiter(query, ' ', true)
|
|
for _, element := range elements {
|
|
parts := splitQueryParameterByDelimiter(element, ':', false)
|
|
switch len(parts) {
|
|
case 1:
|
|
// No key:value pair.
|
|
searchParams.Set("resource_type", parts[0])
|
|
case 2:
|
|
searchParams.Set(parts[0], parts[1])
|
|
default:
|
|
return database.GetAuditLogsOffsetParams{}, []codersdk.ValidationError{
|
|
{Field: "q", Detail: fmt.Sprintf("Query element %q can only contain 1 ':'", element)},
|
|
}
|
|
}
|
|
}
|
|
|
|
// Using the query param parser here just returns consistent errors with
|
|
// other parsing.
|
|
parser := httpapi.NewQueryParamParser()
|
|
const layout = "2006-01-02"
|
|
|
|
var (
|
|
dateFromString = parser.String(searchParams, "", "date_from")
|
|
dateToString = parser.String(searchParams, "", "date_to")
|
|
parsedDateFrom, _ = time.Parse(layout, dateFromString)
|
|
parsedDateTo, _ = time.Parse(layout, dateToString)
|
|
)
|
|
|
|
if dateToString != "" {
|
|
parsedDateTo = parsedDateTo.Add(23*time.Hour + 59*time.Minute + 59*time.Second) // parsedDateTo goes to 23:59
|
|
}
|
|
|
|
if dateToString != "" && parsedDateTo.Before(parsedDateFrom) {
|
|
return database.GetAuditLogsOffsetParams{}, []codersdk.ValidationError{
|
|
{Field: "q", Detail: fmt.Sprintf("DateTo value %q cannot be before than DateFrom", parsedDateTo)},
|
|
}
|
|
}
|
|
|
|
filter := database.GetAuditLogsOffsetParams{
|
|
ResourceType: resourceTypeFromString(parser.String(searchParams, "", "resource_type")),
|
|
ResourceID: parser.UUID(searchParams, uuid.Nil, "resource_id"),
|
|
Action: actionFromString(parser.String(searchParams, "", "action")),
|
|
Username: parser.String(searchParams, "", "username"),
|
|
Email: parser.String(searchParams, "", "email"),
|
|
DateFrom: parsedDateFrom,
|
|
DateTo: parsedDateTo,
|
|
BuildReason: buildReasonFromString(parser.String(searchParams, "", "build_reason")),
|
|
}
|
|
|
|
return filter, parser.Errors
|
|
}
|
|
|
|
func resourceTypeFromString(resourceTypeString string) string {
|
|
switch codersdk.ResourceType(resourceTypeString) {
|
|
case codersdk.ResourceTypeTemplate:
|
|
return resourceTypeString
|
|
case codersdk.ResourceTypeTemplateVersion:
|
|
return resourceTypeString
|
|
case codersdk.ResourceTypeUser:
|
|
return resourceTypeString
|
|
case codersdk.ResourceTypeWorkspace:
|
|
return resourceTypeString
|
|
case codersdk.ResourceTypeWorkspaceBuild:
|
|
return resourceTypeString
|
|
case codersdk.ResourceTypeGitSSHKey:
|
|
return resourceTypeString
|
|
case codersdk.ResourceTypeAPIKey:
|
|
return resourceTypeString
|
|
case codersdk.ResourceTypeGroup:
|
|
return resourceTypeString
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func actionFromString(actionString string) string {
|
|
switch codersdk.AuditAction(actionString) {
|
|
case codersdk.AuditActionCreate:
|
|
return actionString
|
|
case codersdk.AuditActionWrite:
|
|
return actionString
|
|
case codersdk.AuditActionDelete:
|
|
return actionString
|
|
case codersdk.AuditActionStart:
|
|
return actionString
|
|
case codersdk.AuditActionStop:
|
|
return actionString
|
|
default:
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func buildReasonFromString(buildReasonString string) string {
|
|
switch codersdk.BuildReason(buildReasonString) {
|
|
case codersdk.BuildReasonInitiator:
|
|
return buildReasonString
|
|
case codersdk.BuildReasonAutostart:
|
|
return buildReasonString
|
|
case codersdk.BuildReasonAutostop:
|
|
return buildReasonString
|
|
default:
|
|
}
|
|
return ""
|
|
}
|