From 0c3b2ba422d582e93e8c45e2822f0daf5eaa5c6f Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Mon, 18 May 2026 16:00:59 +0100 Subject: [PATCH 1/2] feat: trigger fraud evaluation on billing payment-method attach Adds a BillingPaymentMethodAttached trigger event so fraud evaluation fires once a card has been attached at signup, not on raw User creation. Extends the provider input with card metadata (BIN, last4, country, AVS, CVC) sourced from BillingAccount.status.paymentMethod and forwards it to MaxMind as the credit_card sub-object on /factors. Key changes: - BillingPaymentMethodAttachedTriggerReconciler watches BillingAccount and creates FraudEvaluations when an active FraudPolicy has the new trigger event - provider.CreditCard + resolver.resolvePaymentMethod() look up the user's BillingAccount via the iam.miloapis.com/owner-user label - MaxMind buildRequest emits credit_card with issuer.iin, last_digits, country, avs_result, cvv_result - After PlatformAccessApproval creation, mirror approval onto owned BillingAccounts so billing's phase controller can flip to Ready Note: go.mod uses a local replace for go.miloapis.com/billing while the module is unpublished. Drop the replace before merge. --- api/v1alpha1/fraudpolicy_types.go | 15 ++ cmd/main.go | 9 + go.mod | 36 ++-- go.sum | 68 ++++---- ...aymentmethodattached_trigger_controller.go | 156 ++++++++++++++++++ .../controller/fraudevaluation_controller.go | 36 ++++ internal/datasource/resolver.go | 50 ++++++ internal/provider/maxmind/maxmind.go | 34 +++- internal/provider/provider.go | 30 ++++ 9 files changed, 381 insertions(+), 53 deletions(-) create mode 100644 internal/controller/billingpaymentmethodattached_trigger_controller.go diff --git a/api/v1alpha1/fraudpolicy_types.go b/api/v1alpha1/fraudpolicy_types.go index bd8fc86..f778dae 100644 --- a/api/v1alpha1/fraudpolicy_types.go +++ b/api/v1alpha1/fraudpolicy_types.go @@ -89,6 +89,21 @@ type EnforcementConfig struct { Mode string `json:"mode"` } +// Known TriggerConfig event names. Values map to controllers in +// internal/controller/ that watch the corresponding upstream resource and +// create a FraudEvaluation when fired. +const ( + // TriggerEventUserCreated fires when a User resource is created. + TriggerEventUserCreated = "UserCreated" + + // TriggerEventBillingPaymentMethodAttached fires when a BillingAccount + // transitions to having a payment method attached (the + // PaymentMethodAttached condition flips to True). This is the + // authoritative signup-time trigger when the platform requires a + // card-on-file before granting access. + TriggerEventBillingPaymentMethodAttached = "BillingPaymentMethodAttached" +) + // TriggerConfig defines what triggers a fraud evaluation. type TriggerConfig struct { // type is the kind of trigger. diff --git a/cmd/main.go b/cmd/main.go index 20b91a7..b5a6ad7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -23,6 +23,7 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" + billingv1alpha1 "go.miloapis.com/billing/api/v1alpha1" iamv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1" identityv1alpha1 "go.miloapis.com/milo/pkg/apis/identity/v1alpha1" @@ -43,6 +44,7 @@ func init() { utilruntime.Must(fraudv1alpha1.AddToScheme(scheme)) utilruntime.Must(iamv1alpha1.AddToScheme(scheme)) utilruntime.Must(identityv1alpha1.AddToScheme(scheme)) + utilruntime.Must(billingv1alpha1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -249,6 +251,13 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "UserFraudTrigger") os.Exit(1) } + if err := (&controller.BillingPaymentMethodAttachedTriggerReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "BillingPaymentMethodAttachedTrigger") + os.Exit(1) + } // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/go.mod b/go.mod index b41f1ae..2e528f9 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/onsi/ginkgo/v2 v2.27.2 github.com/onsi/gomega v1.38.2 github.com/prometheus/client_golang v1.23.2 + go.miloapis.com/billing v0.0.0-00010101000000-000000000000 go.miloapis.com/milo v0.24.8 k8s.io/api v0.35.0 k8s.io/apimachinery v0.35.0 @@ -33,7 +34,7 @@ require ( github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect github.com/go-openapi/jsonreference v0.21.3 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/swag/jsonname v0.25.1 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/btree v1.1.3 // indirect @@ -45,15 +46,14 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.2 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect @@ -69,23 +69,23 @@ require ( go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/mod v0.31.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.40.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.43.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/grpc v1.77.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -101,3 +101,5 @@ require ( sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) + +replace go.miloapis.com/billing => /Users/matthewjenkinson/Git/milo-os/billing diff --git a/go.sum b/go.sum index c9e0e5c..dffa800 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc= github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= @@ -83,16 +83,16 @@ github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -118,10 +118,10 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -181,32 +181,32 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= -gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= @@ -215,8 +215,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/controller/billingpaymentmethodattached_trigger_controller.go b/internal/controller/billingpaymentmethodattached_trigger_controller.go new file mode 100644 index 0000000..26d9c05 --- /dev/null +++ b/internal/controller/billingpaymentmethodattached_trigger_controller.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package controller + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + billingv1alpha1 "go.miloapis.com/billing/api/v1alpha1" + + fraudv1alpha1 "go.miloapis.com/fraud/api/v1alpha1" + "go.miloapis.com/fraud/internal/datasource" +) + +// BillingPaymentMethodAttachedTriggerReconciler watches BillingAccount +// resources and creates a FraudEvaluation when the account's +// PaymentMethodAttached condition flips to True, provided a FraudPolicy +// with a `BillingPaymentMethodAttached` trigger is active. The owning +// user is resolved via the OwnerUserLabel. +type BillingPaymentMethodAttachedTriggerReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=billing.miloapis.com,resources=billingaccounts,verbs=get;list;watch + +func (r *BillingPaymentMethodAttachedTriggerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + var account billingv1alpha1.BillingAccount + if err := r.Get(ctx, req.NamespacedName, &account); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + if !isPaymentMethodAttached(&account) { + // Not yet attached — nothing to do; the next status change will + // re-trigger this reconciler. + return ctrl.Result{}, nil + } + + userUID := account.Labels[datasource.OwnerUserLabel] + if userUID == "" { + log.Info("BillingAccount has no owner-user label; skipping fraud trigger", + "account", req.NamespacedName) + return ctrl.Result{}, nil + } + + policy, err := r.findTriggeredPolicy(ctx) + if err != nil { + return ctrl.Result{}, err + } + if policy == nil { + return ctrl.Result{}, nil + } + + exists, err := r.evaluationExists(ctx, userUID, policy.Name) + if err != nil { + return ctrl.Result{}, err + } + if exists { + return ctrl.Result{}, nil + } + + eval := &fraudv1alpha1.FraudEvaluation{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "eval-"}, + Spec: fraudv1alpha1.FraudEvaluationSpec{ + UserRef: fraudv1alpha1.UserReference{Name: userUID}, + PolicyRef: fraudv1alpha1.PolicyReference{Name: policy.Name}, + }, + } + if err := r.Create(ctx, eval); err != nil { + return ctrl.Result{}, fmt.Errorf( + "failed to create FraudEvaluation for user %q: %w", userUID, err) + } + + log.Info("created FraudEvaluation from BillingPaymentMethodAttached trigger", + "user", userUID, + "billingAccount", req.NamespacedName, + "evaluation", eval.Name, + "policy", policy.Name) + + return ctrl.Result{}, nil +} + +func isPaymentMethodAttached(a *billingv1alpha1.BillingAccount) bool { + c := apimeta.FindStatusCondition(a.Status.Conditions, billingv1alpha1.BillingAccountConditionPaymentMethodAttached) + return c != nil && c.Status == metav1.ConditionTrue +} + +func (r *BillingPaymentMethodAttachedTriggerReconciler) findTriggeredPolicy(ctx context.Context) (*fraudv1alpha1.FraudPolicy, error) { + var policies fraudv1alpha1.FraudPolicyList + if err := r.List(ctx, &policies); err != nil { + return nil, fmt.Errorf("failed to list FraudPolicies: %w", err) + } + for i := range policies.Items { + for _, trigger := range policies.Items[i].Spec.Triggers { + if trigger.Type == "Event" && trigger.Event == fraudv1alpha1.TriggerEventBillingPaymentMethodAttached { + return &policies.Items[i], nil + } + } + } + return nil, nil +} + +func (r *BillingPaymentMethodAttachedTriggerReconciler) evaluationExists(ctx context.Context, userName, policyName string) (bool, error) { + var evals fraudv1alpha1.FraudEvaluationList + if err := r.List(ctx, &evals); err != nil { + return false, fmt.Errorf("failed to list FraudEvaluations: %w", err) + } + for _, e := range evals.Items { + if e.Spec.UserRef.Name == userName && e.Spec.PolicyRef.Name == policyName { + return true, nil + } + } + return false, nil +} + +// SetupWithManager wires the reconciler. It only fires on create and on +// updates that change the PaymentMethodAttached condition — generic +// status changes (e.g. LinkedProjectsCount) are filtered out. +func (r *BillingPaymentMethodAttachedTriggerReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&billingv1alpha1.BillingAccount{}). + Named("billingpaymentmethodattachedtrigger"). + WithEventFilter(predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + if ba, ok := e.Object.(*billingv1alpha1.BillingAccount); ok { + return isPaymentMethodAttached(ba) + } + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldBA, oldOK := e.ObjectOld.(*billingv1alpha1.BillingAccount) + newBA, newOK := e.ObjectNew.(*billingv1alpha1.BillingAccount) + if !oldOK || !newOK { + return false + } + return !isPaymentMethodAttached(oldBA) && isPaymentMethodAttached(newBA) + }, + DeleteFunc: func(_ event.DeleteEvent) bool { return false }, + GenericFunc: func(_ event.GenericEvent) bool { return false }, + }). + Complete(r) +} diff --git a/internal/controller/fraudevaluation_controller.go b/internal/controller/fraudevaluation_controller.go index 36942eb..651ab6f 100644 --- a/internal/controller/fraudevaluation_controller.go +++ b/internal/controller/fraudevaluation_controller.go @@ -20,6 +20,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" logf "sigs.k8s.io/controller-runtime/pkg/log" + billingv1alpha1 "go.miloapis.com/billing/api/v1alpha1" iamv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1" fraudv1alpha1 "go.miloapis.com/fraud/api/v1alpha1" @@ -548,6 +549,16 @@ func (r *FraudEvaluationReconciler) applyEnforcement(ctx context.Context, eval * log.Info("PlatformAccessApproval ensured", "name", resourceName, "user", eval.Spec.UserRef.Name) + // Mirror approval onto any BillingAccount owned by this user so + // the billing-account phase controller can transition to Ready. + // Failures here are non-fatal — the enforcement-applied condition + // on the eval is the authoritative signal; the BA mirror is a + // convenience for the gating front-end. + if err := r.markBillingAccountsApproved(ctx, eval.Spec.UserRef.Name); err != nil { + log.Info("failed to mirror approval onto BillingAccounts (non-fatal)", + "user", eval.Spec.UserRef.Name, "error", err) + } + default: // Empty decision means we were called before status.decision was set // (e.g. a stale read between Status().Update and the cache catching up). @@ -647,3 +658,28 @@ func (r *FraudEvaluationReconciler) SetupWithManager(mgr ctrl.Manager) error { Named("fraudevaluation"). Complete(r) } + +// markBillingAccountsApproved patches the PlatformAccessApproved condition +// on every BillingAccount labeled with the given user's UID. Best-effort: +// callers ignore the returned error. +func (r *FraudEvaluationReconciler) markBillingAccountsApproved(ctx context.Context, userUID string) error { + var accounts billingv1alpha1.BillingAccountList + if err := r.List(ctx, &accounts, client.MatchingLabels{datasource.OwnerUserLabel: userUID}); err != nil { + return fmt.Errorf("listing BillingAccounts for user %q: %w", userUID, err) + } + for i := range accounts.Items { + acc := &accounts.Items[i] + patch := client.MergeFrom(acc.DeepCopy()) + meta.SetStatusCondition(&acc.Status.Conditions, metav1.Condition{ + Type: billingv1alpha1.BillingAccountConditionPlatformAccessApproved, + Status: metav1.ConditionTrue, + ObservedGeneration: acc.Generation, + Reason: "FraudEvaluationAccepted", + Message: "Owning user passed fraud evaluation.", + }) + if err := r.Status().Patch(ctx, acc, patch); err != nil { + return fmt.Errorf("patching BillingAccount %s/%s: %w", acc.Namespace, acc.Name, err) + } + } + return nil +} diff --git a/internal/datasource/resolver.go b/internal/datasource/resolver.go index db8648b..b14298a 100644 --- a/internal/datasource/resolver.go +++ b/internal/datasource/resolver.go @@ -10,12 +10,19 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" logf "sigs.k8s.io/controller-runtime/pkg/log" + billingv1alpha1 "go.miloapis.com/billing/api/v1alpha1" iamv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1" identityv1alpha1 "go.miloapis.com/milo/pkg/apis/identity/v1alpha1" "go.miloapis.com/fraud/internal/provider" ) +// OwnerUserLabel is the label used on BillingAccount to identify the +// owning user. The auto-billing-account controller (in milo) writes this +// label at creation time. The fraud resolver uses it to find the user's +// billing account when assembling the provider input. +const OwnerUserLabel = "iam.miloapis.com/owner-user" + // MaxMindTrackingTokenAnnotation is the milo Session annotation key that // auth-provider-zitadel populates from Zitadel session metadata. The // resolver reads it from the latest Session and forwards the value to @@ -55,6 +62,10 @@ func (r *Resolver) Resolve(ctx context.Context, userUID string) (provider.Input, sessionErr = err } + if err := r.resolvePaymentMethod(ctx, userUID, &input); err != nil { + log.Info("failed to resolve payment-method data, continuing with empty card fields", "user", userUID, "error", err) + } + log.Info("resolved provider input", "user", userUID, "email", input.EmailAddress, @@ -62,11 +73,50 @@ func (r *Resolver) Resolve(ctx context.Context, userUID string) (provider.Input, "ip", input.IPAddress, "userAgent", input.UserAgent, "hasTrackingToken", input.TrackingToken != "", + "hasCreditCard", input.CreditCard.HasAny(), ) return input, sessionErr } +// resolvePaymentMethod finds the user's BillingAccount and copies its +// attached payment-method metadata into input.CreditCard. Lookup is by the +// `iam.miloapis.com/owner-user=` label written by the +// auto-billing-account controller. Missing data is non-fatal. +func (r *Resolver) resolvePaymentMethod(ctx context.Context, userUID string, input *provider.Input) error { + var accounts billingv1alpha1.BillingAccountList + if err := r.client.List(ctx, &accounts, client.MatchingLabels{OwnerUserLabel: userUID}); err != nil { + return fmt.Errorf("listing BillingAccounts for user %q: %w", userUID, err) + } + if len(accounts.Items) == 0 { + return fmt.Errorf("no BillingAccount found for user %q", userUID) + } + // When more than one BA is owned by the user, prefer one with a + // payment method attached; otherwise take the most recent. + pick := accounts.Items[0] + for i := range accounts.Items { + if accounts.Items[i].Status.PaymentMethod != nil { + pick = accounts.Items[i] + break + } + if accounts.Items[i].CreationTimestamp.After(pick.CreationTimestamp.Time) { + pick = accounts.Items[i] + } + } + if pick.Status.PaymentMethod == nil { + return fmt.Errorf("BillingAccount %s/%s has no attached payment method", pick.Namespace, pick.Name) + } + pm := pick.Status.PaymentMethod + input.CreditCard = provider.CreditCard{ + IssuerIDNumber: pm.BIN, + LastDigits: pm.Last4, + Country: pm.Country, + AVSResult: pm.AVSResult, + CVVResult: pm.CVCResult, + } + return nil +} + // resolveUser fetches the User CR and populates email and name fields. func (r *Resolver) resolveUser(ctx context.Context, userUID string, input *provider.Input) error { var user iamv1alpha1.User diff --git a/internal/provider/maxmind/maxmind.go b/internal/provider/maxmind/maxmind.go index 8224c63..515ad38 100644 --- a/internal/provider/maxmind/maxmind.go +++ b/internal/provider/maxmind/maxmind.go @@ -72,8 +72,9 @@ func (c *Client) Name() string { // minfraudRequest represents the minFraud API request body. type minfraudRequest struct { - Device *deviceField `json:"device,omitempty"` - Email *emailField `json:"email,omitempty"` + Device *deviceField `json:"device,omitempty"` + Email *emailField `json:"email,omitempty"` + CreditCard *creditCardField `json:"credit_card,omitempty"` } type deviceField struct { @@ -91,6 +92,21 @@ type emailField struct { Domain string `json:"domain,omitempty"` } +// creditCardField mirrors the MaxMind minFraud `credit_card` sub-object. +// Only fields the platform can reliably source from Stripe are exposed +// here; full-PAN / 3DS-specific fields are intentionally omitted. +type creditCardField struct { + Issuer *creditCardIssuer `json:"issuer,omitempty"` + LastDigits string `json:"last_digits,omitempty"` + Country string `json:"country,omitempty"` + AVSResult string `json:"avs_result,omitempty"` + CVVResult string `json:"cvv_result,omitempty"` +} + +type creditCardIssuer struct { + IIN string `json:"iin,omitempty"` +} + // minfraudResponse represents the relevant fields from a minFraud Score response. type minfraudResponse struct { RiskScore float64 `json:"risk_score"` @@ -179,6 +195,20 @@ func buildRequest(input provider.Input) minfraudRequest { } } + // Build credit_card field if any card metadata is present. + if input.CreditCard.HasAny() { + cc := &creditCardField{ + LastDigits: input.CreditCard.LastDigits, + Country: input.CreditCard.Country, + AVSResult: input.CreditCard.AVSResult, + CVVResult: input.CreditCard.CVVResult, + } + if input.CreditCard.IssuerIDNumber != "" { + cc.Issuer = &creditCardIssuer{IIN: input.CreditCard.IssuerIDNumber} + } + req.CreditCard = cc + } + return req } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 9fe63cd..a9246d5 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -17,6 +17,36 @@ type Input struct { // resolver reads it from the Session annotation written by // auth-provider-zitadel; providers forward it as device.tracking_token. TrackingToken string + + // CreditCard carries sanitized card metadata sourced from the + // BillingAccount's attached payment method. All fields are + // individually optional — providers forward whatever is present. + CreditCard CreditCard +} + +// CreditCard is the sanitized payment-method metadata fed into provider +// scoring. Raw PAN is never carried here; only BIN, last4, and +// verification results that issuers return as part of authorization. +type CreditCard struct { + // IssuerIDNumber is the card BIN (first 6-8 digits). MaxMind names + // this `issuer.iin`. + IssuerIDNumber string + // LastDigits is the last 4 digits of the PAN. + LastDigits string + // Country is the ISO 3166-1 alpha-2 country of the issuer. + Country string + // AVSResult is the Address Verification System result from the + // issuer (single character: Y/N/A/Z/…). + AVSResult string + // CVVResult is the CVV verification result from the issuer + // (Y/N/P/X/U). + CVVResult string +} + +// HasAny reports whether the CreditCard has any field populated. +func (c CreditCard) HasAny() bool { + return c.IssuerIDNumber != "" || c.LastDigits != "" || c.Country != "" || + c.AVSResult != "" || c.CVVResult != "" } // Result holds the output of a provider evaluation. From e5807f6ecfb72eb7e4c1e7f13f7df2c7ec1ee890 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Mon, 18 May 2026 16:19:05 +0100 Subject: [PATCH 2/2] chore: replace billing module via GitHub for CI The local-path replace directive blocked CI's go build. Point at a pinned commit on github.com/milo-os/billing so the module is resolvable on Linux runners. Update once go.miloapis.com/billing publishes a tagged release. --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 2e528f9..c811473 100644 --- a/go.mod +++ b/go.mod @@ -102,4 +102,4 @@ require ( sigs.k8s.io/yaml v1.6.0 // indirect ) -replace go.miloapis.com/billing => /Users/matthewjenkinson/Git/milo-os/billing +replace go.miloapis.com/billing => github.com/milo-os/billing v0.1.1-0.20260518150055-210951d4d984 diff --git a/go.sum b/go.sum index dffa800..3f3234b 100644 --- a/go.sum +++ b/go.sum @@ -97,6 +97,8 @@ github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/milo-os/billing v0.1.1-0.20260518150055-210951d4d984 h1:FAR2X8MOLMTSGP+qJmV1FvRbWsVyiriigOtSF0GrVOc= +github.com/milo-os/billing v0.1.1-0.20260518150055-210951d4d984/go.mod h1:9Mk9EVaG2Ltkma9unZk1tojxdRqjWUbtQXJBqV30h/0= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=