Ctrl K
ComunidadGuías…

coderd/audit/request.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package audit

import (
	"cmp"
	"context"
	"database/sql"
	"encoding/json"
	"flag"
	"fmt"
	"net/http"
	"strconv"
	"time"

	"github.com/google/uuid"
	"go.opentelemetry.io/otel/baggage"
	"golang.org/x/xerrors"

	"cdr.dev/slog/v3"
	"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.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.OAuth2ProviderSettings:
		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.AISeatState:
		return "AI Seat"
	case database.AIProvider:
		return typed.Name
	case database.AIProviderKey:
		return typed.ID.String()
	case database.AIGatewayKey:
		return typed.Name
	case database.AuditableGroupAIBudget:
		return typed.GroupName
	case database.AuditableUserAIBudgetOverride:
		return typed.Username
	case database.Chat:
		// Chat titles can contain sensitive content (secrets, internal
		// project names), so we use a short UUID prefix as a display
		// hint instead. The full UUID is still recorded in resource_id,
		// which is what the audit UI links on. An 8-char prefix is fine
		// for display; collisions affect the display label and search
		// filter but not the primary resource identifier.
		return typed.ID.String()[:8]
	case database.ChatProject:
		return typed.Name
	case database.ChatProjectMemory:
		return typed.Name
	case database.AuditableChatProjectInstructions:
		return typed.ProjectName
	case database.ChatModelConfig:
		return cmp.Or(typed.DisplayName, typed.ID.String())
	case database.MCPServerConfig:
		// Updates can persist an empty display name; fall back to the slug, or
		// the ID if both are empty, so the audit entry stays identifiable.
		return cmp.Or(typed.DisplayName, typed.Slug, typed.ID.String())
	case database.UserSecret:
		return typed.Name
	case database.Skill:
		return typed.Name
	case database.AuditableOrganizationSkill:
		return typed.Name
	case database.AuditableChatProjectSkill:
		return typed.Name
	case database.ChatInstructionSettings:
		return typed.Name
	case database.ChatOperationalSettings:
		return ""
	case database.ChatOrganizationSystemPrompt:
		return ""
	case database.ExperimentRule:
		return typed.Experiment
	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")

// Fixed IDs for the two chat instruction settings. History-by-setting works
// only if every change to one setting carries the same resource ID, so
// unlike the per-write artificial IDs of the other settings singletons,
// these never change. C1A7 = "Chat".
var (
	ChatInstructionSystemPromptID = uuid.MustParse("C1A715C0-0000-0000-0000-000000000001")
	ChatInstructionPlanModeID     = uuid.MustParse("C1A715C0-0000-0000-0000-000000000002")
)

// Human-readable targets for the two chat instruction settings, so an audit
// row names the setting it concerns.
const (
	ChatInstructionSystemPromptName = "System prompt"
	ChatInstructionPlanModeName     = "Plan mode instructions"
)

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.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.OAuth2ProviderSettings:
		// 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.AISeatState:
		return typed.UserID
	case database.AIProvider:
		return typed.ID
	case database.AIProviderKey:
		return typed.ID
	case database.AIGatewayKey:
		return typed.ID
	case database.AuditableGroupAIBudget:
		return typed.GroupID
	case database.AuditableUserAIBudgetOverride:
		return typed.UserID
	case database.Chat:
		return typed.ID
	case database.ChatProject:
		return typed.ID
	case database.ChatProjectMemory:
		return typed.ID
	case database.AuditableChatProjectInstructions:
		// One row per project, keyed by the project.
		return typed.ProjectID
	case database.ChatModelConfig:
		return typed.ID
	case database.MCPServerConfig:
		return typed.ID
	case database.UserSecret:
		return typed.ID
	case database.Skill:
		return typed.ID
	case database.AuditableOrganizationSkill:
		return typed.ID
	case database.AuditableChatProjectSkill:
		return typed.ID
	case database.ChatInstructionSettings:
		// Fixed ID per setting; see ChatInstructionSettings IDs.
		return typed.ID
	case database.ChatOperationalSettings:
		return typed.ID
	case database.ChatOrganizationSystemPrompt:
		return typed.OrganizationID
	case database.ExperimentRule:
		// Derived from the experiment name by experiments.AuditRecord.
		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.OAuth2ProviderSettings:
		return database.ResourceTypeOauth2ProviderSettings
	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.AISeatState:
		return database.ResourceTypeAISeat
	case database.AIProvider:
		return database.ResourceTypeAIProvider
	case database.AIProviderKey:
		return database.ResourceTypeAIProviderKey
	case database.AIGatewayKey:
		return database.ResourceTypeAIGatewayKey
	case database.AuditableGroupAIBudget:
		return database.ResourceTypeGroupAIBudget
	case database.AuditableUserAIBudgetOverride:
		return database.ResourceTypeUserAIBudgetOverride
	case database.Chat:
		return database.ResourceTypeChat
	case database.ChatProject:
		return database.ResourceTypeChatProject
	case database.ChatProjectMemory:
		return database.ResourceTypeChatProjectMemory
	case database.AuditableChatProjectInstructions:
		return database.ResourceTypeChatProjectInstructions
	case database.ChatModelConfig:
		return database.ResourceTypeChatModelConfig
	case database.MCPServerConfig:
		return database.ResourceTypeMCPServerConfig
	case database.UserSecret:
		return database.ResourceTypeUserSecret
	case database.Skill:
		return database.ResourceTypeUserSkill
	case database.AuditableOrganizationSkill:
		return database.ResourceTypeOrganizationSkill
	case database.AuditableChatProjectSkill:
		return database.ResourceTypeChatProjectSkill
	case database.ChatInstructionSettings:
		return database.ResourceTypeChatInstructionSettings
	case database.ChatOperationalSettings:
		return database.ResourceTypeChatOperationalSettings
	case database.ChatOrganizationSystemPrompt:
		return database.ResourceTypeChatOrganizationSystemPrompt
	case database.ExperimentRule:
		return database.ResourceTypeExperimentRule
	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.OAuth2ProviderSettings:
		// 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.AISeatState:
		return false
	case database.AIProvider:
		// AI providers are deployment-scoped, not org-scoped.
		return false
	case database.AIProviderKey:
		// AI provider keys inherit the deployment scope of their parent
		// provider.
		return false
	case database.AIGatewayKey:
		// AI Gateway keys are deployment-scoped, not org-scoped.
		return false
	case database.AuditableGroupAIBudget:
		// Group AI budgets are org-scoped through their parent group.
		return true
	case database.AuditableUserAIBudgetOverride:
		// User AI budget overrides are org-scoped through their
		// attributed group.
		return true
	case database.Chat:
		// Chats always have a non-null organization_id (since
		// migration 000467).
		return true
	case database.ChatProject:
		return true
	case database.ChatProjectMemory:
		return true
	case database.AuditableChatProjectInstructions:
		return true
	case database.ChatModelConfig:
		return true
	case database.MCPServerConfig:
		// MCP server configs always carry a non-null organization_id.
		return true
	case database.UserSecret:
		// User secrets are global to the user across organizations.
		return false
	case database.Skill:
		// User skills are global to the user across organizations.
		return false
	case database.AuditableOrganizationSkill:
		return true
	case database.AuditableChatProjectSkill:
		return true
	case database.ChatInstructionSettings:
		// Deployment settings, not scoped to any organization.
		return false
	case database.ChatOperationalSettings:
		return false
	case database.ChatOrganizationSystemPrompt:
		return true
	case database.ExperimentRule:
		// Deployment settings, not scoped to any organization.
		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
}

// 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 := database.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 := database.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")
	}
}