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..c811473 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 => github.com/milo-os/billing v0.1.1-0.20260518150055-210951d4d984 diff --git a/go.sum b/go.sum index c9e0e5c..3f3234b 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,20 +83,22 @@ 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= 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= @@ -118,10 +120,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 +183,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 +217,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.