mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
This change adds support for workspace app auditing. To avoid audit log spam, we introduce the concept of app audit sessions. An audit session is unique per workspace app, user, ip, user agent and http status code. The sessions are stored in a separate table from audit logs to allow use-case specific optimizations. Sessions are ephemeral and the table does not function as a log. The logic for auditing is placed in the DBTokenProvider for workspace apps so that wsproxies are included. This is the final change affecting the API fo #15139. Updates #15139
489 lines
15 KiB
Go
489 lines
15 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"
|
|
"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, 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
|
|
}
|
|
filter.OffsetOpt = int32(page.Offset)
|
|
filter.LimitOpt = int32(page.Limit)
|
|
|
|
if filter.Username == "me" {
|
|
filter.UserID = apiKey.UserID
|
|
filter.Username = ""
|
|
}
|
|
|
|
dblogs, err := api.Database.GetAuditLogsOffset(ctx, filter)
|
|
if dbauthz.IsNotAuthorizedError(err) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
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]
|
|
// @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
|
|
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.OwnerUsername, 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.OwnerUsername, 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)
|
|
|
|
default:
|
|
return ""
|
|
}
|
|
}
|