From e91c730797f0776c0bcc6493de60f817474648e2 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 31 Mar 2026 16:37:59 +0530 Subject: [PATCH 1/3] feat: add prometheus metrics, health checks, and readiness endpoints - Add github.com/prometheus/client_golang dependency - Create internal/metrics package registering HTTPRequestsTotal, HTTPRequestDuration, AuthEventsTotal, and ActiveSessions collectors - Add HealthCheck(ctx) method to the storage.Provider interface and implement it for all six DB providers (sql, mongodb, arangodb, cassandradb, dynamodb, couchbase) - Replace the simple /health string handler with a storage-backed JSON liveness handler (/healthz) and a new readiness handler (/readyz) - Add MetricsMiddleware() and MetricsHandler() to the http_handlers.Provider interface and implement them - Register /healthz, /readyz, /metrics routes and MetricsMiddleware in the Gin router - Call metrics.Init() during server startup in cmd/root.go - Add integration tests for /healthz and /readyz endpoints --- cmd/root.go | 4 ++ go.mod | 13 +++- go.sum | 36 +++++++---- internal/http_handlers/health.go | 31 ++++++++-- internal/http_handlers/metrics.go | 38 ++++++++++++ internal/http_handlers/provider.go | 8 ++- internal/integration_tests/health_test.go | 60 +++++++++++++++++++ internal/metrics/metrics.go | 49 +++++++++++++++ internal/server/http_routes.go | 4 ++ internal/storage/db/arangodb/health_check.go | 9 +++ .../storage/db/cassandradb/health_check.go | 8 +++ internal/storage/db/couchbase/health_check.go | 17 ++++++ internal/storage/db/dynamodb/health_check.go | 13 ++++ internal/storage/db/mongodb/health_check.go | 12 ++++ internal/storage/db/sql/health_check.go | 12 ++++ internal/storage/provider.go | 3 + 16 files changed, 299 insertions(+), 18 deletions(-) create mode 100644 internal/http_handlers/metrics.go create mode 100644 internal/integration_tests/health_test.go create mode 100644 internal/metrics/metrics.go create mode 100644 internal/storage/db/arangodb/health_check.go create mode 100644 internal/storage/db/cassandradb/health_check.go create mode 100644 internal/storage/db/couchbase/health_check.go create mode 100644 internal/storage/db/dynamodb/health_check.go create mode 100644 internal/storage/db/mongodb/health_check.go create mode 100644 internal/storage/db/sql/health_check.go diff --git a/cmd/root.go b/cmd/root.go index a15151b2..5e7a27be 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,6 +19,7 @@ import ( "github.com/authorizerdev/authorizer/internal/events" "github.com/authorizerdev/authorizer/internal/http_handlers" "github.com/authorizerdev/authorizer/internal/memory_store" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/oauth" "github.com/authorizerdev/authorizer/internal/server" "github.com/authorizerdev/authorizer/internal/sms" @@ -310,6 +311,9 @@ func runRoot(c *cobra.Command, args []string) { Level(zeroLogLevel). With().Timestamp().Logger() + // Initialize prometheus metrics + metrics.Init() + // Derive IsEmailServiceEnabled from SMTP config rootArgs.config.IsEmailServiceEnabled = strings.TrimSpace(rootArgs.config.SMTPHost) != "" && rootArgs.config.SMTPPort > 0 && diff --git a/go.mod b/go.mod index 597c1862..ae7e1198 100644 --- a/go.mod +++ b/go.mod @@ -17,11 +17,12 @@ require ( github.com/google/uuid v1.6.0 github.com/guregu/dynamo v1.20.2 github.com/pquerna/otp v1.4.0 + github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.6.3 github.com/robertkrimen/otto v0.2.1 github.com/rs/zerolog v1.33.0 github.com/spf13/cobra v1.8.1 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/twilio/twilio-go v1.14.1 github.com/vektah/gqlparser/v2 v2.5.26 go.mongodb.org/mongo-driver v1.12.1 @@ -39,6 +40,7 @@ require ( github.com/agnivade/levenshtein v1.2.1 // indirect github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9 // indirect github.com/arangodb/go-velocypack v0.0.0-20200318135517-5af53c29c67e // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect @@ -76,7 +78,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.15.15 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/libsql/libsql-client-go v0.0.0-20231026052543-fce76c0f39a7 // indirect @@ -87,9 +89,13 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/montanaflynn/stats v0.7.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect @@ -103,6 +109,7 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect golang.org/x/mod v0.30.0 // indirect @@ -110,7 +117,7 @@ require ( golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/tools v0.39.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/protobuf v1.36.8 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/sourcemap.v1 v1.0.5 // indirect diff --git a/go.sum b/go.sum index e954155a..9767c7bd 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/aws/aws-sdk-go v1.44.306/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8 github.com/aws/aws-sdk-go v1.47.4 h1:IyhNbmPt+5ldi5HNzv7ZnXiqSglDMaJiZlzj4Yq3qnk= github.com/aws/aws-sdk-go v1.47.4/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= @@ -154,8 +156,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= @@ -205,14 +207,14 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.15.15 h1:EF27CXIuDsYJ6mmvtBRlEuB2UVOqHG1tAXgZ7yIO+lw= -github.com/klauspost/compress v1.15.15/go.mod h1:ZcK2JAFqKOpnBlxcLsJzYfrS9X1akm9fHZNnD9+Vo/4= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -250,6 +252,8 @@ github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3P github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/montanaflynn/stats v0.7.0 h1:r3y12KyNxj/Sb/iOE46ws+3mS1+MZca1wlHQFPsY/JU= github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= @@ -261,6 +265,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +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/redis/go-redis/v9 v9.6.3 h1:8Dr5ygF1QFXRxIH/m3Xg9MMG1rS8YCtAgosrsewT6i0= github.com/redis/go-redis/v9 v9.6.3/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= @@ -298,8 +310,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twilio/twilio-go v1.14.1 h1:uyMwNe2naFKwxLpVflAHbKEPiW9iHNI8VF6NWLJJ1Kk= github.com/twilio/twilio-go v1.14.1/go.mod h1:tdnfQ5TjbewoAu4lf9bMsGvfuJ/QU9gYuv9yx3TSIXU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= @@ -326,6 +338,10 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mongodb.org/mongo-driver v1.12.1 h1:nLkghSU8fQNaK7oUmDhQFsnrtcoNy7Z6LVFKsEecqgE= go.mongodb.org/mongo-driver v1.12.1/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= @@ -432,8 +448,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/http_handlers/health.go b/internal/http_handlers/health.go index 63d8a668..551be524 100644 --- a/internal/http_handlers/health.go +++ b/internal/http_handlers/health.go @@ -6,11 +6,34 @@ import ( "github.com/gin-gonic/gin" ) -// HealthHandler is the handler for /health route. -// It states if server is in healthy state or not +// HealthHandler is the handler for /healthz liveness probe route. +// It performs a storage health check and returns 200 if healthy or 503 if not. func (h *httpProvider) HealthHandler() gin.HandlerFunc { - h.Log.Info().Msg("Health check") return func(c *gin.Context) { - c.String(http.StatusOK, "OK") + if err := h.Dependencies.StorageProvider.HealthCheck(c.Request.Context()); err != nil { + h.Dependencies.Log.Error().Err(err).Msg("storage health check failed") + c.JSON(http.StatusServiceUnavailable, gin.H{ + "status": "unhealthy", + "error": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + } +} + +// ReadyHandler is the handler for /readyz readiness probe route. +// It checks storage health and returns 200 if ready or 503 if not. +func (h *httpProvider) ReadyHandler() gin.HandlerFunc { + return func(c *gin.Context) { + if err := h.Dependencies.StorageProvider.HealthCheck(c.Request.Context()); err != nil { + h.Dependencies.Log.Error().Err(err).Msg("storage health check failed in readiness probe") + c.JSON(http.StatusServiceUnavailable, gin.H{ + "status": "not ready", + "error": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ready"}) } } diff --git a/internal/http_handlers/metrics.go b/internal/http_handlers/metrics.go new file mode 100644 index 00000000..11b95db1 --- /dev/null +++ b/internal/http_handlers/metrics.go @@ -0,0 +1,38 @@ +package http_handlers + +import ( + "fmt" + "time" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus/promhttp" + + "github.com/authorizerdev/authorizer/internal/metrics" +) + +// MetricsMiddleware records HTTP request count and duration for every request. +func (h *httpProvider) MetricsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.FullPath() + if path == "" { + path = c.Request.URL.Path + } + + c.Next() + + duration := time.Since(start).Seconds() + status := fmt.Sprintf("%d", c.Writer.Status()) + + metrics.HTTPRequestsTotal.WithLabelValues(c.Request.Method, path, status).Inc() + metrics.HTTPRequestDuration.WithLabelValues(c.Request.Method, path).Observe(duration) + } +} + +// MetricsHandler returns a Gin handler that serves the Prometheus metrics endpoint. +func (h *httpProvider) MetricsHandler() gin.HandlerFunc { + prometheusHandler := promhttp.Handler() + return func(c *gin.Context) { + prometheusHandler.ServeHTTP(c.Writer, c.Request) + } +} diff --git a/internal/http_handlers/provider.go b/internal/http_handlers/provider.go index 8e16870d..b731667b 100644 --- a/internal/http_handlers/provider.go +++ b/internal/http_handlers/provider.go @@ -70,8 +70,10 @@ type Provider interface { DashboardHandler() gin.HandlerFunc // GraphqlHandler is the main handler that handels all the graphql requests GraphqlHandler() gin.HandlerFunc - // HealthHandler is the main handler that handels all the health requests + // HealthHandler is the handler for the /healthz liveness probe HealthHandler() gin.HandlerFunc + // ReadyHandler is the handler for the /readyz readiness probe + ReadyHandler() gin.HandlerFunc // JWKsHandler is the main handler that handels all the jwks requests JWKsHandler() gin.HandlerFunc // LogoutHandler is the main handler that handels all the logout requests @@ -103,4 +105,8 @@ type Provider interface { CORSMiddleware() gin.HandlerFunc // LoggerMiddleware is the middleware that logs the request LoggerMiddleware() gin.HandlerFunc + // MetricsMiddleware records HTTP request count and duration for prometheus. + MetricsMiddleware() gin.HandlerFunc + // MetricsHandler serves the Prometheus metrics scrape endpoint. + MetricsHandler() gin.HandlerFunc } diff --git a/internal/integration_tests/health_test.go b/internal/integration_tests/health_test.go new file mode 100644 index 00000000..21073a3a --- /dev/null +++ b/internal/integration_tests/health_test.go @@ -0,0 +1,60 @@ +package integration_tests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHealthHandler verifies the /healthz liveness probe endpoint behaviour. +func TestHealthHandler(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + + router := gin.New() + router.GET("/healthz", ts.HttpProvider.HealthHandler()) + + t.Run("returns_200_when_storage_is_healthy", func(t *testing.T) { + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/healthz", nil) + require.NoError(t, err) + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var body map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &body) + require.NoError(t, err) + assert.Equal(t, "ok", body["status"], "healthy response must contain status=ok") + }) +} + +// TestReadyHandler verifies the /readyz readiness probe endpoint behaviour. +func TestReadyHandler(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + + router := gin.New() + router.GET("/readyz", ts.HttpProvider.ReadyHandler()) + + t.Run("returns_200_when_storage_is_ready", func(t *testing.T) { + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/readyz", nil) + require.NoError(t, err) + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var body map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &body) + require.NoError(t, err) + assert.Equal(t, "ready", body["status"], "readiness response must contain status=ready") + }) +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 00000000..85967d75 --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,49 @@ +package metrics + +import "github.com/prometheus/client_golang/prometheus" + +var ( + // HTTPRequestsTotal is the total number of HTTP requests received. + HTTPRequestsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "authorizer_http_requests_total", + Help: "Total number of HTTP requests", + }, + []string{"method", "path", "status"}, + ) + + // HTTPRequestDuration tracks the duration of HTTP requests in seconds. + HTTPRequestDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "authorizer_http_request_duration_seconds", + Help: "HTTP request duration in seconds", + Buckets: prometheus.DefBuckets, + }, + []string{"method", "path"}, + ) + + // AuthEventsTotal is the total number of authentication events. + AuthEventsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "authorizer_auth_events_total", + Help: "Total number of authentication events", + }, + []string{"event", "status"}, + ) + + // ActiveSessions is the current number of active sessions. + ActiveSessions = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "authorizer_active_sessions", + Help: "Number of active sessions", + }, + ) +) + +// Init registers all metrics with the default prometheus registry. +func Init() { + prometheus.MustRegister(HTTPRequestsTotal) + prometheus.MustRegister(HTTPRequestDuration) + prometheus.MustRegister(AuthEventsTotal) + prometheus.MustRegister(ActiveSessions) +} diff --git a/internal/server/http_routes.go b/internal/server/http_routes.go index af377fac..c646e9c4 100644 --- a/internal/server/http_routes.go +++ b/internal/server/http_routes.go @@ -13,12 +13,16 @@ func (s *server) NewRouter() *gin.Engine { router.Use(gin.Recovery()) router.Use(s.Dependencies.HTTPProvider.LoggerMiddleware()) + router.Use(s.Dependencies.HTTPProvider.MetricsMiddleware()) router.Use(s.Dependencies.HTTPProvider.ContextMiddleware()) router.Use(s.Dependencies.HTTPProvider.CORSMiddleware()) router.Use(s.Dependencies.HTTPProvider.ClientCheckMiddleware()) router.GET("/", s.Dependencies.HTTPProvider.RootHandler()) router.GET("/health", s.Dependencies.HTTPProvider.HealthHandler()) + router.GET("/healthz", s.Dependencies.HTTPProvider.HealthHandler()) + router.GET("/readyz", s.Dependencies.HTTPProvider.ReadyHandler()) + router.GET("/metrics", s.Dependencies.HTTPProvider.MetricsHandler()) router.POST("/graphql", s.Dependencies.HTTPProvider.GraphqlHandler()) router.GET("/playground", s.Dependencies.HTTPProvider.PlaygroundHandler()) router.GET("/oauth_login/:oauth_provider", s.Dependencies.HTTPProvider.OAuthLoginHandler()) diff --git a/internal/storage/db/arangodb/health_check.go b/internal/storage/db/arangodb/health_check.go new file mode 100644 index 00000000..3ac808cb --- /dev/null +++ b/internal/storage/db/arangodb/health_check.go @@ -0,0 +1,9 @@ +package arangodb + +import "context" + +// HealthCheck verifies that the ArangoDB database is reachable and responsive +func (p *provider) HealthCheck(ctx context.Context) error { + _, err := p.db.Info(ctx) + return err +} diff --git a/internal/storage/db/cassandradb/health_check.go b/internal/storage/db/cassandradb/health_check.go new file mode 100644 index 00000000..7c10c3e1 --- /dev/null +++ b/internal/storage/db/cassandradb/health_check.go @@ -0,0 +1,8 @@ +package cassandradb + +import "context" + +// HealthCheck verifies that the Cassandra database is reachable and responsive +func (p *provider) HealthCheck(ctx context.Context) error { + return p.db.Query("SELECT now() FROM system.local").Exec() +} diff --git a/internal/storage/db/couchbase/health_check.go b/internal/storage/db/couchbase/health_check.go new file mode 100644 index 00000000..92ae64f8 --- /dev/null +++ b/internal/storage/db/couchbase/health_check.go @@ -0,0 +1,17 @@ +package couchbase + +import ( + "context" + "fmt" + + "github.com/couchbase/gocb/v2" +) + +// HealthCheck verifies that the Couchbase backend is reachable and responsive +func (p *provider) HealthCheck(ctx context.Context) error { + query := fmt.Sprintf("SELECT 1 FROM %s LIMIT 1", p.scopeName) + _, err := p.db.Query(query, &gocb.QueryOptions{ + Context: ctx, + }) + return err +} diff --git a/internal/storage/db/dynamodb/health_check.go b/internal/storage/db/dynamodb/health_check.go new file mode 100644 index 00000000..17fbb1a6 --- /dev/null +++ b/internal/storage/db/dynamodb/health_check.go @@ -0,0 +1,13 @@ +package dynamodb + +import ( + "context" + + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// HealthCheck verifies that the DynamoDB backend is reachable and responsive +func (p *provider) HealthCheck(ctx context.Context) error { + var envs []schemas.Env + return p.db.Table(schemas.Collections.Env).Scan().Limit(1).AllWithContext(ctx, &envs) +} diff --git a/internal/storage/db/mongodb/health_check.go b/internal/storage/db/mongodb/health_check.go new file mode 100644 index 00000000..88dcabac --- /dev/null +++ b/internal/storage/db/mongodb/health_check.go @@ -0,0 +1,12 @@ +package mongodb + +import ( + "context" + + "go.mongodb.org/mongo-driver/mongo/readpref" +) + +// HealthCheck verifies that the MongoDB database is reachable and responsive +func (p *provider) HealthCheck(ctx context.Context) error { + return p.db.Client().Ping(ctx, readpref.Primary()) +} diff --git a/internal/storage/db/sql/health_check.go b/internal/storage/db/sql/health_check.go new file mode 100644 index 00000000..fbc7d558 --- /dev/null +++ b/internal/storage/db/sql/health_check.go @@ -0,0 +1,12 @@ +package sql + +import "context" + +// HealthCheck verifies that the SQL database is reachable and responsive +func (p *provider) HealthCheck(ctx context.Context) error { + sqlDB, err := p.db.DB() + if err != nil { + return err + } + return sqlDB.PingContext(ctx) +} diff --git a/internal/storage/provider.go b/internal/storage/provider.go index 36647dcc..7e24706f 100644 --- a/internal/storage/provider.go +++ b/internal/storage/provider.go @@ -168,6 +168,9 @@ type Provider interface { ListAuditLogs(ctx context.Context, pagination *model.Pagination, filter map[string]interface{}) ([]*schemas.AuditLog, *model.Pagination, error) // DeleteAuditLogsBefore removes logs older than a timestamp (retention) DeleteAuditLogsBefore(ctx context.Context, before int64) error + + // HealthCheck verifies that the storage backend is reachable and responsive. + HealthCheck(ctx context.Context) error } // New creates a new database provider based on the configuration From 579c8aaf3370287a09209c591914cad8b7e60699 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Fri, 3 Apr 2026 15:46:32 +0530 Subject: [PATCH 2/3] feat: add security metrics, GraphQL error tracking, and auth event instrumentation - Add security metrics (authorizer_security_events_total) for failed logins, invalid credentials, revoked accounts, and failed admin auth - Add GraphQL error metrics (authorizer_graphql_errors_total) to capture errors in HTTP 200 responses using gqlgen AroundOperations middleware - Add GraphQL operation duration histogram (authorizer_graphql_request_duration_seconds) - Add DB health check counter (authorizer_db_health_check_total) - Instrument auth handlers: login, signup, logout, admin_login, admin_logout, forgot_password, reset_password with metrics.RecordAuthEvent calls - Track active sessions gauge on login/signup (inc) and logout (dec) - Add helper functions RecordAuthEvent, RecordSecurityEvent, RecordGraphQLError - Make metrics.Init() idempotent with sync.Once for safe test usage - Migrate health_test.go from legacy getTestConfig to runForEachDB pattern - Add comprehensive integration tests for all metrics (TestMetricsEndpoint, TestMetricsMiddleware, TestDBHealthCheckMetrics, TestAuthEventMetrics, TestGraphQLErrorMetrics, TestAdminLoginMetrics, TestForgotPasswordMetrics) --- internal/graphql/admin_login.go | 4 + internal/graphql/admin_logout.go | 2 + internal/graphql/forgot_password.go | 4 + internal/graphql/login.go | 9 + internal/graphql/logout.go | 3 + internal/graphql/reset_password.go | 2 + internal/graphql/signup.go | 3 + internal/http_handlers/graphql.go | 31 +++ internal/http_handlers/health.go | 6 + internal/integration_tests/health_test.go | 60 +++-- internal/integration_tests/main_test.go | 13 + internal/integration_tests/metrics_test.go | 293 +++++++++++++++++++++ internal/metrics/metrics.go | 96 ++++++- 13 files changed, 493 insertions(+), 33 deletions(-) create mode 100644 internal/integration_tests/main_test.go create mode 100644 internal/integration_tests/metrics_test.go diff --git a/internal/graphql/admin_login.go b/internal/graphql/admin_login.go index 81b5c79a..e44ca1bc 100644 --- a/internal/graphql/admin_login.go +++ b/internal/graphql/admin_login.go @@ -9,6 +9,7 @@ import ( "github.com/authorizerdev/authorizer/internal/cookie" "github.com/authorizerdev/authorizer/internal/crypto" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/utils" ) @@ -24,6 +25,8 @@ func (g *graphqlProvider) AdminLogin(ctx context.Context, params *model.AdminLog } if params.AdminSecret != g.Config.AdminSecret { log.Debug().Msg("Invalid admin secret") + metrics.RecordAuthEvent(metrics.EventAdminLogin, metrics.StatusFailure) + metrics.RecordSecurityEvent("invalid_admin_secret", "admin_login") g.AuditProvider.LogEvent(audit.Event{ Action: constants.AuditAdminLoginFailedEvent, ActorType: constants.AuditActorTypeAdmin, @@ -40,6 +43,7 @@ func (g *graphqlProvider) AdminLogin(ctx context.Context, params *model.AdminLog } cookie.SetAdminCookie(gc, hashedKey, g.Config.AdminCookieSecure) + metrics.RecordAuthEvent(metrics.EventAdminLogin, metrics.StatusSuccess) g.AuditProvider.LogEvent(audit.Event{ Action: constants.AuditAdminLoginSuccessEvent, ActorType: constants.AuditActorTypeAdmin, diff --git a/internal/graphql/admin_logout.go b/internal/graphql/admin_logout.go index cb24288f..90e331a1 100644 --- a/internal/graphql/admin_logout.go +++ b/internal/graphql/admin_logout.go @@ -8,6 +8,7 @@ import ( "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/cookie" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/utils" ) @@ -26,6 +27,7 @@ func (g *graphqlProvider) AdminLogout(ctx context.Context) (*model.Response, err } cookie.DeleteAdminCookie(gc, g.Config.AdminCookieSecure) + metrics.RecordAuthEvent(metrics.EventAdminLogout, metrics.StatusSuccess) g.AuditProvider.LogEvent(audit.Event{ Action: constants.AuditAdminLogoutEvent, ActorType: constants.AuditActorTypeAdmin, diff --git a/internal/graphql/forgot_password.go b/internal/graphql/forgot_password.go index 2cd53f37..a0d678ef 100644 --- a/internal/graphql/forgot_password.go +++ b/internal/graphql/forgot_password.go @@ -12,6 +12,7 @@ import ( "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/cookie" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/parsers" "github.com/authorizerdev/authorizer/internal/refs" "github.com/authorizerdev/authorizer/internal/storage/schemas" @@ -60,6 +61,7 @@ func (g *graphqlProvider) ForgotPassword(ctx context.Context, params *model.Forg log.Debug().Err(err).Msg("Failed to get user by phone number") } if err != nil { + metrics.RecordAuthEvent(metrics.EventForgotPwd, metrics.StatusFailure) return nil, fmt.Errorf(`bad user credentials`) } hostname := parsers.GetHost(gc) @@ -127,6 +129,7 @@ func (g *graphqlProvider) ForgotPassword(ctx context.Context, params *model.Forg IPAddress: utils.GetIP(gc.Request), UserAgent: utils.GetUserAgent(gc.Request), }) + metrics.RecordAuthEvent(metrics.EventForgotPwd, metrics.StatusSuccess) return &model.ForgotPasswordResponse{ Message: `Please check your inbox! We have sent a password reset link.`, }, nil @@ -168,6 +171,7 @@ func (g *graphqlProvider) ForgotPassword(ctx context.Context, params *model.Forg IPAddress: utils.GetIP(gc.Request), UserAgent: utils.GetUserAgent(gc.Request), }) + metrics.RecordAuthEvent(metrics.EventForgotPwd, metrics.StatusSuccess) return &model.ForgotPasswordResponse{ Message: "Please enter the OTP sent to your phone number and change your password.", ShouldShowMobileOtpScreen: refs.NewBoolRef(true), diff --git a/internal/graphql/login.go b/internal/graphql/login.go index b6fcea59..15e55e9e 100644 --- a/internal/graphql/login.go +++ b/internal/graphql/login.go @@ -13,6 +13,7 @@ import ( "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/cookie" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/parsers" "github.com/authorizerdev/authorizer/internal/refs" "github.com/authorizerdev/authorizer/internal/storage/schemas" @@ -61,10 +62,14 @@ func (g *graphqlProvider) Login(ctx context.Context, params *model.LoginRequest) log.Debug().Str("phone_number", phoneNumber).Msg("User found by phone number") } if err != nil { + metrics.RecordAuthEvent(metrics.EventLogin, metrics.StatusFailure) + metrics.RecordSecurityEvent("invalid_credentials", "user_not_found") return nil, fmt.Errorf(`user not found`) } if user.RevokedTimestamp != nil { log.Debug().Msg("User access has been revoked") + metrics.RecordAuthEvent(metrics.EventLogin, metrics.StatusFailure) + metrics.RecordSecurityEvent("account_revoked", "login_attempt") return nil, fmt.Errorf(`user access has been revoked`) } isEmailServiceEnabled := g.Config.IsEmailServiceEnabled @@ -189,6 +194,8 @@ func (g *graphqlProvider) Login(ctx context.Context, params *model.LoginRequest) err = bcrypt.CompareHashAndPassword([]byte(*user.Password), []byte(params.Password)) if err != nil { log.Debug().Msg("Bad user credentials") + metrics.RecordAuthEvent(metrics.EventLogin, metrics.StatusFailure) + metrics.RecordSecurityEvent("invalid_credentials", "bad_password") g.AuditProvider.LogEvent(audit.Event{ Action: constants.AuditLoginFailedEvent, ActorID: user.ID, @@ -396,6 +403,8 @@ func (g *graphqlProvider) Login(ctx context.Context, params *model.LoginRequest) IP: utils.GetIP(gc.Request), }) }() + metrics.RecordAuthEvent(metrics.EventLogin, metrics.StatusSuccess) + metrics.ActiveSessions.Inc() g.AuditProvider.LogEvent(audit.Event{ Action: constants.AuditLoginSuccessEvent, ActorID: user.ID, diff --git a/internal/graphql/logout.go b/internal/graphql/logout.go index 5be4ba41..7fd925e2 100644 --- a/internal/graphql/logout.go +++ b/internal/graphql/logout.go @@ -7,6 +7,7 @@ import ( "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/cookie" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/utils" ) @@ -36,6 +37,8 @@ func (g *graphqlProvider) Logout(ctx context.Context) (*model.Response, error) { return nil, err } cookie.DeleteSession(gc, g.Config.AppCookieSecure) + metrics.RecordAuthEvent(metrics.EventLogout, metrics.StatusSuccess) + metrics.ActiveSessions.Dec() g.AuditProvider.LogEvent(audit.Event{ Action: constants.AuditLogoutEvent, ActorID: tokenData.UserID, diff --git a/internal/graphql/reset_password.go b/internal/graphql/reset_password.go index 2b9e844e..eeefa352 100644 --- a/internal/graphql/reset_password.go +++ b/internal/graphql/reset_password.go @@ -11,6 +11,7 @@ import ( "github.com/authorizerdev/authorizer/internal/cookie" "github.com/authorizerdev/authorizer/internal/crypto" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/parsers" "github.com/authorizerdev/authorizer/internal/refs" "github.com/authorizerdev/authorizer/internal/storage/schemas" @@ -182,6 +183,7 @@ func (g *graphqlProvider) ResetPassword(ctx context.Context, params *model.Reset IPAddress: utils.GetIP(gc.Request), UserAgent: utils.GetUserAgent(gc.Request), }) + metrics.RecordAuthEvent(metrics.EventResetPwd, metrics.StatusSuccess) return &model.Response{ Message: `Password updated successfully.`, }, nil diff --git a/internal/graphql/signup.go b/internal/graphql/signup.go index c1d8d165..62cbfbee 100644 --- a/internal/graphql/signup.go +++ b/internal/graphql/signup.go @@ -15,6 +15,7 @@ import ( "github.com/authorizerdev/authorizer/internal/cookie" "github.com/authorizerdev/authorizer/internal/crypto" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/parsers" "github.com/authorizerdev/authorizer/internal/refs" "github.com/authorizerdev/authorizer/internal/storage/schemas" @@ -372,6 +373,8 @@ func (g *graphqlProvider) SignUp(ctx context.Context, params *model.SignUpReques log.Debug().Err(err).Msg("Failed to add session") } }() + metrics.RecordAuthEvent(metrics.EventSignup, metrics.StatusSuccess) + metrics.ActiveSessions.Inc() g.AuditProvider.LogEvent(audit.Event{ Action: constants.AuditSignupEvent, ActorID: user.ID, diff --git a/internal/http_handlers/graphql.go b/internal/http_handlers/graphql.go index a32c02b2..2f3580ea 100644 --- a/internal/http_handlers/graphql.go +++ b/internal/http_handlers/graphql.go @@ -3,6 +3,7 @@ package http_handlers import ( "context" "net/http" + "time" gql "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/handler" @@ -15,6 +16,7 @@ import ( "github.com/authorizerdev/authorizer/internal/graph" "github.com/authorizerdev/authorizer/internal/graph/generated" "github.com/authorizerdev/authorizer/internal/graphql" + "github.com/authorizerdev/authorizer/internal/metrics" ) func (h *httpProvider) gqlLoggingMiddleware() gql.FieldMiddleware { @@ -35,6 +37,34 @@ func (h *httpProvider) gqlLoggingMiddleware() gql.FieldMiddleware { } } +// gqlMetricsMiddleware records GraphQL operation duration and errors. +// It captures errors returned in HTTP 200 responses (GraphQL convention). +func (h *httpProvider) gqlMetricsMiddleware() gql.OperationMiddleware { + return func(ctx context.Context, next gql.OperationHandler) gql.ResponseHandler { + oc := gql.GetOperationContext(ctx) + operationName := oc.OperationName + if operationName == "" { + operationName = "anonymous" + } + start := time.Now() + + responseHandler := next(ctx) + + return func(ctx context.Context) *gql.Response { + resp := responseHandler(ctx) + if resp != nil { + duration := time.Since(start).Seconds() + metrics.GraphQLRequestDuration.WithLabelValues(operationName).Observe(duration) + + if len(resp.Errors) > 0 { + metrics.RecordGraphQLError(operationName) + } + } + return resp + } + } +} + // GraphqlHandler is the main handler that handels all the graphql requests func (h *httpProvider) GraphqlHandler() gin.HandlerFunc { gqlProvider, err := graphql.New(h.Config, &graphql.Dependencies{ @@ -65,6 +95,7 @@ func (h *httpProvider) GraphqlHandler() gin.HandlerFunc { srv.SetQueryCache(lru.New[*ast.QueryDocument](1000)) srv.AroundFields(h.gqlLoggingMiddleware()) + srv.AroundOperations(h.gqlMetricsMiddleware()) if h.Config.EnableGraphQLIntrospection { srv.Use(extension.Introspection{}) } diff --git a/internal/http_handlers/health.go b/internal/http_handlers/health.go index 551be524..d73944ac 100644 --- a/internal/http_handlers/health.go +++ b/internal/http_handlers/health.go @@ -4,6 +4,8 @@ import ( "net/http" "github.com/gin-gonic/gin" + + "github.com/authorizerdev/authorizer/internal/metrics" ) // HealthHandler is the handler for /healthz liveness probe route. @@ -12,12 +14,14 @@ func (h *httpProvider) HealthHandler() gin.HandlerFunc { return func(c *gin.Context) { if err := h.Dependencies.StorageProvider.HealthCheck(c.Request.Context()); err != nil { h.Dependencies.Log.Error().Err(err).Msg("storage health check failed") + metrics.DBHealthCheckTotal.WithLabelValues("unhealthy").Inc() c.JSON(http.StatusServiceUnavailable, gin.H{ "status": "unhealthy", "error": err.Error(), }) return } + metrics.DBHealthCheckTotal.WithLabelValues("healthy").Inc() c.JSON(http.StatusOK, gin.H{"status": "ok"}) } } @@ -28,12 +32,14 @@ func (h *httpProvider) ReadyHandler() gin.HandlerFunc { return func(c *gin.Context) { if err := h.Dependencies.StorageProvider.HealthCheck(c.Request.Context()); err != nil { h.Dependencies.Log.Error().Err(err).Msg("storage health check failed in readiness probe") + metrics.DBHealthCheckTotal.WithLabelValues("unhealthy").Inc() c.JSON(http.StatusServiceUnavailable, gin.H{ "status": "not ready", "error": err.Error(), }) return } + metrics.DBHealthCheckTotal.WithLabelValues("healthy").Inc() c.JSON(http.StatusOK, gin.H{"status": "ready"}) } } diff --git a/internal/integration_tests/health_test.go b/internal/integration_tests/health_test.go index 21073a3a..b8cf43db 100644 --- a/internal/integration_tests/health_test.go +++ b/internal/integration_tests/health_test.go @@ -9,52 +9,56 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/config" ) // TestHealthHandler verifies the /healthz liveness probe endpoint behaviour. func TestHealthHandler(t *testing.T) { - cfg := getTestConfig() - ts := initTestSetup(t, cfg) + runForEachDB(t, func(t *testing.T, cfg *config.Config) { + ts := initTestSetup(t, cfg) - router := gin.New() - router.GET("/healthz", ts.HttpProvider.HealthHandler()) + router := gin.New() + router.GET("/healthz", ts.HttpProvider.HealthHandler()) - t.Run("returns_200_when_storage_is_healthy", func(t *testing.T) { - w := httptest.NewRecorder() - req, err := http.NewRequest(http.MethodGet, "/healthz", nil) - require.NoError(t, err) + t.Run("returns_200_when_storage_is_healthy", func(t *testing.T) { + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/healthz", nil) + require.NoError(t, err) - router.ServeHTTP(w, req) + router.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, http.StatusOK, w.Code) - var body map[string]interface{} - err = json.Unmarshal(w.Body.Bytes(), &body) - require.NoError(t, err) - assert.Equal(t, "ok", body["status"], "healthy response must contain status=ok") + var body map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &body) + require.NoError(t, err) + assert.Equal(t, "ok", body["status"], "healthy response must contain status=ok") + }) }) } // TestReadyHandler verifies the /readyz readiness probe endpoint behaviour. func TestReadyHandler(t *testing.T) { - cfg := getTestConfig() - ts := initTestSetup(t, cfg) + runForEachDB(t, func(t *testing.T, cfg *config.Config) { + ts := initTestSetup(t, cfg) - router := gin.New() - router.GET("/readyz", ts.HttpProvider.ReadyHandler()) + router := gin.New() + router.GET("/readyz", ts.HttpProvider.ReadyHandler()) - t.Run("returns_200_when_storage_is_ready", func(t *testing.T) { - w := httptest.NewRecorder() - req, err := http.NewRequest(http.MethodGet, "/readyz", nil) - require.NoError(t, err) + t.Run("returns_200_when_storage_is_ready", func(t *testing.T) { + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/readyz", nil) + require.NoError(t, err) - router.ServeHTTP(w, req) + router.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, http.StatusOK, w.Code) - var body map[string]interface{} - err = json.Unmarshal(w.Body.Bytes(), &body) - require.NoError(t, err) - assert.Equal(t, "ready", body["status"], "readiness response must contain status=ready") + var body map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &body) + require.NoError(t, err) + assert.Equal(t, "ready", body["status"], "readiness response must contain status=ready") + }) }) } diff --git a/internal/integration_tests/main_test.go b/internal/integration_tests/main_test.go new file mode 100644 index 00000000..cc92a46c --- /dev/null +++ b/internal/integration_tests/main_test.go @@ -0,0 +1,13 @@ +package integration_tests + +import ( + "os" + "testing" + + "github.com/authorizerdev/authorizer/internal/metrics" +) + +func TestMain(m *testing.M) { + metrics.Init() + os.Exit(m.Run()) +} diff --git a/internal/integration_tests/metrics_test.go b/internal/integration_tests/metrics_test.go new file mode 100644 index 00000000..e97fc79d --- /dev/null +++ b/internal/integration_tests/metrics_test.go @@ -0,0 +1,293 @@ +package integration_tests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" + "github.com/authorizerdev/authorizer/internal/refs" +) + +// TestMetricsEndpoint verifies the /metrics endpoint serves Prometheus format. +func TestMetricsEndpoint(t *testing.T) { + runForEachDB(t, func(t *testing.T, cfg *config.Config) { + ts := initTestSetup(t, cfg) + + router := gin.New() + router.GET("/metrics", ts.HttpProvider.MetricsHandler()) + + t.Run("returns_200_with_prometheus_format", func(t *testing.T) { + // Trigger some metrics so they appear in output + metrics.RecordAuthEvent("test", "test") + metrics.RecordSecurityEvent("test", "test") + metrics.RecordGraphQLError("test") + metrics.DBHealthCheckTotal.WithLabelValues("test").Inc() + + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/metrics", nil) + require.NoError(t, err) + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + // Gauge metrics always appear + assert.Contains(t, body, "authorizer_active_sessions") + // Counter/histogram metrics appear after first increment + assert.Contains(t, body, "authorizer_auth_events_total") + assert.Contains(t, body, "authorizer_security_events_total") + assert.Contains(t, body, "authorizer_graphql_errors_total") + assert.Contains(t, body, "authorizer_db_health_check_total") + }) + }) +} + +// TestMetricsMiddleware verifies the HTTP metrics middleware records request count. +func TestMetricsMiddleware(t *testing.T) { + runForEachDB(t, func(t *testing.T, cfg *config.Config) { + ts := initTestSetup(t, cfg) + + router := gin.New() + router.Use(ts.HttpProvider.MetricsMiddleware()) + router.GET("/healthz", ts.HttpProvider.HealthHandler()) + router.GET("/metrics", ts.HttpProvider.MetricsHandler()) + + t.Run("records_http_request_metrics", func(t *testing.T) { + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/healthz", nil) + require.NoError(t, err) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + // Check metrics endpoint has recorded it + w2 := httptest.NewRecorder() + req2, err := http.NewRequest(http.MethodGet, "/metrics", nil) + require.NoError(t, err) + router.ServeHTTP(w2, req2) + + body := w2.Body.String() + assert.Contains(t, body, `authorizer_http_requests_total{method="GET",path="/healthz",status="200"}`) + }) + }) +} + +// TestDBHealthCheckMetrics verifies health check outcomes are tracked. +func TestDBHealthCheckMetrics(t *testing.T) { + runForEachDB(t, func(t *testing.T, cfg *config.Config) { + ts := initTestSetup(t, cfg) + + router := gin.New() + router.GET("/healthz", ts.HttpProvider.HealthHandler()) + router.GET("/metrics", ts.HttpProvider.MetricsHandler()) + + t.Run("records_healthy_db_check", func(t *testing.T) { + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/healthz", nil) + require.NoError(t, err) + router.ServeHTTP(w, req) + + var body map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &body) + require.NoError(t, err) + assert.Equal(t, "ok", body["status"]) + + // Verify metric was recorded + w2 := httptest.NewRecorder() + req2, err := http.NewRequest(http.MethodGet, "/metrics", nil) + require.NoError(t, err) + router.ServeHTTP(w2, req2) + + metricsBody := w2.Body.String() + assert.Contains(t, metricsBody, `authorizer_db_health_check_total{status="healthy"}`) + }) + }) +} + +// TestAuthEventMetrics verifies that auth events are recorded in metrics. +func TestAuthEventMetrics(t *testing.T) { + runForEachDB(t, func(t *testing.T, cfg *config.Config) { + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + router := gin.New() + router.GET("/metrics", ts.HttpProvider.MetricsHandler()) + + email := "metrics_" + uuid.New().String() + "@authorizer.dev" + password := "Password@123" + + t.Run("records_login_failure_on_bad_credentials", func(t *testing.T) { + loginReq := &model.LoginRequest{ + Email: &email, + Password: "wrong_password", + } + _, err := ts.GraphQLProvider.Login(ctx, loginReq) + assert.Error(t, err) + + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/metrics", nil) + require.NoError(t, err) + router.ServeHTTP(w, req) + + body := w.Body.String() + assert.Contains(t, body, `authorizer_auth_events_total{event="login",status="failure"}`) + assert.Contains(t, body, `authorizer_security_events_total{event="invalid_credentials"`) + }) + + t.Run("records_signup_and_login_success", func(t *testing.T) { + signupReq := &model.SignUpRequest{ + Email: &email, + Password: password, + ConfirmPassword: password, + } + res, err := ts.GraphQLProvider.SignUp(ctx, signupReq) + require.NoError(t, err) + assert.NotNil(t, res) + + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/metrics", nil) + require.NoError(t, err) + router.ServeHTTP(w, req) + assert.Contains(t, w.Body.String(), `authorizer_auth_events_total{event="signup",status="success"}`) + + loginReq := &model.LoginRequest{ + Email: &email, + Password: password, + } + loginRes, err := ts.GraphQLProvider.Login(ctx, loginReq) + require.NoError(t, err) + assert.NotNil(t, loginRes) + + w2 := httptest.NewRecorder() + req2, err := http.NewRequest(http.MethodGet, "/metrics", nil) + require.NoError(t, err) + router.ServeHTTP(w2, req2) + assert.Contains(t, w2.Body.String(), `authorizer_auth_events_total{event="login",status="success"}`) + }) + }) +} + +// TestGraphQLErrorMetrics verifies that GraphQL errors in 200 responses are captured. +func TestGraphQLErrorMetrics(t *testing.T) { + runForEachDB(t, func(t *testing.T, cfg *config.Config) { + ts := initTestSetup(t, cfg) + + router := gin.New() + router.Use(ts.HttpProvider.ContextMiddleware()) + router.Use(ts.HttpProvider.CORSMiddleware()) + router.POST("/graphql", ts.HttpProvider.GraphqlHandler()) + router.GET("/metrics", ts.HttpProvider.MetricsHandler()) + + t.Run("captures_graphql_errors_in_200_responses", func(t *testing.T) { + body := `{"query":"mutation { login(params: {email: \"nonexistent@test.com\", password: \"wrong\"}) { message } }"}` + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodPost, "/graphql", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-authorizer-url", "http://localhost:8080") + req.Header.Set("Origin", "http://localhost:3000") + router.ServeHTTP(w, req) + + // GraphQL always returns 200 even with errors + assert.Equal(t, http.StatusOK, w.Code) + + // Check metrics endpoint + w2 := httptest.NewRecorder() + req2, err := http.NewRequest(http.MethodGet, "/metrics", nil) + require.NoError(t, err) + router.ServeHTTP(w2, req2) + + metricsBody := w2.Body.String() + assert.Contains(t, metricsBody, "authorizer_graphql_request_duration_seconds") + }) + }) +} + +// TestRecordAuthEventHelpers verifies the helper functions work correctly. +func TestRecordAuthEventHelpers(t *testing.T) { + t.Run("RecordAuthEvent_increments_counter", func(t *testing.T) { + metrics.RecordAuthEvent(metrics.EventVerifyEmail, metrics.StatusSuccess) + metrics.RecordAuthEvent(metrics.EventVerifyOTP, metrics.StatusFailure) + metrics.RecordSecurityEvent("brute_force", "rate_limit") + }) +} + +// TestAdminLoginMetrics verifies admin login records metrics. +func TestAdminLoginMetrics(t *testing.T) { + runForEachDB(t, func(t *testing.T, cfg *config.Config) { + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + router := gin.New() + router.GET("/metrics", ts.HttpProvider.MetricsHandler()) + + t.Run("records_admin_login_failure", func(t *testing.T) { + loginReq := &model.AdminLoginRequest{ + AdminSecret: "wrong-secret", + } + _, err := ts.GraphQLProvider.AdminLogin(ctx, loginReq) + assert.Error(t, err) + + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/metrics", nil) + require.NoError(t, err) + router.ServeHTTP(w, req) + + body := w.Body.String() + assert.Contains(t, body, `authorizer_auth_events_total{event="admin_login",status="failure"}`) + assert.Contains(t, body, `authorizer_security_events_total{event="invalid_admin_secret"`) + }) + + t.Run("records_admin_login_success", func(t *testing.T) { + loginReq := &model.AdminLoginRequest{ + AdminSecret: cfg.AdminSecret, + } + res, err := ts.GraphQLProvider.AdminLogin(ctx, loginReq) + require.NoError(t, err) + assert.NotNil(t, res) + + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/metrics", nil) + require.NoError(t, err) + router.ServeHTTP(w, req) + + assert.Contains(t, w.Body.String(), `authorizer_auth_events_total{event="admin_login",status="success"}`) + }) + }) +} + +// TestForgotPasswordMetrics verifies forgot password records metrics. +func TestForgotPasswordMetrics(t *testing.T) { + runForEachDB(t, func(t *testing.T, cfg *config.Config) { + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + router := gin.New() + router.GET("/metrics", ts.HttpProvider.MetricsHandler()) + + t.Run("records_forgot_password_failure_for_nonexistent_user", func(t *testing.T) { + nonExistentEmail := "nonexistent_metrics@authorizer.dev" + forgotReq := &model.ForgotPasswordRequest{ + Email: refs.NewStringRef(nonExistentEmail), + } + _, err := ts.GraphQLProvider.ForgotPassword(ctx, forgotReq) + assert.Error(t, err) + + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/metrics", nil) + require.NoError(t, err) + router.ServeHTTP(w, req) + + assert.Contains(t, w.Body.String(), `authorizer_auth_events_total{event="forgot_password",status="failure"}`) + }) + }) +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 85967d75..e9166cd0 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -1,6 +1,33 @@ package metrics -import "github.com/prometheus/client_golang/prometheus" +import ( + "sync" + + "github.com/prometheus/client_golang/prometheus" +) + +var initOnce sync.Once + +// Auth event names used as label values for AuthEventsTotal. +const ( + EventLogin = "login" + EventSignup = "signup" + EventLogout = "logout" + EventForgotPwd = "forgot_password" + EventResetPwd = "reset_password" + EventVerifyEmail = "verify_email" + EventVerifyOTP = "verify_otp" + EventMagicLink = "magic_link_login" + EventAdminLogin = "admin_login" + EventAdminLogout = "admin_logout" + EventOAuthLogin = "oauth_login" + EventOAuthCallback = "oauth_callback" + EventTokenRefresh = "token_refresh" + EventTokenRevoke = "token_revoke" + + StatusSuccess = "success" + StatusFailure = "failure" +) var ( // HTTPRequestsTotal is the total number of HTTP requests received. @@ -38,12 +65,71 @@ var ( Help: "Number of active sessions", }, ) + + // SecurityEventsTotal tracks security-sensitive events for alerting. + SecurityEventsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "authorizer_security_events_total", + Help: "Total number of security-relevant events (failed logins, invalid tokens, etc.)", + }, + []string{"event", "reason"}, + ) + + // GraphQLErrorsTotal tracks GraphQL responses that contain errors (HTTP 200 but with errors). + GraphQLErrorsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "authorizer_graphql_errors_total", + Help: "Total number of GraphQL responses containing errors", + }, + []string{"operation"}, + ) + + // GraphQLRequestDuration tracks GraphQL operation latency. + GraphQLRequestDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "authorizer_graphql_request_duration_seconds", + Help: "GraphQL operation duration in seconds", + Buckets: prometheus.DefBuckets, + }, + []string{"operation"}, + ) + + // DBHealthCheckTotal tracks database health check outcomes. + DBHealthCheckTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "authorizer_db_health_check_total", + Help: "Total number of database health checks by result", + }, + []string{"status"}, + ) ) // Init registers all metrics with the default prometheus registry. +// It is safe to call multiple times; registration happens only once. func Init() { - prometheus.MustRegister(HTTPRequestsTotal) - prometheus.MustRegister(HTTPRequestDuration) - prometheus.MustRegister(AuthEventsTotal) - prometheus.MustRegister(ActiveSessions) + initOnce.Do(func() { + prometheus.MustRegister(HTTPRequestsTotal) + prometheus.MustRegister(HTTPRequestDuration) + prometheus.MustRegister(AuthEventsTotal) + prometheus.MustRegister(ActiveSessions) + prometheus.MustRegister(SecurityEventsTotal) + prometheus.MustRegister(GraphQLErrorsTotal) + prometheus.MustRegister(GraphQLRequestDuration) + prometheus.MustRegister(DBHealthCheckTotal) + }) +} + +// RecordAuthEvent records an authentication event with given status. +func RecordAuthEvent(event, status string) { + AuthEventsTotal.WithLabelValues(event, status).Inc() +} + +// RecordSecurityEvent records a security-relevant event for alerting. +func RecordSecurityEvent(event, reason string) { + SecurityEventsTotal.WithLabelValues(event, reason).Inc() +} + +// RecordGraphQLError records a GraphQL error for the given operation. +func RecordGraphQLError(operation string) { + GraphQLErrorsTotal.WithLabelValues(operation).Inc() } From 2e61749b58f8df8cc2a3be29257de6dbf68f9ffa Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Fri, 3 Apr 2026 16:01:35 +0530 Subject: [PATCH 3/3] feat: add audit logs and metrics to OAuth/token HTTP handlers - oauth_login.go: Add AuditOAuthLoginInitiatedEvent audit log + metrics - oauth_callback.go: Add AuditOAuthCallbackFailedEvent for error paths (user info failure, revoked account) + success/failure metrics - token.go: Add security metric for invalid_client + token success metrics - revoke_refresh_token.go: Add EventTokenRevoke metric on success - logout.go: Add EventLogout metric + ActiveSessions decrement - verify_email.go: Add AuditEmailVerifiedEvent audit log + metrics --- internal/http_handlers/logout.go | 3 +++ internal/http_handlers/oauth_callback.go | 25 +++++++++++++++++++ internal/http_handlers/oauth_login.go | 13 ++++++++++ .../http_handlers/revoke_refresh_token.go | 2 ++ internal/http_handlers/token.go | 7 ++++++ internal/http_handlers/verify_email.go | 14 +++++++++++ 6 files changed, 64 insertions(+) diff --git a/internal/http_handlers/logout.go b/internal/http_handlers/logout.go index d2c3df1c..de9aec60 100644 --- a/internal/http_handlers/logout.go +++ b/internal/http_handlers/logout.go @@ -11,6 +11,7 @@ import ( "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/cookie" "github.com/authorizerdev/authorizer/internal/crypto" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/parsers" "github.com/authorizerdev/authorizer/internal/token" "github.com/authorizerdev/authorizer/internal/utils" @@ -60,6 +61,8 @@ func (h *httpProvider) LogoutHandler() gin.HandlerFunc { h.MemoryStoreProvider.DeleteUserSession(sessionToken, sessionData.Nonce) cookie.DeleteSession(gc, h.Config.AppCookieSecure) + metrics.RecordAuthEvent(metrics.EventLogout, metrics.StatusSuccess) + metrics.ActiveSessions.Dec() h.AuditProvider.LogEvent(audit.Event{ Action: constants.AuditSessionTerminatedEvent, ActorID: userID, diff --git a/internal/http_handlers/oauth_callback.go b/internal/http_handlers/oauth_callback.go index 689946c7..d8ed0a52 100644 --- a/internal/http_handlers/oauth_callback.go +++ b/internal/http_handlers/oauth_callback.go @@ -20,6 +20,7 @@ import ( "github.com/authorizerdev/authorizer/internal/audit" "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/cookie" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/parsers" "github.com/authorizerdev/authorizer/internal/refs" "github.com/authorizerdev/authorizer/internal/storage/schemas" @@ -124,6 +125,16 @@ func (h *httpProvider) OAuthCallbackHandler() gin.HandlerFunc { if err != nil { log.Debug().Err(err).Msg("Failed to process user info") + metrics.RecordAuthEvent(metrics.EventOAuthCallback, metrics.StatusFailure) + metrics.RecordSecurityEvent("oauth_callback_failed", provider) + h.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditOAuthCallbackFailedEvent, + ActorType: constants.AuditActorTypeUser, + ResourceType: constants.AuditResourceTypeSession, + Metadata: provider, + IPAddress: utils.GetIP(ctx.Request), + UserAgent: utils.GetUserAgent(ctx.Request), + }) ctx.JSON(400, gin.H{"error": err.Error()}) return } @@ -176,6 +187,18 @@ func (h *httpProvider) OAuthCallbackHandler() gin.HandlerFunc { } else { if existingUser.RevokedTimestamp != nil { log.Debug().Msg("User access has been revoked") + metrics.RecordAuthEvent(metrics.EventOAuthCallback, metrics.StatusFailure) + metrics.RecordSecurityEvent("account_revoked", "oauth_callback") + h.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditOAuthCallbackFailedEvent, + ActorID: existingUser.ID, + ActorType: constants.AuditActorTypeUser, + ActorEmail: refs.StringValue(existingUser.Email), + ResourceType: constants.AuditResourceTypeSession, + Metadata: provider, + IPAddress: utils.GetIP(ctx.Request), + UserAgent: utils.GetUserAgent(ctx.Request), + }) ctx.JSON(400, gin.H{"error": "user access has been revoked"}) return } @@ -355,6 +378,8 @@ func (h *httpProvider) OAuthCallbackHandler() gin.HandlerFunc { } // remove state from store go h.MemoryStoreProvider.RemoveState(state) + metrics.RecordAuthEvent(metrics.EventOAuthCallback, metrics.StatusSuccess) + metrics.ActiveSessions.Inc() h.AuditProvider.LogEvent(audit.Event{ Action: constants.AuditOAuthCallbackSuccessEvent, ActorID: user.ID, diff --git a/internal/http_handlers/oauth_login.go b/internal/http_handlers/oauth_login.go index 487573d9..f207e5e7 100644 --- a/internal/http_handlers/oauth_login.go +++ b/internal/http_handlers/oauth_login.go @@ -7,7 +7,11 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/authorizerdev/authorizer/internal/audit" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/parsers" + "github.com/authorizerdev/authorizer/internal/utils" "github.com/authorizerdev/authorizer/internal/validators" ) @@ -93,6 +97,15 @@ func (h *httpProvider) OAuthLoginHandler() gin.HandlerFunc { } url := cfg.AuthCodeURL(oauthStateString) log.Debug().Str("url", url).Msg("redirecting to oauth provider") + metrics.RecordAuthEvent(metrics.EventOAuthLogin, metrics.StatusSuccess) + h.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditOAuthLoginInitiatedEvent, + ActorType: constants.AuditActorTypeUser, + ResourceType: constants.AuditResourceTypeSession, + Metadata: provider, + IPAddress: utils.GetIP(c.Request), + UserAgent: utils.GetUserAgent(c.Request), + }) c.Redirect(http.StatusTemporaryRedirect, url) } } diff --git a/internal/http_handlers/revoke_refresh_token.go b/internal/http_handlers/revoke_refresh_token.go index 50f2d5e1..389a2b27 100644 --- a/internal/http_handlers/revoke_refresh_token.go +++ b/internal/http_handlers/revoke_refresh_token.go @@ -6,6 +6,7 @@ import ( "github.com/authorizerdev/authorizer/internal/audit" "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/utils" "github.com/gin-gonic/gin" ) @@ -137,6 +138,7 @@ func (h *httpProvider) RevokeRefreshTokenHandler() gin.HandlerFunc { }) return } + metrics.RecordAuthEvent(metrics.EventTokenRevoke, metrics.StatusSuccess) h.AuditProvider.LogEvent(audit.Event{ Action: constants.AuditTokenRevokedEvent, ActorID: userID, diff --git a/internal/http_handlers/token.go b/internal/http_handlers/token.go index 8e6e2be5..bf824343 100644 --- a/internal/http_handlers/token.go +++ b/internal/http_handlers/token.go @@ -13,6 +13,7 @@ import ( "github.com/authorizerdev/authorizer/internal/audit" "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/cookie" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/parsers" "github.com/authorizerdev/authorizer/internal/refs" "github.com/authorizerdev/authorizer/internal/token" @@ -84,6 +85,7 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { if h.Config.ClientID != clientID { log.Debug().Str("client_id", clientID).Msg("Client ID is invalid") + metrics.RecordSecurityEvent("invalid_client", "token_endpoint") // RFC 6749 ยง5.2: If client auth fails via HTTP Basic, return 401 if _, _, hasBasicAuth := gc.Request.BasicAuth(); hasBasicAuth { gc.Header("WWW-Authenticate", "Basic realm=\"authorizer\"") @@ -345,6 +347,11 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { res["refresh_token"] = authToken.RefreshToken.Token h.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeRefreshToken+"_"+authToken.FingerPrint, authToken.RefreshToken.Token, authToken.RefreshToken.ExpiresAt) } + if isRefreshTokenGrant { + metrics.RecordAuthEvent(metrics.EventTokenRefresh, metrics.StatusSuccess) + } else { + metrics.RecordAuthEvent(metrics.EventTokenRefresh, metrics.StatusSuccess) + } auditAction := constants.AuditTokenIssuedEvent if isRefreshTokenGrant { auditAction = constants.AuditTokenRefreshedEvent diff --git a/internal/http_handlers/verify_email.go b/internal/http_handlers/verify_email.go index 6e4cdd39..7e6c5b1a 100644 --- a/internal/http_handlers/verify_email.go +++ b/internal/http_handlers/verify_email.go @@ -9,8 +9,10 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/authorizerdev/authorizer/internal/audit" "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/cookie" + "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/parsers" "github.com/authorizerdev/authorizer/internal/refs" "github.com/authorizerdev/authorizer/internal/storage/schemas" @@ -211,6 +213,18 @@ func (h *httpProvider) VerifyEmailHandler() gin.HandlerFunc { redirectURL = redirectURL + "?" + strings.TrimPrefix(params, "&") } + metrics.RecordAuthEvent(metrics.EventVerifyEmail, metrics.StatusSuccess) + metrics.ActiveSessions.Inc() + h.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditEmailVerifiedEvent, + ActorID: user.ID, + ActorType: constants.AuditActorTypeUser, + ActorEmail: refs.StringValue(user.Email), + ResourceType: constants.AuditResourceTypeUser, + ResourceID: user.ID, + IPAddress: utils.GetIP(c.Request), + UserAgent: utils.GetUserAgent(c.Request), + }) go func() { if isSignUp { h.EventsProvider.RegisterEvent(c, constants.UserSignUpWebhookEvent, loginMethod, user)