Files
coder/coderd/audit/request.go
Sas Swart 01163ea57b feat: allow users to pause prebuilt workspace reconciliation (#18700)
This PR provides two commands:
* `coder prebuilds pause`
* `coder prebuilds resume`

These allow the suspension of all prebuilds activity, intended for use
if prebuilds are misbehaving.
2025-07-02 15:05:42 +00:00

600 lines
17 KiB
Go

package audit
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"net"
"net/http"
"strconv"
"time"
"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/idpsync"
"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 interface{}
}
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
}
// UpdateOrganizationID can be used if the organization ID is not known
// at the initiation of an audit log request.
func (r *Request[T]) UpdateOrganizationID(id uuid.UUID) {
r.params.OrganizationID = id
}
type BackgroundAuditParams[T Auditable] struct {
Audit Auditor
Log slog.Logger
UserID uuid.UUID
RequestID uuid.UUID
Time time.Time
Status int
Action database.AuditAction
OrganizationID uuid.UUID
IP string
UserAgent string
// todo: this should automatically marshal an interface{} instead of accepting a raw message.
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.WorkspaceTable:
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.NotificationsSettings:
return "" // no target?
case database.PrebuildsSettings:
return "" // no target?
case database.OAuth2ProviderApp:
return typed.Name
case database.OAuth2ProviderAppSecret:
return typed.DisplaySecret
case database.CustomRole:
return typed.Name
case database.AuditableOrganizationMember:
return typed.Username
case database.Organization:
return typed.Name
case database.NotificationTemplate:
return typed.Name
case idpsync.OrganizationSyncSettings:
return "Organization Sync"
case idpsync.GroupSyncSettings:
return "Organization Group Sync"
case idpsync.RoleSyncSettings:
return "Organization Role Sync"
case database.WorkspaceAgent:
return typed.Name
case database.WorkspaceApp:
return typed.Slug
default:
panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt))
}
}
// noID can be used for resources that do not have an uuid.
// An example is singleton configuration resources.
// 51A51C = "Static"
var noID = uuid.MustParse("51A51C00-0000-0000-0000-000000000000")
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.WorkspaceTable:
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.NotificationsSettings:
// Artificial ID for auditing purposes
return typed.ID
case database.PrebuildsSettings:
// Artificial ID for auditing purposes
return typed.ID
case database.OAuth2ProviderApp:
return typed.ID
case database.OAuth2ProviderAppSecret:
return typed.ID
case database.CustomRole:
return typed.ID
case database.AuditableOrganizationMember:
return typed.UserID
case database.Organization:
return typed.ID
case database.NotificationTemplate:
return typed.ID
case idpsync.OrganizationSyncSettings:
return noID // Deployment all uses the same org sync settings
case idpsync.GroupSyncSettings:
return noID // Org field on audit log has org id
case idpsync.RoleSyncSettings:
return noID // Org field on audit log has org id
case database.WorkspaceAgent:
return typed.ID
case database.WorkspaceApp:
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.WorkspaceTable:
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.NotificationsSettings:
return database.ResourceTypeNotificationsSettings
case database.PrebuildsSettings:
return database.ResourceTypePrebuildsSettings
case database.OAuth2ProviderApp:
return database.ResourceTypeOauth2ProviderApp
case database.OAuth2ProviderAppSecret:
return database.ResourceTypeOauth2ProviderAppSecret
case database.CustomRole:
return database.ResourceTypeCustomRole
case database.AuditableOrganizationMember:
return database.ResourceTypeOrganizationMember
case database.Organization:
return database.ResourceTypeOrganization
case database.NotificationTemplate:
return database.ResourceTypeNotificationTemplate
case idpsync.OrganizationSyncSettings:
return database.ResourceTypeIdpSyncSettingsOrganization
case idpsync.RoleSyncSettings:
return database.ResourceTypeIdpSyncSettingsRole
case idpsync.GroupSyncSettings:
return database.ResourceTypeIdpSyncSettingsGroup
case database.WorkspaceAgent:
return database.ResourceTypeWorkspaceAgent
case database.WorkspaceApp:
return database.ResourceTypeWorkspaceApp
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.WorkspaceTable, 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.NotificationsSettings:
// Artificial ID for auditing purposes
return false
case database.PrebuildsSettings:
// Artificial ID for auditing purposes
return false
case database.OAuth2ProviderApp:
return false
case database.OAuth2ProviderAppSecret:
return false
case database.CustomRole:
return true
case database.AuditableOrganizationMember:
return true
case database.Organization:
return true
case database.NotificationTemplate:
return false
case idpsync.OrganizationSyncSettings:
return false
case idpsync.GroupSyncSettings:
return true
case idpsync.RoleSyncSettings:
return true
case database.WorkspaceAgent:
return true
case database.WorkspaceApp:
return true
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
}
// InitRequestWithCancel returns a commit function with a boolean arg.
// If the arg is false, future calls to commit() will not create an audit log
// entry.
func InitRequestWithCancel[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request[T], func(commit bool)) {
req, commitF := InitRequest[T](w, p)
canceled := false
return req, func(commit bool) {
// Once 'commit=false' is called, block
// any future commit attempts.
if !commit {
canceled = true
return
}
// If it was ever canceled, block any commits
if !canceled {
commitF()
}
}
}
// 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("{}")
}
}
additionalFieldsRaw := json.RawMessage("{}")
if p.AdditionalFields != nil {
data, err := json.Marshal(p.AdditionalFields)
if err != nil {
p.Log.Warn(logCtx, "marshal additional fields", slog.Error(err))
} else {
additionalFieldsRaw = json.RawMessage(data)
}
}
var userID uuid.UUID
key, ok := httpmw.APIKeyOptional(p.Request)
switch {
case ok:
userID = key.UserID
case req.UserID != uuid.Nil:
userID = req.UserID
default:
// 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,
// #nosec G115 - Safe conversion as HTTP status code is expected to be within int32 range (typically 100-599)
StatusCode: int32(sw.Status),
RequestID: httpmw.RequestID(p.Request),
AdditionalFields: additionalFieldsRaw,
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.Time.IsZero() {
p.Time = dbtime.Now()
} else {
// NOTE(mafredri): dbtime.Time does not currently enforce UTC.
p.Time = dbtime.Time(p.Time.In(time.UTC))
}
if p.AdditionalFields == nil {
p.AdditionalFields = json.RawMessage("{}")
}
auditLog := database.AuditLog{
ID: uuid.New(),
Time: p.Time,
UserID: p.UserID,
OrganizationID: requireOrgID[T](ctx, p.OrganizationID, p.Log),
Ip: ip,
UserAgent: sql.NullString{Valid: p.UserAgent != "", String: p.UserAgent},
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,
// #nosec G115 - Safe conversion as HTTP status code is expected to be within int32 range (typically 100-599)
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, newVal T, fn func(T) R, auditAction database.AuditAction) R {
switch {
case ResourceID(newVal) != uuid.Nil:
return fn(newVal)
case ResourceID(old) != uuid.Nil:
return fn(old)
case 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)
default:
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,
}
}