package audit import ( "context" "database/sql" "encoding/json" "flag" "fmt" "net" "net/http" "strconv" "github.com/google/uuid" "github.com/sqlc-dev/pqtype" "go.opentelemetry.io/otel/baggage" "golang.org/x/xerrors" "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/tracing" ) type RequestParams struct { Audit Auditor Log slog.Logger // OrganizationID is only provided when possible. If an audit resource extends // beyond the org scope, leave this as the nil uuid. OrganizationID uuid.UUID Request *http.Request Action database.AuditAction AdditionalFields json.RawMessage } type Request[T Auditable] struct { params *RequestParams Old T New T // UserID is an optional field can be passed in when the userID cannot be // determined from the API Key such as in the case of login, when the audit // log is created prior the API Key's existence. UserID uuid.UUID // Action is an optional field can be passed in if the AuditAction must be // overridden such as in the case of new user authentication when the Audit // Action is 'register', not 'login'. Action database.AuditAction } type BackgroundAuditParams[T Auditable] struct { Audit Auditor Log slog.Logger UserID uuid.UUID RequestID uuid.UUID Status int Action database.AuditAction OrganizationID uuid.UUID IP string AdditionalFields json.RawMessage New T Old T } func ResourceTarget[T Auditable](tgt T) string { switch typed := any(tgt).(type) { case database.Template: return typed.Name case database.TemplateVersion: return typed.Name case database.User: return typed.Username case database.Workspace: return typed.Name case database.WorkspaceBuild: // this isn't used return "" case database.GitSSHKey: return typed.PublicKey case database.AuditableGroup: return typed.Group.Name case database.APIKey: if typed.TokenName != "nil" { return typed.TokenName } // API Keys without names are used for auth // and don't have a target return "" case database.License: return strconv.Itoa(int(typed.ID)) case database.WorkspaceProxy: return typed.Name case database.AuditOAuthConvertState: return string(typed.ToLoginType) case database.HealthSettings: return "" // no target? case database.OAuth2ProviderApp: return typed.Name case database.OAuth2ProviderAppSecret: return typed.DisplaySecret default: panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt)) } } func ResourceID[T Auditable](tgt T) uuid.UUID { switch typed := any(tgt).(type) { case database.Template: return typed.ID case database.TemplateVersion: return typed.ID case database.User: return typed.ID case database.Workspace: return typed.ID case database.WorkspaceBuild: return typed.ID case database.GitSSHKey: return typed.UserID case database.AuditableGroup: return typed.Group.ID case database.APIKey: return typed.UserID case database.License: return typed.UUID case database.WorkspaceProxy: return typed.ID case database.AuditOAuthConvertState: // The merge state is for the given user return typed.UserID case database.HealthSettings: // Artificial ID for auditing purposes return typed.ID case database.OAuth2ProviderApp: return typed.ID case database.OAuth2ProviderAppSecret: return typed.ID default: panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt)) } } func ResourceType[T Auditable](tgt T) database.ResourceType { switch typed := any(tgt).(type) { case database.Template: return database.ResourceTypeTemplate case database.TemplateVersion: return database.ResourceTypeTemplateVersion case database.User: return database.ResourceTypeUser case database.Workspace: return database.ResourceTypeWorkspace case database.WorkspaceBuild: return database.ResourceTypeWorkspaceBuild case database.GitSSHKey: return database.ResourceTypeGitSshKey case database.AuditableGroup: return database.ResourceTypeGroup case database.APIKey: return database.ResourceTypeApiKey case database.License: return database.ResourceTypeLicense case database.WorkspaceProxy: return database.ResourceTypeWorkspaceProxy case database.AuditOAuthConvertState: return database.ResourceTypeConvertLogin case database.HealthSettings: return database.ResourceTypeHealthSettings case database.OAuth2ProviderApp: return database.ResourceTypeOauth2ProviderApp case database.OAuth2ProviderAppSecret: return database.ResourceTypeOauth2ProviderAppSecret default: panic(fmt.Sprintf("unknown resource %T for ResourceType", typed)) } } // ResourceRequiresOrgID will ensure given resources are always audited with an // organization ID. func ResourceRequiresOrgID[T Auditable]() bool { var tgt T switch any(tgt).(type) { case database.Template, database.TemplateVersion: return true case database.Workspace, database.WorkspaceBuild: return true case database.AuditableGroup: return true case database.User: return false case database.GitSSHKey: return false case database.APIKey: return false case database.License: return false case database.WorkspaceProxy: return false case database.AuditOAuthConvertState: // The merge state is for the given user return false case database.HealthSettings: // Artificial ID for auditing purposes return false case database.OAuth2ProviderApp: return false case database.OAuth2ProviderAppSecret: return false default: panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt)) } } // requireOrgID will either panic (in unit tests) or log an error (in production) // if the given resource requires an organization ID and the provided ID is nil. func requireOrgID[T Auditable](ctx context.Context, id uuid.UUID, log slog.Logger) uuid.UUID { if ResourceRequiresOrgID[T]() && id == uuid.Nil { var tgt T resourceName := fmt.Sprintf("%T", tgt) if flag.Lookup("test.v") != nil { // In unit tests we panic to fail the tests panic(fmt.Sprintf("missing required organization ID for resource %q", resourceName)) } log.Error(ctx, "missing required organization ID for resource in audit log", slog.F("resource", resourceName), ) } return id } // InitRequest initializes an audit log for a request. It returns a function // that should be deferred, causing the audit log to be committed when the // handler returns. func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request[T], func()) { sw, ok := w.(*tracing.StatusWriter) if !ok { panic("dev error: http.ResponseWriter is not *tracing.StatusWriter") } req := &Request[T]{ params: p, } return req, func() { ctx := context.Background() logCtx := p.Request.Context() // If no resources were provided, there's nothing we can audit. if ResourceID(req.Old) == uuid.Nil && ResourceID(req.New) == uuid.Nil { // If the request action is a login or logout, we always want to audit it even if // there is no diff. This is so we can capture events where an API Key is never created // because a known user fails to login. if req.params.Action != database.AuditActionLogin && req.params.Action != database.AuditActionLogout { return } } diffRaw := []byte("{}") // Only generate diffs if the request succeeded // and only if we aren't auditing authentication actions if sw.Status < 400 && req.params.Action != database.AuditActionLogin && req.params.Action != database.AuditActionLogout { diff := Diff(p.Audit, req.Old, req.New) var err error diffRaw, err = json.Marshal(diff) if err != nil { p.Log.Warn(logCtx, "marshal diff", slog.Error(err)) diffRaw = []byte("{}") } } if p.AdditionalFields == nil { p.AdditionalFields = json.RawMessage("{}") } var userID uuid.UUID key, ok := httpmw.APIKeyOptional(p.Request) if ok { userID = key.UserID } else if req.UserID != uuid.Nil { userID = req.UserID } else { // if we do not have a user associated with the audit action // we do not want to audit // (this pertains to logins; we don't want to capture non-user login attempts) return } action := p.Action if req.Action != "" { action = req.Action } ip := parseIP(p.Request.RemoteAddr) auditLog := database.AuditLog{ ID: uuid.New(), Time: dbtime.Now(), UserID: userID, Ip: ip, UserAgent: sql.NullString{String: p.Request.UserAgent(), Valid: true}, ResourceType: either(req.Old, req.New, ResourceType[T], req.params.Action), ResourceID: either(req.Old, req.New, ResourceID[T], req.params.Action), ResourceTarget: either(req.Old, req.New, ResourceTarget[T], req.params.Action), Action: action, Diff: diffRaw, StatusCode: int32(sw.Status), RequestID: httpmw.RequestID(p.Request), AdditionalFields: p.AdditionalFields, OrganizationID: requireOrgID[T](logCtx, p.OrganizationID, p.Log), } err := p.Audit.Export(ctx, auditLog) if err != nil { p.Log.Error(logCtx, "export audit log", slog.F("audit_log", auditLog), slog.Error(err), ) return } } } // BackgroundAudit creates an audit log for a background event. // The audit log is committed upon invocation. func BackgroundAudit[T Auditable](ctx context.Context, p *BackgroundAuditParams[T]) { ip := parseIP(p.IP) diff := Diff(p.Audit, p.Old, p.New) var err error diffRaw, err := json.Marshal(diff) if err != nil { p.Log.Warn(ctx, "marshal diff", slog.Error(err)) diffRaw = []byte("{}") } if p.AdditionalFields == nil { p.AdditionalFields = json.RawMessage("{}") } auditLog := database.AuditLog{ ID: uuid.New(), Time: dbtime.Now(), UserID: p.UserID, OrganizationID: requireOrgID[T](ctx, p.OrganizationID, p.Log), Ip: ip, UserAgent: sql.NullString{}, ResourceType: either(p.Old, p.New, ResourceType[T], p.Action), ResourceID: either(p.Old, p.New, ResourceID[T], p.Action), ResourceTarget: either(p.Old, p.New, ResourceTarget[T], p.Action), Action: p.Action, Diff: diffRaw, StatusCode: int32(p.Status), RequestID: p.RequestID, AdditionalFields: p.AdditionalFields, } err = p.Audit.Export(ctx, auditLog) if err != nil { p.Log.Error(ctx, "export audit log", slog.F("audit_log", auditLog), slog.Error(err), ) } } type WorkspaceBuildBaggage struct { IP string } func (b WorkspaceBuildBaggage) Props() ([]baggage.Property, error) { ipProp, err := baggage.NewKeyValueProperty("ip", b.IP) if err != nil { return nil, xerrors.Errorf("create ip kv property: %w", err) } return []baggage.Property{ipProp}, nil } func WorkspaceBuildBaggageFromRequest(r *http.Request) WorkspaceBuildBaggage { return WorkspaceBuildBaggage{IP: r.RemoteAddr} } type Baggage interface { Props() ([]baggage.Property, error) } func BaggageToContext(ctx context.Context, d Baggage) (context.Context, error) { props, err := d.Props() if err != nil { return ctx, xerrors.Errorf("create baggage properties: %w", err) } m, err := baggage.NewMember("audit", "baggage", props...) if err != nil { return ctx, xerrors.Errorf("create new baggage member: %w", err) } b, err := baggage.New(m) if err != nil { return ctx, xerrors.Errorf("create new baggage carrier: %w", err) } return baggage.ContextWithBaggage(ctx, b), nil } func BaggageFromContext(ctx context.Context) WorkspaceBuildBaggage { d := WorkspaceBuildBaggage{} b := baggage.FromContext(ctx) props := b.Member("audit").Properties() for _, prop := range props { switch prop.Key() { case "ip": d.IP, _ = prop.Value() default: } } return d } func either[T Auditable, R any](old, new T, fn func(T) R, auditAction database.AuditAction) R { if ResourceID(new) != uuid.Nil { return fn(new) } else if ResourceID(old) != uuid.Nil { return fn(old) } else if auditAction == database.AuditActionLogin || auditAction == database.AuditActionLogout { // If the request action is a login or logout, we always want to audit it even if // there is no diff. See the comment in audit.InitRequest for more detail. return fn(old) } panic("both old and new are nil") } func parseIP(ipStr string) pqtype.Inet { ip := net.ParseIP(ipStr) ipNet := net.IPNet{} if ip != nil { ipNet = net.IPNet{ IP: ip, Mask: net.CIDRMask(len(ip)*8, len(ip)*8), } } return pqtype.Inet{ IPNet: ipNet, Valid: ip != nil, } }