Compare commits

...

10 Commits

Author SHA1 Message Date
Hein a172c73ab0 fix(handler): update default sort retrieval logic
Tests / Unit Tests (push) Failing after 10s
Tests / Integration Tests (push) Failing after 13s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m5s
Build , Vet Test, and Lint / Lint Code (push) Failing after 5m44s
Build , Vet Test, and Lint / Build (push) Successful in 7m12s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 8m11s
2026-07-29 10:23:08 +02:00
Hein ef28959c4d test(types): add test for ResolveSortColumns with descending order 2026-07-29 10:15:52 +02:00
Hein 7c737afc5a feat(handler): implement default sort configuration for models
* Add SetDefaultSort method to configure default sort order
* Implement getDefaultSort method to retrieve configured defaults
* Update handleRead to apply default sort when none specified
* Add tests for default sort functionality
2026-07-29 10:05:24 +02:00
Hein a70e3e02d0 feat(security): complete OAuth2/OIDC spec coverage in OAuthServer
Add client_secret_basic/client_secret_post client authentication, the
client_credentials grant (RFC 6749 §4.4, backed by a synthetic
service-account user so it reuses the existing session/introspection/RLS
pipeline unchanged), RFC 9728 protected resource metadata, and OIDC
discovery + JWKS + id_token/userinfo support.

Remove plan_oauth.md, which was only meant as a working handoff doc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 09:45:09 +02:00
Hein cec8eb5c0f Merge branch 'main' of https://github.com/bitechdev/ResolveSpec
Tests / Integration Tests (push) Failing after 16s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 51s
Tests / Unit Tests (push) Failing after 11s
Build , Vet Test, and Lint / Build (push) Successful in 1m23s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 1m35s
Build , Vet Test, and Lint / Lint Code (push) Successful in 1m35s
2026-07-28 17:48:14 +02:00
Hein 06fa3198f2 feat(security): implement OAuth2 client authentication and grants
* Add client authentication methods and client_credentials grant support
* Introduce RFC 9728 Protected Resource Metadata endpoint
* Implement OIDC discovery and id_token issuance
* Update database schema for new client fields
* Add new HTTP handlers for metadata and userinfo
2026-07-28 17:48:09 +02:00
warkanum 52d3dca1fa feat(hooks): add BeforeOp hook and fix BeforeScan row-security gap
Tests / Unit Tests (push) Failing after 11s
Tests / Integration Tests (push) Failing after 15s
Build , Vet Test, and Lint / Build (push) Successful in 1m45s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m0s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m0s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m4s
Adds a BeforeOp HookType across resolvespec, restheadspec, websocketspec,
mqttspec, and funcspec that fires before every SQL operation (read,
create, update, delete, scan/query) via a new ExecuteBeforeOp helper.

Also closes a row-level-security gap: resolvespec registered a BeforeScan
hook for ApplyRowSecurity but never fired it, and websocketspec/mqttspec
had no BeforeScan hook point at all, so row security was never applied
to their queries. BeforeScan now fires right before the actual scan in
all three, with the (possibly hook-modified) query used for execution.
2026-07-25 11:39:31 +02:00
Hein 873e8925d4 fix(handler): ensure target ID is provided for updates
Tests / Unit Tests (push) Failing after 9s
Tests / Integration Tests (push) Failing after 12s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 48s
Build , Vet Test, and Lint / Build (push) Successful in 1m18s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 1m27s
Build , Vet Test, and Lint / Lint Code (push) Successful in 1m28s
2026-07-24 16:35:22 +02:00
Hein b23916048a fix: ordering in before hook 2026-07-24 15:56:23 +02:00
Hein 47708fc87a fix(security): run RLS-scoping hooks in the same transaction as their queries
Tests / Unit Tests (push) Failing after 12s
Tests / Integration Tests (push) Failing after 16s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 51s
Build , Vet Test, and Lint / Build (push) Successful in 1m24s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 1m31s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m4s
BeforeRead/BeforeCreate hooks (used to set session-scoped RLS GUCs) were
firing against the pooled db handle while the actual queries ran as
separate calls to the same pool. Under connection pooling these could
land on different physical connections, silently bypassing row-level
security on creates and reads. handleUpdate already did this correctly;
handleRead/handleCreate in both resolvespec and restheadspec now wrap
hook execution and queries in a single RunInTransaction call.
2026-07-24 12:40:27 +02:00
24 changed files with 2364 additions and 1031 deletions
+2 -2
View File
@@ -8,6 +8,7 @@ require (
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/getsentry/sentry-go v0.46.2
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
@@ -61,7 +62,6 @@ require (
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.5.1+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
@@ -144,12 +144,12 @@ require (
go.uber.org/multierr v1.11.0 // 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-20260508232706-74f9aab9d74a // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 // indirect
google.golang.org/grpc v1.81.1 // indirect
+23 -143
View File
@@ -5,43 +5,35 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
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/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I=
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8=
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -68,14 +60,13 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
@@ -94,12 +85,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo=
github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns=
github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
@@ -115,16 +102,13 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
@@ -137,8 +121,6 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
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/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@@ -152,8 +134,6 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
@@ -164,8 +144,6 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
@@ -183,10 +161,10 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
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.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -200,21 +178,13 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mark3labs/mcp-go v0.46.0 h1:8KRibF4wcKejbLsHxCA/QBVUr5fQ9nwz/n8lGqmaALo=
github.com/mark3labs/mcp-go v0.46.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
github.com/mark3labs/mcp-go v0.54.0 h1:PZhQvd+5xrT43cUoiaKn/hDcvLUhcLc1twSEKYPTcTA=
github.com/mark3labs/mcp-go v0.54.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -237,20 +207,14 @@ github.com/mochi-mqtt/server/v2 v2.7.9 h1:y0g4vrSLAag7T07l2oCzOa/+nKVLoazKEWAArw
github.com/mochi-mqtt/server/v2 v2.7.9/go.mod h1:lZD3j35AVNqJL5cezlnSkuG05c0FCHSsfAKSPBOSbqc=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ=
github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -261,8 +225,6 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
@@ -281,29 +243,20 @@ 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.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
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.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
@@ -344,8 +297,6 @@ github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSW
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
@@ -364,22 +315,10 @@ github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYm
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/uptrace/bun/dialect/mssqldialect v1.2.16 h1:rKv0cKPNBviXadB/+2Y/UedA/c1JnwGzUWZkdN5FdSQ=
github.com/uptrace/bun/dialect/mssqldialect v1.2.16/go.mod h1:J5U7tGKWDsx2Q7MwDZF2417jCdpD6yD/ZMFJcCR80bk=
github.com/uptrace/bun/dialect/mssqldialect v1.2.17 h1:xEUH4WamuY9rXT9d8wHVZanhmLJCrc4s4v7frDH/PMc=
github.com/uptrace/bun/dialect/mssqldialect v1.2.17/go.mod h1:i1NRx/5cz1nivwtV7FEb/gP3CIbRTj4AQC9/Q0lNVno=
github.com/uptrace/bun/dialect/mssqldialect v1.2.18 h1:nYzHoyJKJlIyl5i95Exi8ZTK8ooKWG+o3z3f404d/yQ=
github.com/uptrace/bun/dialect/mssqldialect v1.2.18/go.mod h1:Su45Je7z66sfeZ3d1ZsnOQEK8xfzGgaMzBvtoE8yFhk=
github.com/uptrace/bun/dialect/pgdialect v1.2.16 h1:KFNZ0LxAyczKNfK/IJWMyaleO6eI9/Z5tUv3DE1NVL4=
github.com/uptrace/bun/dialect/pgdialect v1.2.16/go.mod h1:IJdMeV4sLfh0LDUZl7TIxLI0LipF1vwTK3hBC7p5qLo=
github.com/uptrace/bun/dialect/pgdialect v1.2.17 h1:DFmhOollvbYHvooxoS8ZIbiGC0wXIzstKeFUmWs+TP4=
github.com/uptrace/bun/dialect/pgdialect v1.2.17/go.mod h1:ej8ZDsvLETvyELlRDfUtIoA57sWnATv1GhOEVsuVG/k=
github.com/uptrace/bun/dialect/pgdialect v1.2.18 h1:IZ6nM2+OYrL8lkEAy7UkSEZvoa3vluTAUlZfPtlRB2k=
github.com/uptrace/bun/dialect/pgdialect v1.2.18/go.mod h1:Tqdf4QP1okrGYpXfodXvCOK6Ob1OOTwSaoAzCgBB3IU=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.16 h1:6wVAiYLj1pMibRthGwy4wDLa3D5AQo32Y8rvwPd8CQ0=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.16/go.mod h1:Z7+5qK8CGZkDQiPMu+LSdVuDuR1I5jcwtkB1Pi3F82E=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.17 h1:ZipEoNr+wQJQleGy2poKSSoaQDavzc+nXTDp3ZzkA0E=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.17/go.mod h1:phXmrxxeYqUhMU09FgazbfNxq9LlArdqjZqHc1ILy9U=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.18 h1:Z33SY/U++XK9uGWqS4h8OZVxfCXguIG+sU9cYq2PGFQ=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.18/go.mod h1:1MVOS/Ncy4FZbkJcgUFH6OqYoQinYNjkEwsmNQEXz2A=
github.com/uptrace/bun/driver/sqliteshim v1.2.16 h1:M6Dh5kkDWFbUWBrOsIE1g1zdZ5JbSytTD4piFRBOUAI=
github.com/uptrace/bun/driver/sqliteshim v1.2.16/go.mod h1:iKdJ06P3XS+pwKcONjSIK07bbhksH3lWsw3mpfr0+bY=
github.com/uptrace/bunrouter v1.0.23 h1:Bi7NKw3uCQkcA/GUCtDNPq5LE5UdR9pe+UyWbjHB/wU=
@@ -403,47 +342,30 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
@@ -452,12 +374,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
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.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
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=
@@ -474,22 +392,14 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
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/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -509,12 +419,8 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -524,8 +430,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -551,8 +455,6 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
@@ -570,9 +472,8 @@ golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
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/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -587,12 +488,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
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/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -601,24 +498,16 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 h1:DddG61lE5LkX6144z22i0gma9BMBs5aZ9B8lZLobxyw=
google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 h1:eZCjr/aAF8c5ccm5pb6T4EXgIei5MlAAPWPJk+5ArfY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
@@ -644,37 +533,28 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+24
View File
@@ -90,6 +90,30 @@ type SortOption struct {
Direction string `json:"direction"`
}
// PrimaryKeySortColumn is a sentinel SortOption.Column value that gets
// resolved to a model's actual primary key column name at query time.
// This lets a single global default sort (e.g. set once via
// Handler.SetDefaultSort) work across models with different primary keys,
// e.g. common.SortOption{Column: common.PrimaryKeySortColumn, Direction: "asc"}.
const PrimaryKeySortColumn = "$pk"
// ResolveSortColumns returns a copy of sort with any PrimaryKeySortColumn
// entries replaced by pkName. If pkName is empty, matching entries are
// dropped since there is no column to sort by.
func ResolveSortColumns(sort []SortOption, pkName string) []SortOption {
resolved := make([]SortOption, 0, len(sort))
for _, s := range sort {
if s.Column == PrimaryKeySortColumn {
if pkName == "" {
continue
}
s.Column = pkName
}
resolved = append(resolved, s)
}
return resolved
}
type CustomOperator struct {
Name string `json:"name"`
SQL string `json:"sql"`
+58
View File
@@ -0,0 +1,58 @@
package common
import (
"reflect"
"testing"
)
func TestResolveSortColumns(t *testing.T) {
sort := []SortOption{
{Column: PrimaryKeySortColumn, Direction: "asc"},
{Column: "name", Direction: "desc"},
}
got := ResolveSortColumns(sort, "user_id")
want := []SortOption{
{Column: "user_id", Direction: "asc"},
{Column: "name", Direction: "desc"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("ResolveSortColumns() = %v, want %v", got, want)
}
// Original slice must not be mutated
if sort[0].Column != PrimaryKeySortColumn {
t.Errorf("ResolveSortColumns() mutated input slice: %v", sort)
}
}
func TestResolveSortColumnsDesc(t *testing.T) {
sort := []SortOption{
{Column: PrimaryKeySortColumn, Direction: "desc"},
}
got := ResolveSortColumns(sort, "id")
want := []SortOption{{Column: "id", Direction: "desc"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("ResolveSortColumns() = %v, want %v", got, want)
}
}
func TestResolveSortColumnsEmptyPK(t *testing.T) {
sort := []SortOption{
{Column: PrimaryKeySortColumn, Direction: "asc"},
{Column: "name", Direction: "desc"},
}
got := ResolveSortColumns(sort, "")
want := []SortOption{{Column: "name", Direction: "desc"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("ResolveSortColumns() with empty pk = %v, want %v", got, want)
}
}
func TestResolveSortColumnsNil(t *testing.T) {
if got := ResolveSortColumns(nil, "id"); len(got) != 0 {
t.Errorf("ResolveSortColumns(nil) = %v, want empty", got)
}
}
+4 -4
View File
@@ -197,7 +197,7 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
hookCtx.Tx = tx
// Execute BeforeQueryList hook (inside transaction)
if err := h.hooks.Execute(BeforeQueryList, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeQueryList, hookCtx); err != nil {
logger.Error("BeforeQueryList hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return err
@@ -261,7 +261,7 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
// Execute BeforeSQLExec hook
hookCtx.SQLQuery = sqlquery
if err := h.hooks.Execute(BeforeSQLExec, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeSQLExec, hookCtx); err != nil {
logger.Error("BeforeSQLExec hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return err
@@ -563,7 +563,7 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
hookCtx.Tx = tx
// Execute BeforeQuery hook (inside transaction)
if err := h.hooks.Execute(BeforeQuery, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeQuery, hookCtx); err != nil {
logger.Error("BeforeQuery hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return err
@@ -582,7 +582,7 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
sqlquery = hookCtx.SQLQuery
// Execute BeforeSQLExec hook
if err := h.hooks.Execute(BeforeSQLExec, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeSQLExec, hookCtx); err != nil {
logger.Error("BeforeSQLExec hook failed: %v", err)
sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return err
+14
View File
@@ -28,6 +28,10 @@ const (
// Response hooks (before response is sent)
BeforeResponse HookType = "before_response"
// BeforeOp fires immediately before every SQL operation (query, query list, SQL exec).
// It fires at each individual SQL-operation hook point, so it runs once per statement executed.
BeforeOp HookType = "before_op"
)
// HookContext contains all the data available to a hook
@@ -130,6 +134,16 @@ func (r *HookRegistry) Execute(hookType HookType, ctx *HookContext) error {
return nil
}
// ExecuteBeforeOp executes the BeforeOp hook followed by the given SQL-operation hook
// (BeforeQuery, BeforeQueryList, or BeforeSQLExec). BeforeOp always runs first so it can
// observe/veto every SQL operation regardless of type.
func (r *HookRegistry) ExecuteBeforeOp(hookType HookType, ctx *HookContext) error {
if err := r.Execute(BeforeOp, ctx); err != nil {
return err
}
return r.Execute(hookType, ctx)
}
// Clear removes all hooks for the specified type
func (r *HookRegistry) Clear(hookType HookType) {
delete(r.hooks, hookType)
+28 -4
View File
@@ -313,7 +313,7 @@ func (h *Handler) handleRequest(client *Client, msg *Message) {
// handleRead processes a read operation
func (h *Handler) handleRead(client *Client, msg *Message, hookCtx *HookContext) {
// Execute before hook
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeRead, hookCtx); err != nil {
logger.Error("[MQTTSpec] BeforeRead hook failed: %v", err)
h.sendError(client.ID, msg.ID, "hook_error", err.Error())
return
@@ -356,7 +356,7 @@ func (h *Handler) handleRead(client *Client, msg *Message, hookCtx *HookContext)
// handleCreate processes a create operation
func (h *Handler) handleCreate(client *Client, msg *Message, hookCtx *HookContext) {
// Execute before hook
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
logger.Error("[MQTTSpec] BeforeCreate hook failed: %v", err)
h.sendError(client.ID, msg.ID, "hook_error", err.Error())
return
@@ -390,7 +390,7 @@ func (h *Handler) handleCreate(client *Client, msg *Message, hookCtx *HookContex
// handleUpdate processes an update operation
func (h *Handler) handleUpdate(client *Client, msg *Message, hookCtx *HookContext) {
// Execute before hook
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
logger.Error("[MQTTSpec] BeforeUpdate hook failed: %v", err)
h.sendError(client.ID, msg.ID, "hook_error", err.Error())
return
@@ -424,7 +424,7 @@ func (h *Handler) handleUpdate(client *Client, msg *Message, hookCtx *HookContex
// handleDelete processes a delete operation
func (h *Handler) handleDelete(client *Client, msg *Message, hookCtx *HookContext) {
// Execute before hook
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("[MQTTSpec] BeforeDelete hook failed: %v", err)
h.sendError(client.ID, msg.ID, "hook_error", err.Error())
return
@@ -686,6 +686,18 @@ func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) {
}
}
// Execute BeforeScan hooks - pass query so hooks (e.g. row-level security) can modify it
if hookCtx.Metadata == nil {
hookCtx.Metadata = make(map[string]interface{})
}
hookCtx.Metadata["query"] = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
return nil, fmt.Errorf("BeforeScan hook failed: %w", err)
}
if modifiedQuery, ok := hookCtx.Metadata["query"].(common.SelectQuery); ok {
query = modifiedQuery
}
// Execute query
if err := query.ScanModel(hookCtx.Context); err != nil {
return nil, fmt.Errorf("failed to read record: %w", err)
@@ -738,6 +750,18 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
}
}
// Execute BeforeScan hooks - pass query so hooks (e.g. row-level security) can modify it
if hookCtx.Metadata == nil {
hookCtx.Metadata = make(map[string]interface{})
}
hookCtx.Metadata["query"] = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
return nil, nil, fmt.Errorf("BeforeScan hook failed: %w", err)
}
if modifiedQuery, ok := hookCtx.Metadata["query"].(common.SelectQuery); ok {
query = modifiedQuery
}
// Execute query
if err := query.ScanModel(hookCtx.Context); err != nil {
return nil, nil, fmt.Errorf("failed to read records: %w", err)
+4
View File
@@ -34,6 +34,7 @@ const (
AfterUpdate = websocketspec.AfterUpdate
BeforeDelete = websocketspec.BeforeDelete
AfterDelete = websocketspec.AfterDelete
BeforeScan = websocketspec.BeforeScan
// Subscription hooks
BeforeSubscribe = websocketspec.BeforeSubscribe
@@ -46,6 +47,9 @@ const (
AfterConnect = websocketspec.AfterConnect
BeforeDisconnect = websocketspec.BeforeDisconnect
AfterDisconnect = websocketspec.AfterDisconnect
// BeforeOp fires immediately before every SQL operation (read, create, update, delete)
BeforeOp = websocketspec.BeforeOp
)
// NewHookRegistry creates a new hook registry
+10 -4
View File
@@ -27,25 +27,31 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
return security.LoadSecurityRules(secCtx, securityList)
})
// Hook 2: AfterRead - Apply column-level security (masking)
// Hook 2: BeforeScan - Apply row-level security filters
handler.Hooks().Register(BeforeScan, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.ApplyRowSecurity(secCtx, securityList)
})
// Hook 3: AfterRead - Apply column-level security (masking)
handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.ApplyColumnSecurity(secCtx, securityList)
})
// Hook 3 (Optional): Audit logging
// Hook 4 (Optional): Audit logging
handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.LogDataAccess(secCtx)
})
// Hook 4: BeforeUpdate - enforce CanUpdate rule from context/registry
// Hook 5: BeforeUpdate - enforce CanUpdate rule from context/registry
handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.CheckModelUpdateAllowed(secCtx)
})
// Hook 5: BeforeDelete - enforce CanDelete rule from context/registry
// Hook 6: BeforeDelete - enforce CanDelete rule from context/registry
handler.Hooks().Register(BeforeDelete, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.CheckModelDeleteAllowed(secCtx)
+322 -123
View File
@@ -30,6 +30,7 @@ type Handler struct {
hooks *HookRegistry
fallbackHandler FallbackHandler
openAPIGenerator func() (string, error)
defaultSort map[string][]common.SortOption
}
// NewHandler creates a new API handler with database and registry abstractions
@@ -56,6 +57,32 @@ func (h *Handler) SetFallbackHandler(fallback FallbackHandler) {
h.fallbackHandler = fallback
}
// SetDefaultSort sets the sort order applied to list ("read") results when a
// request does not specify its own sort. Pass an empty schema and name to set
// a global default used by any model without a more specific default.
func (h *Handler) SetDefaultSort(schema, name string, sort ...common.SortOption) {
if h.defaultSort == nil {
h.defaultSort = make(map[string][]common.SortOption)
}
h.defaultSort[defaultSortKey(schema, name)] = sort
}
// getDefaultSort returns the configured default sort for a model, falling
// back to the global default (registered with an empty schema and name).
func (h *Handler) getDefaultSort(schema, name string) []common.SortOption {
if h.defaultSort == nil {
return nil
}
if sort := h.defaultSort[defaultSortKey(schema, name)]; len(sort) > 0 {
return sort
}
return h.defaultSort[defaultSortKey("", "")]
}
func defaultSortKey(schema, name string) string {
return fmt.Sprintf("%s.%s", schema, name)
}
// GetDatabase returns the underlying database connection
// Implements common.SpecHandler interface
func (h *Handler) GetDatabase() common.Database {
@@ -259,9 +286,44 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
sliceType := reflect.SliceOf(reflect.PointerTo(modelType))
modelPtr := reflect.New(sliceType).Interface()
// Everything below runs inside a single transaction so that the BeforeRead
// hook (which sets session-scoped RLS GUCs via SET LOCAL) executes on the
// same physical connection as the queries it is meant to protect. Under
// connection pooling, firing the hook against h.db and then querying
// against h.db again may hand out two different connections, silently
// bypassing RLS.
var (
result interface{}
total int
rowNumber *int64
limit int
offset int
statusCode int
errCode string
errMsg string
)
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: id,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeRead, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusInternalServerError, "hook_error", "BeforeRead hook failed"
return err
}
options = hookCtx.Options
// Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count()
// Bun's Model() accepts both single pointers and slice pointers
query := h.db.NewSelect().Model(modelPtr)
query := tx.NewSelect().Model(modelPtr)
// Only set Table() if the model doesn't provide a table name via the underlying type
// Create a temporary instance to check for TableNameProvider
@@ -296,8 +358,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
query, err = h.applyPreloads(model, query, options.Preload)
if err != nil {
logger.Error("Failed to apply preloads: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_preload", "Failed to apply preloads", err)
return
statusCode, errCode, errMsg = http.StatusBadRequest, "invalid_preload", "Failed to apply preloads"
return err
}
}
@@ -310,6 +372,11 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
query = query.Where(customOp.SQL)
}
// Fall back to the configured default sort when the request didn't specify one
if len(options.Sort) == 0 {
options.Sort = common.ResolveSortColumns(h.getDefaultSort(schema, entity), reflection.GetPrimaryKeyName(model))
}
// Apply sorting
for _, sort := range options.Sort {
direction := "ASC"
@@ -339,8 +406,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
cursorFilter, err := GetCursorFilter(tableName, pkName, modelColumns, options, nil)
if err != nil {
logger.Error("Error building cursor filter: %v", err)
h.sendError(w, http.StatusBadRequest, "cursor_error", "Invalid cursor pagination", err)
return
statusCode, errCode, errMsg = http.StatusBadRequest, "cursor_error", "Invalid cursor pagination"
return err
}
// Apply cursor filter to query
@@ -355,9 +422,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
}
}
// Get total count before pagination
var total int
// Try to get from cache first
// Use extended cache key if cursors are present
var cacheKeyHash string
@@ -384,8 +448,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Try to retrieve from cache
var cachedTotal cachedTotal
err := cache.GetDefaultCache().Get(ctx, cacheKey, &cachedTotal)
if err == nil {
if err := cache.GetDefaultCache().Get(ctx, cacheKey, &cachedTotal); err == nil {
total = cachedTotal.Total
logger.Debug("Total records (from cache): %d", total)
} else {
@@ -394,8 +457,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
count, err := query.Count(ctx)
if err != nil {
logger.Error("Error counting records: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error counting records", err)
return
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error counting records"
return err
}
total = count
logger.Debug("Total records (from query): %d", total)
@@ -411,7 +474,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
}
// Handle FetchRowNumber if requested
var rowNumber *int64
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
logger.Debug("Fetching row number for ID: %s", *options.FetchRowNumber)
pkName := reflection.GetPrimaryKeyName(model)
@@ -439,7 +501,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
RowNum int64 `bun:"row_num"`
}
rowNumQuery := h.db.NewSelect().Table(tableName).
rowNumQuery := tx.NewSelect().Table(tableName).
ColumnExpr(fmt.Sprintf("%s AS row_num", rowNumberSQL)).
Column(pkName)
@@ -457,8 +519,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
rowNumQuery = rowNumQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), *options.FetchRowNumber)
// Execute query to get row number
var result RowNumResult
if err := rowNumQuery.Scan(ctx, &result); err != nil {
var rnResult RowNumResult
if err := rowNumQuery.Scan(ctx, &rnResult); err != nil {
if err == sql.ErrNoRows {
// Build filter description for error message
filterInfo := fmt.Sprintf("filters: %d", len(options.Filters))
@@ -474,7 +536,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Warn("Error fetching row number: %v", err)
}
} else {
rowNumber = &result.RowNum
rowNumber = &rnResult.RowNum
logger.Debug("Found row number: %d", *rowNumber)
}
}
@@ -492,7 +554,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
}
// Execute query
var result interface{}
if id != "" || (options.FetchRowNumber != nil && *options.FetchRowNumber != "") {
// Single record query - either by URL ID or FetchRowNumber
var targetID string
@@ -509,28 +570,56 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
pkName := reflection.GetPrimaryKeyName(singleResult)
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
// Execute BeforeScan hooks - pass query chain so hooks (e.g. row-level security) can modify it
hookCtx.Query = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err)
statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
return err
}
query = hookCtx.Query
if err := query.Scan(ctx, singleResult); err != nil {
logger.Error("Error querying record: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err)
return
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
return err
}
result = singleResult
} else {
logger.Debug("Querying multiple records")
// Execute BeforeScan hooks - pass query chain so hooks (e.g. row-level security) can modify it
hookCtx.Query = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err)
statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
return err
}
query = hookCtx.Query
// Use the modelPtr already created and set on the query
if err := query.Scan(ctx, modelPtr); err != nil {
logger.Error("Error querying records: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err)
return
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
return err
}
result = reflect.ValueOf(modelPtr).Elem().Interface()
}
logger.Info("Successfully retrieved records")
return nil
})
if txErr != nil {
if statusCode == 0 {
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
}
h.sendError(w, statusCode, errCode, errMsg, txErr)
return
}
// Build metadata
limit := 0
offset := 0
count := int64(total)
// When FetchRowNumber is used, we only return 1 record
@@ -585,54 +674,107 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
// Check if we should use nested processing
if h.shouldUseNestedProcessor(v, model) {
logger.Info("Using nested CUD processor for create operation")
result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", v, model, make(map[string]interface{}), tableName)
var nestedResult *common.ProcessResult
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: v,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
v = modifiedData
}
originalProcessor := h.nestedProcessor
h.nestedProcessor = common.NewNestedCUDProcessor(tx, h.registry, h)
defer func() {
h.nestedProcessor = originalProcessor
}()
var procErr error
nestedResult, procErr = h.nestedProcessor.ProcessNestedCUD(ctx, "insert", v, model, make(map[string]interface{}), tableName)
return procErr
})
if err != nil {
logger.Error("Error in nested create: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record with nested data", err)
return
}
logger.Info("Successfully created record with nested data, ID: %v", result.ID)
logger.Info("Successfully created record with nested data, ID: %v", nestedResult.ID)
// Invalidate cache for this table
cacheTags := buildCacheTags(schema, tableName)
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
}
h.sendResponse(w, result.Data, nil)
h.sendResponse(w, nestedResult.Data, nil)
return
}
// Standard processing without nested relations
pkName := reflection.GetPrimaryKeyName(model)
query := h.db.NewInsert().Table(tableName)
var responseData interface{} = v
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: v,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
v = modifiedData
}
responseData = v
query := tx.NewInsert().Table(tableName)
for key, value := range v {
query = query.Value(key, common.ConvertSliceForBun(value))
}
var responseData interface{} = v
if pkName == "" {
// No PK on model — insert and return input as-is.
result, err := query.Exec(ctx)
if err != nil {
logger.Error("Error creating record: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
return
return err
}
logger.Info("Successfully created record, rows affected: %d", result.RowsAffected())
} else {
return nil
}
var insertedID interface{}
if err := query.Returning(pkName).Scan(ctx, &insertedID); err != nil {
logger.Error("Error creating record: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
return
return err
}
logger.Info("Successfully created record with %s: %v", pkName, insertedID)
fetchedRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
if fetchErr := h.db.NewSelect().Model(fetchedRecord).
if fetchErr := tx.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), insertedID).
ScanModel(ctx); fetchErr == nil {
responseData = mergeWithInput(fetchedRecord, v)
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, insertedID, fetchErr)
}
return nil
})
if err != nil {
logger.Error("Error creating record: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
return
}
// Invalidate cache for this table
cacheTags := buildCacheTags(schema, tableName)
@@ -663,6 +805,24 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
}()
for _, item := range v {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
item = modifiedData
}
result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", item, model, make(map[string]interface{}), tableName)
if err != nil {
return fmt.Errorf("failed to process item: %w", err)
@@ -689,10 +849,27 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
// Standard batch insert without nested relations
pkName := reflection.GetPrimaryKeyName(model)
modelElemType := reflection.GetPointerElement(reflect.TypeOf(model))
originals := make([]map[string]interface{}, 0, len(v))
insertedIDs := make([]interface{}, 0, len(v))
responseItems := make([]interface{}, 0, len(v))
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: item,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
item = modifiedData
}
txQuery := tx.NewInsert().Table(tableName)
for key, value := range item {
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value))
@@ -701,16 +878,22 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if _, err := txQuery.Exec(ctx); err != nil {
return err
}
originals = append(originals, item)
insertedIDs = append(insertedIDs, nil)
responseItems = append(responseItems, item)
continue
}
var returnedID interface{}
if err := txQuery.Returning(pkName).Scan(ctx, &returnedID); err != nil {
return err
}
originals = append(originals, item)
insertedIDs = append(insertedIDs, returnedID)
fetchedRecord := reflect.New(modelElemType).Interface()
if fetchErr := tx.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), returnedID).
ScanModel(ctx); fetchErr == nil {
responseItems = append(responseItems, mergeWithInput(fetchedRecord, item))
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, returnedID, fetchErr)
responseItems = append(responseItems, item)
}
}
return nil
})
@@ -725,23 +908,6 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
}
// Re-fetch each record after transaction commits; fall back to input on failure.
responseItems := make([]interface{}, 0, len(insertedIDs))
for i, pkVal := range insertedIDs {
if pkVal == nil {
responseItems = append(responseItems, originals[i])
continue
}
fetchedRecord := reflect.New(modelElemType).Interface()
if fetchErr := h.db.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), pkVal).
ScanModel(ctx); fetchErr == nil {
responseItems = append(responseItems, mergeWithInput(fetchedRecord, originals[i]))
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, pkVal, fetchErr)
responseItems = append(responseItems, originals[i])
}
}
h.sendResponse(w, responseItems, nil)
case []interface{}:
@@ -770,6 +936,24 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
for _, item := range v {
if itemMap, ok := item.(map[string]interface{}); ok {
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
itemMap = modifiedData
}
result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", itemMap, model, make(map[string]interface{}), tableName)
if err != nil {
return fmt.Errorf("failed to process item: %w", err)
@@ -797,14 +981,32 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
// Standard batch insert without nested relations
pkName := reflection.GetPrimaryKeyName(model)
modelElemType := reflection.GetPointerElement(reflect.TypeOf(model))
originals := make([]map[string]interface{}, 0, len(v))
insertedIDs := make([]interface{}, 0, len(v))
responseItems := make([]interface{}, 0, len(v))
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
for _, item := range v {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
Data: itemMap,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
return fmt.Errorf("BeforeCreate hook failed: %w", err)
}
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
itemMap = modifiedData
}
txQuery := tx.NewInsert().Table(tableName)
for key, value := range itemMap {
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value))
@@ -813,16 +1015,22 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if _, err := txQuery.Exec(ctx); err != nil {
return err
}
originals = append(originals, itemMap)
insertedIDs = append(insertedIDs, nil)
responseItems = append(responseItems, itemMap)
continue
}
var returnedID interface{}
if err := txQuery.Returning(pkName).Scan(ctx, &returnedID); err != nil {
return err
}
originals = append(originals, itemMap)
insertedIDs = append(insertedIDs, returnedID)
fetchedRecord := reflect.New(modelElemType).Interface()
if fetchErr := tx.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), returnedID).
ScanModel(ctx); fetchErr == nil {
responseItems = append(responseItems, mergeWithInput(fetchedRecord, itemMap))
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, returnedID, fetchErr)
responseItems = append(responseItems, itemMap)
}
}
return nil
})
@@ -837,23 +1045,6 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
}
// Re-fetch each record after transaction commits; fall back to input on failure.
responseItems := make([]interface{}, 0, len(insertedIDs))
for i, pkVal := range insertedIDs {
if pkVal == nil {
responseItems = append(responseItems, originals[i])
continue
}
fetchedRecord := reflect.New(modelElemType).Interface()
if fetchErr := h.db.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), pkVal).
ScanModel(ctx); fetchErr == nil {
responseItems = append(responseItems, mergeWithInput(fetchedRecord, originals[i]))
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, pkVal, fetchErr)
responseItems = append(responseItems, originals[i])
}
}
h.sendResponse(w, responseItems, nil)
default:
@@ -917,27 +1108,58 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Get the primary key name
pkName := reflection.GetPrimaryKeyName(model)
if targetID == nil {
logger.Error("Update request for %s.%s has no ID (not in URL, request ID, or data)", schema, entity)
h.sendError(w, http.StatusBadRequest, "missing_id", "No ID provided for update", nil)
return
}
// Wrap in transaction to ensure BeforeUpdate hook is inside transaction
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
// First, read the existing record from the database
// Execute BeforeUpdate hooks inside transaction, before any queries run.
// BeforeUpdate hooks may set session-scoped RLS GUCs (via SET LOCAL);
// they must run before the existence-check select so that select is
// also subject to RLS on this connection/transaction.
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: urlID,
Data: updates,
Writer: w,
Tx: tx,
}
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed: %w", err)
}
// Use potentially modified data from hook context
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
updates = modifiedData
}
// Now read the existing record from the database
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Column(reflection.GetSQLModelColumns(model)...)
// Apply conditions to select
if urlID != "" {
logger.Debug("Updating by URL ID: %s", urlID)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
} else if reqID != nil {
switch id := reqID.(type) {
// Apply conditions to select, based on the resolved target ID
// (URL ID, request ID, or the "id" field embedded in the data payload).
switch id := targetID.(type) {
case string:
logger.Debug("Updating by request ID: %s", id)
logger.Debug("Updating by ID: %s", id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
case []string:
if len(id) > 0 {
logger.Debug("Updating by multiple IDs: %v", id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id)
}
}
default:
logger.Debug("Updating by ID: %v", id)
selectQuery = selectQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
}
if err := selectQuery.ScanModel(ctx); err != nil {
@@ -957,29 +1179,6 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
return fmt.Errorf("error unmarshaling existing record: %w", err)
}
// Execute BeforeUpdate hooks inside transaction
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
Model: model,
Options: options,
ID: urlID,
Data: updates,
Writer: w,
Tx: tx,
}
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed: %w", err)
}
// Use potentially modified data from hook context
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
updates = modifiedData
}
// Merge only non-null and non-empty values from the incoming request into the existing record
for key, newValue := range updates {
// Skip if the value is nil
@@ -999,16 +1198,16 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
// Build update query with merged data
query := tx.NewUpdate().Table(tableName).SetMap(existingMap)
// Apply conditions
if urlID != "" {
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
} else if reqID != nil {
switch id := reqID.(type) {
// Apply conditions, using the same target ID resolved for the select above
switch id := targetID.(type) {
case string:
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
case []string:
if len(id) > 0 {
query = query.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id)
}
default:
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
}
result, err := query.Exec(ctx)
@@ -1155,7 +1354,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
Tx: tx,
}
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed for ID %v: %w", itemID, err)
}
@@ -1311,7 +1510,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
Tx: tx,
}
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed for ID %v: %w", itemID, err)
}
@@ -1414,7 +1613,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Writer: w,
Tx: h.db,
}
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed: %v", err)
h.sendError(w, http.StatusForbidden, "delete_forbidden", "Delete operation not allowed", err)
return
+30
View File
@@ -41,6 +41,36 @@ func TestSetFallbackHandler(t *testing.T) {
}
}
func TestSetDefaultSort(t *testing.T) {
handler := NewHandler(nil, nil)
// No default configured yet
if got := handler.getDefaultSort("public", "users"); got != nil {
t.Errorf("Expected no default sort, got %v", got)
}
// Global default
global := []common.SortOption{{Column: "created_at", Direction: "desc"}}
handler.SetDefaultSort("", "", global...)
if got := handler.getDefaultSort("public", "users"); !reflect.DeepEqual(got, global) {
t.Errorf("Expected global default sort %v, got %v", global, got)
}
// Per-model default overrides the global default
perModel := []common.SortOption{{Column: "name", Direction: "asc"}}
handler.SetDefaultSort("public", "users", perModel...)
if got := handler.getDefaultSort("public", "users"); !reflect.DeepEqual(got, perModel) {
t.Errorf("Expected per-model default sort %v, got %v", perModel, got)
}
// Other models still fall back to the global default
if got := handler.getDefaultSort("public", "orders"); !reflect.DeepEqual(got, global) {
t.Errorf("Expected global default sort %v for unrelated model, got %v", global, got)
}
}
func TestGetDatabase(t *testing.T) {
handler := NewHandler(nil, nil)
db := handler.GetDatabase()
+15
View File
@@ -34,6 +34,11 @@ const (
// Scan/Execute operation hooks (for query building)
BeforeScan HookType = "before_scan"
// BeforeOp fires immediately before every SQL operation (read, create, update, delete, scan).
// Unlike BeforeHandle, which fires once before operation dispatch, BeforeOp fires at each
// individual SQL-operation hook point, so it runs once per statement executed.
BeforeOp HookType = "before_op"
)
// HookContext contains all the data available to a hook
@@ -128,6 +133,16 @@ func (r *HookRegistry) Execute(hookType HookType, ctx *HookContext) error {
return nil
}
// ExecuteBeforeOp executes the BeforeOp hook followed by the given SQL-operation hook
// (BeforeRead, BeforeCreate, BeforeUpdate, BeforeDelete, or BeforeScan). BeforeOp always
// runs first so it can observe/veto every SQL operation regardless of type.
func (r *HookRegistry) ExecuteBeforeOp(hookType HookType, ctx *HookContext) error {
if err := r.Execute(BeforeOp, ctx); err != nil {
return err
}
return r.Execute(hookType, ctx)
}
// Clear removes all hooks for the specified type
func (r *HookRegistry) Clear(hookType HookType) {
delete(r.hooks, hookType)
+38
View File
@@ -0,0 +1,38 @@
package restheadspec
import (
"reflect"
"testing"
"github.com/bitechdev/ResolveSpec/pkg/common"
)
func TestSetDefaultSort(t *testing.T) {
handler := NewHandler(nil, nil)
// No default configured yet
if got := handler.getDefaultSort("public", "users"); got != nil {
t.Errorf("Expected no default sort, got %v", got)
}
// Global default
global := []common.SortOption{{Column: "created_at", Direction: "desc"}}
handler.SetDefaultSort("", "", global...)
if got := handler.getDefaultSort("public", "users"); !reflect.DeepEqual(got, global) {
t.Errorf("Expected global default sort %v, got %v", global, got)
}
// Per-model default overrides the global default
perModel := []common.SortOption{{Column: "name", Direction: "asc"}}
handler.SetDefaultSort("public", "users", perModel...)
if got := handler.getDefaultSort("public", "users"); !reflect.DeepEqual(got, perModel) {
t.Errorf("Expected per-model default sort %v, got %v", perModel, got)
}
// Other models still fall back to the global default
if got := handler.getDefaultSort("public", "orders"); !reflect.DeepEqual(got, global) {
t.Errorf("Expected global default sort %v for unrelated model, got %v", global, got)
}
}
+163 -88
View File
@@ -32,6 +32,7 @@ type Handler struct {
nestedProcessor *common.NestedCUDProcessor
fallbackHandler FallbackHandler
openAPIGenerator func() (string, error)
defaultSort map[string][]common.SortOption
}
// NewHandler creates a new API handler with database and registry abstractions
@@ -64,6 +65,33 @@ func (h *Handler) SetFallbackHandler(fallback FallbackHandler) {
h.fallbackHandler = fallback
}
// SetDefaultSort sets the sort order applied to list ("read") results when a
// request does not specify its own sort (no x-sort header / sort query param).
// Pass an empty schema and name to set a global default used by any model
// without a more specific default.
func (h *Handler) SetDefaultSort(schema, name string, sort ...common.SortOption) {
if h.defaultSort == nil {
h.defaultSort = make(map[string][]common.SortOption)
}
h.defaultSort[defaultSortKey(schema, name)] = sort
}
// getDefaultSort returns the configured default sort for a model, falling
// back to the global default (registered with an empty schema and name).
func (h *Handler) getDefaultSort(schema, name string) []common.SortOption {
if h.defaultSort == nil {
return nil
}
if sort := h.defaultSort[defaultSortKey(schema, name)]; len(sort) > 0 {
return sort
}
return h.defaultSort[defaultSortKey("", "")]
}
func defaultSortKey(schema, name string) string {
return fmt.Sprintf("%s.%s", schema, name)
}
// handlePanic is a helper function to handle panics with stack traces
func (h *Handler) handlePanic(w common.ResponseWriter, method string, err interface{}) {
stack := debug.Stack()
@@ -327,26 +355,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
options.SingleRecordAsObject = false
}
// Execute BeforeRead hooks
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
TableName: tableName,
Model: model,
Options: options,
ID: id,
Writer: w,
Tx: h.db,
}
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil {
logger.Error("BeforeRead hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
}
// Validate and unwrap model type to get base struct
modelType := reflect.TypeOf(model)
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
@@ -364,9 +372,43 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Info("Reading records from %s.%s", schema, entity)
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
TableName: tableName,
Model: model,
Options: options,
ID: id,
Writer: w,
}
// Everything below runs inside a single transaction so that the BeforeRead/BeforeScan
// hooks (which may set session-scoped RLS GUCs via SET LOCAL) execute on the same
// physical connection as the queries they are meant to protect. Under connection
// pooling, firing a hook against h.db and then querying against h.db again may hand
// out two different connections, silently bypassing RLS.
var (
total int
fetchedRowNumber *int64
statusCode int
errCode string
errMsg string
)
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx.Tx = tx
if err := h.hooks.ExecuteBeforeOp(BeforeRead, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
return err
}
options = hookCtx.Options
// Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count()
// Bun's Model() accepts both single pointers and slice pointers
query := h.db.NewSelect().Model(modelPtr)
query := tx.NewSelect().Model(modelPtr)
// Only set Table() if the model doesn't provide a table name via the underlying type
// Create a temporary instance to check for TableNameProvider
@@ -483,9 +525,9 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
fixedWhere, err := common.ValidateAndFixPreloadWhere(preload.Where, preload.Relation)
if err != nil {
logger.Error("Invalid preload WHERE clause for relation '%s': %v", preload.Relation, err)
h.sendError(w, http.StatusBadRequest, "invalid_preload_where",
fmt.Sprintf("Invalid preload WHERE clause for relation '%s'", preload.Relation), err)
return
statusCode, errCode, errMsg = http.StatusBadRequest, "invalid_preload_where",
fmt.Sprintf("Invalid preload WHERE clause for relation '%s'", preload.Relation)
return err
}
preload.Where = fixedWhere
}
@@ -602,7 +644,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Handle FetchRowNumber before applying ID filter
// This must happen before the query to get the row position, then filter by PK
var fetchedRowNumber *int64
var fetchRowNumberPKValue string
if options.FetchRowNumber != nil && *options.FetchRowNumber != "" {
pkName := reflection.GetPrimaryKeyName(model)
@@ -610,11 +651,11 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Debug("FetchRowNumber: Fetching row number for PK %s = %s", pkName, fetchRowNumberPKValue)
rowNum, err := h.FetchRowNumber(ctx, tableName, pkName, fetchRowNumberPKValue, options, model)
rowNum, err := h.FetchRowNumber(ctx, tx, tableName, pkName, fetchRowNumberPKValue, options, model)
if err != nil {
logger.Error("Failed to fetch row number: %v", err)
h.sendError(w, http.StatusBadRequest, "fetch_rownumber_error", "Failed to fetch row number", err)
return
statusCode, errCode, errMsg = http.StatusBadRequest, "fetch_rownumber_error", "Failed to fetch row number"
return err
}
fetchedRowNumber = &rowNum
@@ -632,6 +673,11 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
query = query.Where(fmt.Sprintf("%s.%s = ?", common.QuoteIdent(tableAlias), common.QuoteIdent(pkName)), id)
}
// Fall back to the configured default sort when the request didn't specify one
if len(options.Sort) == 0 {
options.Sort = common.ResolveSortColumns(h.getDefaultSort(schema, entity), reflection.GetPrimaryKeyName(model))
}
// Apply sorting
tableAlias := reflection.ExtractTableNameOnly(tableName)
for _, sort := range options.Sort {
@@ -655,7 +701,6 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
}
// Get total count before pagination (unless skip count is requested)
var total int
if !options.SkipCount {
// Try to get from cache first (unless SkipCache is true)
var cachedTotalData *cachedTotal
@@ -703,8 +748,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
count, err := query.Count(ctx)
if err != nil {
logger.Error("Error counting records: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error counting records", err)
return
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error counting records"
return err
}
total = count
logger.Debug("Total records (from query): %d", total)
@@ -764,8 +809,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
cursorFilter, err := options.GetCursorFilter(tableName, pkName, modelColumns, expandJoins)
if err != nil {
logger.Error("Error building cursor filter: %v", err)
h.sendError(w, http.StatusBadRequest, "cursor_error", "Invalid cursor pagination", err)
return
statusCode, errCode, errMsg = http.StatusBadRequest, "cursor_error", "Invalid cursor pagination"
return err
}
// Apply cursor filter to query
@@ -780,10 +825,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Execute BeforeScan hooks - pass query chain so hooks can modify it
hookCtx.Query = query
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
return err
}
// Use potentially modified query from hook context
@@ -794,7 +839,18 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
// Execute query - modelPtr was already created earlier
if err := query.ScanModel(ctx); err != nil {
logger.Error("Error executing query: %v", err)
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err)
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
return err
}
return nil
})
if txErr != nil {
if statusCode == 0 {
statusCode, errCode, errMsg = http.StatusInternalServerError, "query_error", "Error executing query"
}
h.sendError(w, statusCode, errCode, errMsg, txErr)
return
}
@@ -839,7 +895,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Debug("FetchRowNumber: Row number %d set in metadata", *fetchedRowNumber)
}
// Execute AfterRead hooks
// Execute AfterRead hooks (runs after the transaction commits, against the pooled db)
hookCtx.Tx = h.db
hookCtx.Result = modelPtr
hookCtx.Error = nil
@@ -1133,7 +1190,6 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
logger.Info("Creating record in %s.%s", schema, entity)
// Execute BeforeCreate hooks
hookCtx := &HookContext{
Context: ctx,
Handler: h,
@@ -1144,28 +1200,38 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Options: options,
Data: data,
Writer: w,
Tx: h.db,
}
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
logger.Error("BeforeCreate hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
// Everything below (including the BeforeCreate hook) runs inside a single
// transaction so that session-scoped RLS GUCs set by the hook execute on the
// same physical connection as the inserts they are meant to protect.
var (
dataSlice []interface{}
originalDataMaps []map[string]interface{}
statusCode int
errCode string
errMsg string
)
// Process all items in a transaction
results := make([]interface{}, 0)
txErr := h.db.RunInTransaction(ctx, func(tx common.Database) error {
hookCtx.Tx = tx
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
statusCode, errCode, errMsg = http.StatusBadRequest, "hook_error", "Hook execution failed"
return err
}
// Use potentially modified data from hook context
data = hookCtx.Data
// Normalize data to slice for unified processing
dataSlice := h.normalizeToSlice(data)
dataSlice = h.normalizeToSlice(data)
logger.Debug("Processing %d item(s) for creation", len(dataSlice))
// Store original data maps for merging later
originalDataMaps := make([]map[string]interface{}, 0, len(dataSlice))
originalDataMaps = make([]map[string]interface{}, 0, len(dataSlice))
// Process all items in a transaction
results := make([]interface{}, 0, len(dataSlice))
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
// Create temporary nested processor with transaction
txNestedProcessor := common.NewNestedCUDProcessor(tx, h.registry, h)
@@ -1236,7 +1302,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
Query: query,
Tx: tx,
}
if err := h.hooks.Execute(BeforeScan, itemHookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeScan, itemHookCtx); err != nil {
return fmt.Errorf("BeforeScan hook failed for item %d: %w", i, err)
}
@@ -1266,9 +1332,12 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
return nil
})
if err != nil {
logger.Error("Error creating records: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating records", err)
if txErr != nil {
if statusCode == 0 {
statusCode, errCode, errMsg = http.StatusInternalServerError, "create_error", "Error creating records"
}
logger.Error("Error creating records: %v", txErr)
h.sendError(w, statusCode, errCode, errMsg, txErr)
return
}
@@ -1284,7 +1353,10 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
}
}
// Execute AfterCreate hooks
// Execute AfterCreate hooks (runs after the transaction commits, against the
// pooled db — hookCtx.Tx was pointed at the now-closed transaction inside the
// RunInTransaction closure above and must not be reused here).
hookCtx.Tx = h.db
var responseData interface{}
if len(mergedResults) == 1 {
responseData = mergedResults[0]
@@ -1366,7 +1438,34 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// Create temporary nested processor with transaction
txNestedProcessor := common.NewNestedCUDProcessor(tx, h.registry, h)
// First, read the existing record from the database
// Execute BeforeUpdate hooks inside transaction, before any queries run.
// BeforeUpdate hooks may set session-scoped RLS GUCs (via SET LOCAL);
// they must run before the existence-check select so that select is
// also subject to RLS on this connection/transaction.
hookCtx = &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
TableName: tableName,
Tx: tx,
Model: model,
Options: options,
ID: id,
Data: dataMap,
Writer: w,
}
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed: %w", err)
}
// Use potentially modified data from hook context
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
dataMap = modifiedData
}
// Now read the existing record from the database
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
selectQuery := tx.NewSelect().Model(existingRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
if err := selectQuery.ScanModel(ctx); err != nil {
@@ -1398,30 +1497,6 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
nestedRelations = relations
}
// Execute BeforeUpdate hooks inside transaction
hookCtx = &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
TableName: tableName,
Tx: tx,
Model: model,
Options: options,
ID: id,
Data: dataMap,
Writer: w,
}
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
return fmt.Errorf("BeforeUpdate hook failed: %w", err)
}
// Use potentially modified data from hook context
if modifiedData, ok := hookCtx.Data.(map[string]interface{}); ok {
dataMap = modifiedData
}
// Merge only non-null and non-empty values from the incoming request into the existing record
for key, newValue := range dataMap {
// Skip if the value is nil
@@ -1458,7 +1533,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// Execute BeforeScan hooks - pass query chain so hooks can modify it
hookCtx.Query = query
hookCtx.Tx = tx
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
return fmt.Errorf("BeforeScan hook failed: %w", err)
}
@@ -1502,7 +1577,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// pooled connection rather than the now-dead tx.
hookCtx.Tx = h.db
hookCtx.Query = selectQuery
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err)
h.sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
return
@@ -1577,7 +1652,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Tx: tx,
}
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed for ID %s: %v", itemID, err)
return fmt.Errorf("delete not allowed for ID %s: %w", itemID, err)
}
@@ -1651,7 +1726,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Tx: tx,
}
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed for ID %v: %v", itemID, err)
return fmt.Errorf("delete not allowed for ID %v: %w", itemID, err)
}
@@ -1709,7 +1784,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Tx: tx,
}
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed for ID %v: %v", itemID, err)
return fmt.Errorf("delete not allowed for ID %v: %w", itemID, err)
}
@@ -1794,7 +1869,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
Data: recordToDelete,
}
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("BeforeDelete hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
@@ -1805,7 +1880,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
// Execute BeforeScan hooks - pass query chain so hooks can modify it
hookCtx.Query = query
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
@@ -2828,7 +2903,7 @@ func (h *Handler) sendError(w common.ResponseWriter, statusCode int, code, messa
// FetchRowNumber calculates the row number of a specific record based on sorting and filtering
// Returns the 1-based row number of the record with the given primary key value
func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName string, pkValue string, options ExtendedRequestOptions, model any) (int64, error) {
func (h *Handler) FetchRowNumber(ctx context.Context, db common.Database, tableName string, pkName string, pkValue string, options ExtendedRequestOptions, model any) (int64, error) {
defer func() {
if r := recover(); r != nil {
logger.Error("Panic during FetchRowNumber: %v", r)
@@ -2912,7 +2987,7 @@ func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName s
RN int64 `bun:"rn"`
}
logger.Debug("[FetchRowNumber] BEFORE Query call - about to execute raw query")
err := h.db.Query(ctx, &result, queryStr, pkValue)
err := db.Query(ctx, &result, queryStr, pkValue)
logger.Debug("[FetchRowNumber] AFTER Query call - query completed with %d results, err: %v", len(result), err)
if err != nil {
return 0, fmt.Errorf("failed to fetch row number: %w", err)
+15
View File
@@ -34,6 +34,11 @@ const (
// Scan/Execute operation hooks
BeforeScan HookType = "before_scan"
// BeforeOp fires immediately before every SQL operation (read, create, update, delete, scan).
// Unlike BeforeHandle, which fires once before operation dispatch, BeforeOp fires at each
// individual SQL-operation hook point, so it runs once per statement executed.
BeforeOp HookType = "before_op"
)
// HookContext contains all the data available to a hook
@@ -137,6 +142,16 @@ func (r *HookRegistry) Execute(hookType HookType, ctx *HookContext) error {
return nil
}
// ExecuteBeforeOp executes the BeforeOp hook followed by the given SQL-operation hook
// (BeforeRead, BeforeCreate, BeforeUpdate, BeforeDelete, or BeforeScan). BeforeOp always
// runs first so it can observe/veto every SQL operation regardless of type.
func (r *HookRegistry) ExecuteBeforeOp(hookType HookType, ctx *HookContext) error {
if err := r.Execute(BeforeOp, ctx); err != nil {
return err
}
return r.Execute(hookType, ctx)
}
// Clear removes all hooks for the specified type
func (r *HookRegistry) Clear(hookType HookType) {
delete(r.hooks, hookType)
+6 -2
View File
@@ -1597,6 +1597,8 @@ CREATE TABLE IF NOT EXISTS oauth_clients (
client_name VARCHAR(255),
grant_types TEXT[] DEFAULT ARRAY['authorization_code'],
allowed_scopes TEXT[] DEFAULT ARRAY['openid','profile','email'],
client_secret_hash TEXT, -- sha256 hex of the confidential-client secret; NULL for public clients
token_endpoint_auth_method VARCHAR(30) DEFAULT 'none',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@@ -1634,13 +1636,15 @@ DECLARE
BEGIN
v_client_id := p_data->>'client_id';
INSERT INTO oauth_clients (client_id, redirect_uris, client_name, grant_types, allowed_scopes)
INSERT INTO oauth_clients (client_id, redirect_uris, client_name, grant_types, allowed_scopes, client_secret_hash, token_endpoint_auth_method)
VALUES (
v_client_id,
ARRAY(SELECT jsonb_array_elements_text(p_data->'redirect_uris')),
p_data->>'client_name',
COALESCE(ARRAY(SELECT jsonb_array_elements_text(p_data->'grant_types')), ARRAY['authorization_code']),
COALESCE(ARRAY(SELECT jsonb_array_elements_text(p_data->'allowed_scopes')), ARRAY['openid','profile','email'])
COALESCE(ARRAY(SELECT jsonb_array_elements_text(p_data->'allowed_scopes')), ARRAY['openid','profile','email']),
NULLIF(p_data->>'client_secret_hash', ''),
COALESCE(NULLIF(p_data->>'token_endpoint_auth_method', ''), 'none')
)
RETURNING to_jsonb(oauth_clients.*) INTO v_row;
+2
View File
@@ -100,6 +100,8 @@ CREATE TABLE IF NOT EXISTS oauth_clients (
client_name VARCHAR(255),
grant_types TEXT, -- JSON-encoded []string
allowed_scopes TEXT, -- JSON-encoded []string
client_secret_hash TEXT, -- sha256 hex of the confidential-client secret; NULL for public clients
token_endpoint_auth_method VARCHAR(30) DEFAULT 'none',
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP
);
+399 -10
View File
@@ -3,8 +3,11 @@ package security
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
@@ -12,6 +15,9 @@ import (
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/oauth2"
)
// OAuthServerConfig configures the MCP-standard OAuth2 authorization server.
@@ -44,6 +50,15 @@ type OAuthServerConfig struct {
// AuthCodeTTL is the auth code lifetime. Defaults to 2 minutes.
AuthCodeTTL time.Duration
// ResourceIdentifier is this server's protected-resource identifier, advertised in
// RFC 9728 metadata. Defaults to Issuer.
ResourceIdentifier string
// SigningKey signs id_tokens (RS256) and is exposed via the JWKS endpoint. If nil, an
// RSA-2048 key is generated in memory when the server starts. Supply a persistent key
// for multi-instance deployments so id_tokens remain verifiable across restarts/instances.
SigningKey *rsa.PrivateKey
}
// oauthClient is a dynamically registered OAuth2 client (RFC 7591).
@@ -53,6 +68,14 @@ type oauthClient struct {
ClientName string `json:"client_name,omitempty"`
GrantTypes []string `json:"grant_types"`
AllowedScopes []string `json:"allowed_scopes,omitempty"`
ClientSecretHash string `json:"client_secret_hash,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
}
// isConfidential reports whether the client has a registered secret and must
// authenticate itself at the token endpoint.
func (c *oauthClient) isConfidential() bool {
return c.ClientSecretHash != ""
}
// pendingAuth tracks an in-progress authorization code exchange.
@@ -85,13 +108,25 @@ type externalProvider struct {
// The server exposes these RFC-compliant endpoints:
//
// GET /.well-known/oauth-authorization-server RFC 8414 — server metadata discovery
// GET /.well-known/openid-configuration OIDC discovery (superset of the above)
// GET /.well-known/oauth-protected-resource RFC 9728 — protected resource metadata
// POST /oauth/register RFC 7591 — dynamic client registration
// GET /oauth/authorize OAuth 2.1 + PKCE — start authorization
// POST /oauth/authorize Direct login form submission
// POST /oauth/token Token exchange and refresh
// POST /oauth/token Token exchange: authorization_code,
// refresh_token, client_credentials (RFC 6749 §4.4)
// POST /oauth/revoke RFC 7009 — token revocation
// POST /oauth/introspect RFC 7662 — token introspection
// GET /oauth/userinfo OIDC UserInfo endpoint
// GET /oauth/jwks.json JWKS — id_token verification keys
// GET {ProviderCallbackPath} Internal — external provider callback
//
// Confidential clients (registered with token_endpoint_auth_method other than "none", or
// any grant_types including client_credentials) authenticate at /oauth/token via
// client_secret_basic or client_secret_post. Public clients keep relying on PKCE alone.
//
// When the granted scope includes "openid", authorization_code and refresh_token responses
// include an RS256-signed id_token (see OAuthServerConfig.SigningKey).
type OAuthServer struct {
cfg OAuthServerConfig
auth *DatabaseAuthenticator // nil = only external providers
@@ -102,6 +137,9 @@ type OAuthServer struct {
pending map[string]*pendingAuth // provider_state → pending (external flow)
codes map[string]*pendingAuth // auth_code → pending (post-auth)
signingKey *rsa.PrivateKey
signingKeyID string
done chan struct{} // closed by Close() to stop background goroutines
}
@@ -130,14 +168,33 @@ func NewOAuthServer(cfg OAuthServerConfig, auth *DatabaseAuthenticator) *OAuthSe
}
// Normalize issuer: remove trailing slash to ensure consistent endpoint URL construction.
cfg.Issuer = strings.TrimSuffix(cfg.Issuer, "/")
if cfg.ResourceIdentifier == "" {
cfg.ResourceIdentifier = cfg.Issuer
}
signingKey := cfg.SigningKey
if signingKey == nil {
var err error
signingKey, err = rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
// Signing keys are only required for id_token issuance (OIDC "openid" scope);
// leaving signingKey nil degrades gracefully by omitting id_token/JWKS support.
signingKey = nil
}
}
s := &OAuthServer{
cfg: cfg,
auth: auth,
clients: make(map[string]*oauthClient),
pending: make(map[string]*pendingAuth),
codes: make(map[string]*pendingAuth),
signingKey: signingKey,
done: make(chan struct{}),
}
if signingKey != nil {
s.signingKeyID = rsaKeyID(&signingKey.PublicKey)
}
go s.cleanupExpired()
return s
}
@@ -178,11 +235,15 @@ func (s *OAuthServer) ProviderCallbackPath() string {
func (s *OAuthServer) HTTPHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/oauth-authorization-server", s.metadataHandler)
mux.HandleFunc("/.well-known/openid-configuration", s.openIDConfigurationHandler)
mux.HandleFunc("/.well-known/oauth-protected-resource", s.protectedResourceHandler)
mux.HandleFunc("/oauth/register", s.registerHandler)
mux.HandleFunc("/oauth/authorize", s.authorizeHandler)
mux.HandleFunc("/oauth/token", s.tokenHandler)
mux.HandleFunc("/oauth/revoke", s.revokeHandler)
mux.HandleFunc("/oauth/introspect", s.introspectHandler)
mux.HandleFunc("/oauth/userinfo", s.userinfoHandler)
mux.HandleFunc("/oauth/jwks.json", s.jwksHandler)
mux.HandleFunc(s.cfg.ProviderCallbackPath, s.providerCallbackHandler)
return mux
}
@@ -217,25 +278,127 @@ func (s *OAuthServer) cleanupExpired() {
// RFC 8414 — Server metadata
// --------------------------------------------------------------------------
func (s *OAuthServer) metadataHandler(w http.ResponseWriter, r *http.Request) {
// serverMetadata builds the fields shared by RFC 8414 authorization-server
// metadata and OIDC discovery metadata.
func (s *OAuthServer) serverMetadata() map[string]interface{} {
issuer := s.cfg.Issuer
meta := map[string]interface{}{
grantTypes := []string{"authorization_code", "refresh_token"}
if s.auth != nil {
grantTypes = append(grantTypes, "client_credentials")
}
return map[string]interface{}{
"issuer": issuer,
"authorization_endpoint": issuer + "/oauth/authorize",
"token_endpoint": issuer + "/oauth/token",
"registration_endpoint": issuer + "/oauth/register",
"revocation_endpoint": issuer + "/oauth/revoke",
"introspection_endpoint": issuer + "/oauth/introspect",
"userinfo_endpoint": issuer + "/oauth/userinfo",
"jwks_uri": issuer + "/oauth/jwks.json",
"scopes_supported": s.cfg.DefaultScopes,
"response_types_supported": []string{"code"},
"grant_types_supported": []string{"authorization_code", "refresh_token"},
"grant_types_supported": grantTypes,
"code_challenge_methods_supported": []string{"S256"},
"token_endpoint_auth_methods_supported": []string{"none"},
"token_endpoint_auth_methods_supported": []string{"none", "client_secret_basic", "client_secret_post"},
}
}
func (s *OAuthServer) metadataHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(s.serverMetadata()) //nolint:errcheck
}
// --------------------------------------------------------------------------
// OIDC discovery — GET /.well-known/openid-configuration
// --------------------------------------------------------------------------
func (s *OAuthServer) openIDConfigurationHandler(w http.ResponseWriter, r *http.Request) {
meta := s.serverMetadata()
meta["subject_types_supported"] = []string{"public"}
meta["id_token_signing_alg_values_supported"] = []string{"RS256"}
meta["claims_supported"] = []string{"sub", "iss", "aud", "exp", "iat", "email", "preferred_username"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(meta) //nolint:errcheck
}
// --------------------------------------------------------------------------
// RFC 9728 — Protected Resource Metadata
// --------------------------------------------------------------------------
func (s *OAuthServer) protectedResourceHandler(w http.ResponseWriter, r *http.Request) {
meta := map[string]interface{}{
"resource": s.cfg.ResourceIdentifier,
"authorization_servers": []string{s.cfg.Issuer},
"scopes_supported": s.cfg.DefaultScopes,
"bearer_methods_supported": []string{"header"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(meta) //nolint:errcheck
}
// --------------------------------------------------------------------------
// JWKS — GET /oauth/jwks.json
// --------------------------------------------------------------------------
func (s *OAuthServer) jwksHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if s.signingKey == nil {
json.NewEncoder(w).Encode(map[string]interface{}{"keys": []interface{}{}}) //nolint:errcheck
return
}
pub := s.signingKey.PublicKey
jwk := map[string]interface{}{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": s.signingKeyID,
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(bigEndianBytes(pub.E)),
}
json.NewEncoder(w).Encode(map[string]interface{}{"keys": []interface{}{jwk}}) //nolint:errcheck
}
// --------------------------------------------------------------------------
// Userinfo — GET/POST /oauth/userinfo
// --------------------------------------------------------------------------
func (s *OAuthServer) userinfoHandler(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
token := strings.TrimPrefix(auth, "Bearer ")
if token == "" || token == auth {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
writeOAuthError(w, "invalid_token", "missing bearer token", http.StatusUnauthorized)
return
}
authToUse := s.auth
if authToUse == nil {
s.mu.RLock()
if len(s.providers) > 0 {
authToUse = s.providers[0].auth
}
s.mu.RUnlock()
}
if authToUse == nil {
writeOAuthError(w, "invalid_token", "no authenticator configured", http.StatusUnauthorized)
return
}
info, err := authToUse.OAuthIntrospectToken(r.Context(), token)
if err != nil || !info.Active {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
writeOAuthError(w, "invalid_token", "token is inactive or invalid", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ //nolint:errcheck
"sub": info.Sub,
"preferred_username": info.Username,
"email": info.Email,
})
}
// --------------------------------------------------------------------------
// RFC 7591 — Dynamic client registration
// --------------------------------------------------------------------------
@@ -250,6 +413,7 @@ func (s *OAuthServer) registerHandler(w http.ResponseWriter, r *http.Request) {
ClientName string `json:"client_name"`
GrantTypes []string `json:"grant_types"`
AllowedScopes []string `json:"allowed_scopes"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeOAuthError(w, "invalid_request", "malformed JSON", http.StatusBadRequest)
@@ -272,12 +436,37 @@ func (s *OAuthServer) registerHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
// client_credentials is a machine-to-machine grant and requires a confidential
// client (RFC 6749 §4.4), so it always forces secret issuance regardless of the
// requested auth method.
authMethod := req.TokenEndpointAuthMethod
if authMethod == "" {
authMethod = "none"
}
if oauthSliceContains(grantTypes, "client_credentials") && authMethod == "none" {
authMethod = "client_secret_basic"
}
var plaintextSecret string
var secretHash string
if authMethod != "none" {
plaintextSecret, err = randomOAuthToken()
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
secretHash = hashClientSecret(plaintextSecret)
}
client := &oauthClient{
ClientID: clientID,
RedirectURIs: req.RedirectURIs,
ClientName: req.ClientName,
GrantTypes: grantTypes,
AllowedScopes: allowedScopes,
ClientSecretHash: secretHash,
TokenEndpointAuthMethod: authMethod,
}
if s.cfg.PersistClients && s.auth != nil {
@@ -287,6 +476,8 @@ func (s *OAuthServer) registerHandler(w http.ResponseWriter, r *http.Request) {
ClientName: client.ClientName,
GrantTypes: client.GrantTypes,
AllowedScopes: client.AllowedScopes,
ClientSecretHash: client.ClientSecretHash,
TokenEndpointAuthMethod: client.TokenEndpointAuthMethod,
}
if _, err := s.auth.OAuthRegisterClient(r.Context(), dbClient); err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
@@ -298,9 +489,25 @@ func (s *OAuthServer) registerHandler(w http.ResponseWriter, r *http.Request) {
s.clients[clientID] = client
s.mu.Unlock()
// RFC 7591 registration response: the plaintext secret is returned exactly once here
// and never persisted or served again — only its hash (client.ClientSecretHash) is
// stored, and that hash is deliberately excluded from this response.
resp := map[string]interface{}{
"client_id": client.ClientID,
"redirect_uris": client.RedirectURIs,
"client_name": client.ClientName,
"grant_types": client.GrantTypes,
"allowed_scopes": client.AllowedScopes,
"token_endpoint_auth_method": client.TokenEndpointAuthMethod,
}
if plaintextSecret != "" {
resp["client_secret"] = plaintextSecret
resp["client_secret_expires_at"] = 0
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(client) //nolint:errcheck
json.NewEncoder(w).Encode(resp) //nolint:errcheck
}
// --------------------------------------------------------------------------
@@ -577,6 +784,8 @@ func (s *OAuthServer) tokenHandler(w http.ResponseWriter, r *http.Request) {
s.handleAuthCodeGrant(w, r)
case "refresh_token":
s.handleRefreshGrant(w, r)
case "client_credentials":
s.handleClientCredentialsGrant(w, r)
default:
writeOAuthError(w, "unsupported_grant_type", "", http.StatusBadRequest)
}
@@ -593,6 +802,15 @@ func (s *OAuthServer) handleAuthCodeGrant(w http.ResponseWriter, r *http.Request
return
}
// Confidential clients (those registered with a client_secret) must authenticate;
// public clients keep relying on PKCE alone, unchanged from prior behavior.
if client, ok := s.lookupOrFetchClient(r.Context(), clientID); ok && client.isConfidential() {
if _, err := s.authenticateClient(r); err != nil {
writeOAuthError(w, "invalid_client", err.Error(), http.StatusUnauthorized)
return
}
}
var sessionToken string
var refreshToken string
var scopes []string
@@ -647,12 +865,13 @@ func (s *OAuthServer) handleAuthCodeGrant(w http.ResponseWriter, r *http.Request
scopes = pending.Scopes
}
s.writeOAuthToken(w, sessionToken, refreshToken, scopes)
s.writeOAuthToken(w, r, sessionToken, refreshToken, clientID, scopes, true)
}
func (s *OAuthServer) handleRefreshGrant(w http.ResponseWriter, r *http.Request) {
refreshToken := r.FormValue("refresh_token")
providerName := r.FormValue("provider")
clientID := r.FormValue("client_id")
if refreshToken == "" {
writeOAuthError(w, "invalid_request", "refresh_token required", http.StatusBadRequest)
return
@@ -666,7 +885,7 @@ func (s *OAuthServer) handleRefreshGrant(w http.ResponseWriter, r *http.Request)
writeOAuthError(w, "invalid_grant", err.Error(), http.StatusBadRequest)
return
}
s.writeOAuthToken(w, loginResp.Token, loginResp.RefreshToken, nil)
s.writeOAuthToken(w, r, loginResp.Token, loginResp.RefreshToken, clientID, nil, false)
return
}
@@ -676,13 +895,86 @@ func (s *OAuthServer) handleRefreshGrant(w http.ResponseWriter, r *http.Request)
writeOAuthError(w, "invalid_grant", err.Error(), http.StatusBadRequest)
return
}
s.writeOAuthToken(w, loginResp.Token, loginResp.RefreshToken, nil)
s.writeOAuthToken(w, r, loginResp.Token, loginResp.RefreshToken, clientID, nil, false)
return
}
writeOAuthError(w, "invalid_grant", "no provider available for refresh", http.StatusBadRequest)
}
// --------------------------------------------------------------------------
// RFC 6749 §4.4 — Client credentials grant
// --------------------------------------------------------------------------
func (s *OAuthServer) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
writeOAuthError(w, "unsupported_grant_type", "client_credentials requires a local user store", http.StatusBadRequest)
return
}
client, err := s.authenticateClient(r)
if err != nil {
w.Header().Set("WWW-Authenticate", `Basic realm="oauth"`)
writeOAuthError(w, "invalid_client", err.Error(), http.StatusUnauthorized)
return
}
if !oauthSliceContains(client.GrantTypes, "client_credentials") {
writeOAuthError(w, "unauthorized_client", "client is not authorized for client_credentials", http.StatusBadRequest)
return
}
requested := strings.Fields(r.FormValue("scope"))
effectiveScopes := client.AllowedScopes
if len(requested) > 0 {
effectiveScopes = nil
for _, sc := range requested {
if oauthSliceContains(client.AllowedScopes, sc) {
effectiveScopes = append(effectiveScopes, sc)
}
}
if len(effectiveScopes) == 0 {
writeOAuthError(w, "invalid_scope", "no requested scope is allowed for this client", http.StatusBadRequest)
return
}
}
// client_credentials tokens have no end user, but the rest of the stack (RLS-scoping
// hooks, introspection) expects every access token to resolve to a user_sessions row
// with a user_id. Represent the client as a deterministic synthetic "service account"
// user so the existing get-or-create/create-session/introspection pipeline handles it
// unchanged — no new tables or code paths required.
userCtx := &UserContext{
UserName: "client:" + client.ClientID,
Email: "oauth-client-" + client.ClientID + "@service.internal",
RemoteID: client.ClientID,
Roles: effectiveScopes,
}
userID, err := s.auth.oauth2GetOrCreateUser(r.Context(), userCtx, "oauth2_client")
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
sessionToken, err := randomOAuthToken()
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
expiresAt := time.Now().Add(s.cfg.AccessTokenTTL)
err = s.auth.oauth2CreateSession(r.Context(), sessionToken, userID, &oauth2.Token{
AccessToken: sessionToken,
TokenType: "Bearer",
}, expiresAt, "oauth2_client")
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
// No refresh token per RFC 6749 §4.4.3, and no id_token — client_credentials has no
// end-user subject to represent in OIDC terms.
s.writeOAuthToken(w, r, sessionToken, "", client.ClientID, effectiveScopes, false)
}
// --------------------------------------------------------------------------
// RFC 7009 — Token revocation
// --------------------------------------------------------------------------
@@ -879,7 +1171,10 @@ func oauthSliceContains(slice []string, s string) bool {
return false
}
func (s *OAuthServer) writeOAuthToken(w http.ResponseWriter, accessToken, refreshToken string, scopes []string) {
// writeOAuthToken writes the token response. When issueIDToken is true and the granted
// scopes include "openid", an RS256-signed id_token is included (OIDC); client_credentials
// responses always pass issueIDToken=false since that grant has no end-user subject.
func (s *OAuthServer) writeOAuthToken(w http.ResponseWriter, r *http.Request, accessToken, refreshToken, clientID string, scopes []string, issueIDToken bool) {
expiresIn := int64(s.cfg.AccessTokenTTL.Seconds())
resp := map[string]interface{}{
"access_token": accessToken,
@@ -892,12 +1187,106 @@ func (s *OAuthServer) writeOAuthToken(w http.ResponseWriter, accessToken, refres
if len(scopes) > 0 {
resp["scope"] = strings.Join(scopes, " ")
}
if issueIDToken && oauthSliceContains(scopes, "openid") {
if idToken, err := s.buildIDToken(r.Context(), accessToken, clientID, scopes); err == nil {
resp["id_token"] = idToken
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
json.NewEncoder(w).Encode(resp) //nolint:errcheck
}
// buildIDToken issues an OIDC id_token for the just-issued access token by reusing the
// existing introspection pipeline to resolve the subject's claims.
func (s *OAuthServer) buildIDToken(ctx context.Context, accessToken, clientID string, scopes []string) (string, error) {
if s.signingKey == nil {
return "", fmt.Errorf("no signing key configured")
}
authToUse := s.auth
if authToUse == nil {
s.mu.RLock()
if len(s.providers) > 0 {
authToUse = s.providers[0].auth
}
s.mu.RUnlock()
}
if authToUse == nil {
return "", fmt.Errorf("no authenticator configured")
}
info, err := authToUse.OAuthIntrospectToken(ctx, accessToken)
if err != nil || !info.Active {
return "", fmt.Errorf("token not active")
}
now := time.Now()
claims := jwt.MapClaims{
"iss": s.cfg.Issuer,
"sub": info.Sub,
"aud": clientID,
"exp": now.Add(s.cfg.AccessTokenTTL).Unix(),
"iat": now.Unix(),
}
if oauthSliceContains(scopes, "profile") && info.Username != "" {
claims["preferred_username"] = info.Username
}
if oauthSliceContains(scopes, "email") && info.Email != "" {
claims["email"] = info.Email
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = s.signingKeyID
return token.SignedString(s.signingKey)
}
// authenticateClient validates client_secret_basic (Authorization: Basic) or
// client_secret_post (client_id/client_secret form fields) credentials against a
// registered confidential client's stored secret hash.
func (s *OAuthServer) authenticateClient(r *http.Request) (*oauthClient, error) {
clientID, clientSecret, ok := r.BasicAuth()
if !ok {
clientID = r.FormValue("client_id")
clientSecret = r.FormValue("client_secret")
}
if clientID == "" || clientSecret == "" {
return nil, fmt.Errorf("client authentication required")
}
client, ok := s.lookupOrFetchClient(r.Context(), clientID)
if !ok || !client.isConfidential() {
return nil, fmt.Errorf("invalid client credentials")
}
if subtle.ConstantTimeCompare([]byte(hashClientSecret(clientSecret)), []byte(client.ClientSecretHash)) != 1 {
return nil, fmt.Errorf("invalid client credentials")
}
return client, nil
}
func hashClientSecret(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
// rsaKeyID derives a stable JWKS "kid" from an RSA public key's modulus.
func rsaKeyID(pub *rsa.PublicKey) string {
sum := sha256.Sum256(pub.N.Bytes())
return base64.RawURLEncoding.EncodeToString(sum[:8])
}
// bigEndianBytes encodes a small positive int (e.g. an RSA public exponent) as
// minimal big-endian bytes for JWK "e" encoding.
func bigEndianBytes(n int) []byte {
if n == 0 {
return []byte{0}
}
var b []byte
for n > 0 {
b = append([]byte{byte(n & 0xff)}, b...)
n >>= 8
}
return b
}
func writeOAuthError(w http.ResponseWriter, errCode, description string, status int) {
resp := map[string]string{"error": errCode}
if description != "" {
+2
View File
@@ -14,6 +14,8 @@ type OAuthServerClient struct {
ClientName string `json:"client_name,omitempty"`
GrantTypes []string `json:"grant_types"`
AllowedScopes []string `json:"allowed_scopes,omitempty"`
ClientSecretHash string `json:"client_secret_hash,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
}
// OAuthCode is a short-lived authorization code.
+27 -6
View File
@@ -15,6 +15,15 @@ import (
// Array columns (redirect_uris, grant_types, allowed_scopes, scopes) are
// JSON-encoded TEXT instead of native Postgres arrays.
// nullIfEmpty converts an empty string to a SQL NULL so optional TEXT columns
// (e.g. client_secret_hash for public clients) stay unset rather than "".
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
func (a *DatabaseAuthenticator) oauthRegisterClientDirect(ctx context.Context, client *OAuthServerClient) (*OAuthServerClient, error) {
grantTypes := client.GrantTypes
if len(grantTypes) == 0 {
@@ -38,11 +47,16 @@ func (a *DatabaseAuthenticator) oauthRegisterClientDirect(ctx context.Context, c
return nil, fmt.Errorf("failed to marshal allowed_scopes: %w", err)
}
authMethod := client.TokenEndpointAuthMethod
if authMethod == "" {
authMethod = "none"
}
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(
`INSERT INTO %s (client_id, redirect_uris, client_name, grant_types, allowed_scopes, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO %s (client_id, redirect_uris, client_name, grant_types, allowed_scopes, client_secret_hash, token_endpoint_auth_method, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.tableNames.OAuthClients))
_, err := db.ExecContext(ctx, query, client.ClientID, string(redirectURIsJSON), client.ClientName, string(grantTypesJSON), string(allowedScopesJSON), true, time.Now())
_, err := db.ExecContext(ctx, query, client.ClientID, string(redirectURIsJSON), client.ClientName, string(grantTypesJSON), string(allowedScopesJSON), nullIfEmpty(client.ClientSecretHash), authMethod, true, time.Now())
return err
})
if err != nil {
@@ -55,18 +69,20 @@ func (a *DatabaseAuthenticator) oauthRegisterClientDirect(ctx context.Context, c
ClientName: client.ClientName,
GrantTypes: grantTypes,
AllowedScopes: allowedScopes,
ClientSecretHash: client.ClientSecretHash,
TokenEndpointAuthMethod: authMethod,
}, nil
}
func (a *DatabaseAuthenticator) oauthGetClientDirect(ctx context.Context, clientID string) (*OAuthServerClient, error) {
var redirectURIsJSON, grantTypesJSON, allowedScopesJSON sql.NullString
var clientName sql.NullString
var clientName, clientSecretHash, authMethod sql.NullString
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(
`SELECT redirect_uris, client_name, grant_types, allowed_scopes FROM %s WHERE client_id = ? AND is_active = ?`,
`SELECT redirect_uris, client_name, grant_types, allowed_scopes, client_secret_hash, token_endpoint_auth_method FROM %s WHERE client_id = ? AND is_active = ?`,
a.tableNames.OAuthClients))
return db.QueryRowContext(ctx, query, clientID, true).Scan(&redirectURIsJSON, &clientName, &grantTypesJSON, &allowedScopesJSON)
return db.QueryRowContext(ctx, query, clientID, true).Scan(&redirectURIsJSON, &clientName, &grantTypesJSON, &allowedScopesJSON, &clientSecretHash, &authMethod)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -75,7 +91,12 @@ func (a *DatabaseAuthenticator) oauthGetClientDirect(ctx context.Context, client
return nil, fmt.Errorf("failed to get client: %w", err)
}
result := &OAuthServerClient{ClientID: clientID, ClientName: clientName.String}
result := &OAuthServerClient{
ClientID: clientID,
ClientName: clientName.String,
ClientSecretHash: clientSecretHash.String,
TokenEndpointAuthMethod: authMethod.String,
}
if redirectURIsJSON.Valid {
_ = json.Unmarshal([]byte(redirectURIsJSON.String), &result.RedirectURIs)
}
+483
View File
@@ -0,0 +1,483 @@
package security
import (
"context"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/golang-jwt/jwt/v5"
)
func newTestOAuthServer(t *testing.T) (*OAuthServer, *DatabaseAuthenticator) {
t.Helper()
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
srv := NewOAuthServer(OAuthServerConfig{Issuer: "https://auth.example.com", PersistCodes: true}, auth)
t.Cleanup(srv.Close)
return srv, auth
}
// s256Challenge computes the PKCE S256 code_challenge for a given verifier,
// matching validatePKCESHA256 in oauth_server.go.
func s256Challenge(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}
func doJSON(t *testing.T, mux http.Handler, method, path string, body map[string]interface{}) (*httptest.ResponseRecorder, map[string]interface{}) {
t.Helper()
var reqBody *strings.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
reqBody = strings.NewReader(string(b))
} else {
reqBody = strings.NewReader("")
}
req := httptest.NewRequest(method, path, reqBody)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
var parsed map[string]interface{}
if rec.Body.Len() > 0 {
_ = json.Unmarshal(rec.Body.Bytes(), &parsed)
}
return rec, parsed
}
func doForm(t *testing.T, mux http.Handler, path string, form url.Values, basicUser, basicPass string) (*httptest.ResponseRecorder, map[string]interface{}) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if basicUser != "" {
req.SetBasicAuth(basicUser, basicPass)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
var parsed map[string]interface{}
if rec.Body.Len() > 0 {
_ = json.Unmarshal(rec.Body.Bytes(), &parsed)
}
return rec, parsed
}
func doGet(mux http.Handler, path, bearer string) (*httptest.ResponseRecorder, map[string]interface{}) {
req := httptest.NewRequest(http.MethodGet, path, nil)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
var parsed map[string]interface{}
if rec.Body.Len() > 0 {
_ = json.Unmarshal(rec.Body.Bytes(), &parsed)
}
return rec, parsed
}
func TestOAuthServer_RegisterConfidentialClient_IssuesSecretOnce(t *testing.T) {
srv, _ := newTestOAuthServer(t)
mux := srv.HTTPHandler()
rec, resp := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
"redirect_uris": []string{"https://app.example.com/callback"},
"grant_types": []string{"client_credentials"},
})
if rec.Code != http.StatusCreated {
t.Fatalf("register status = %d, body = %s", rec.Code, rec.Body.String())
}
secret, _ := resp["client_secret"].(string)
if secret == "" {
t.Fatal("expected client_secret to be present in registration response")
}
if _, ok := resp["client_secret_hash"]; ok {
t.Error("client_secret_hash must never be returned in the registration response")
}
if resp["token_endpoint_auth_method"] != "client_secret_basic" {
t.Errorf("token_endpoint_auth_method = %v, want client_secret_basic", resp["token_endpoint_auth_method"])
}
if resp["client_id"] == "" || resp["client_id"] == nil {
t.Fatal("expected non-empty client_id")
}
// Fetching the client back (e.g. via a later authorize/token call) must never leak the hash.
clientID := resp["client_id"].(string)
fetched, ok := srv.lookupOrFetchClient(context.Background(), clientID)
if !ok {
t.Fatal("expected client to be found")
}
if fetched.ClientSecretHash == "" {
t.Error("expected ClientSecretHash to be stored internally")
}
if fetched.ClientSecretHash == secret {
t.Error("stored hash must not equal the plaintext secret")
}
// Public registration (no client_credentials) should stay a public client with no secret.
recPublic, respPublic := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
"redirect_uris": []string{"https://app.example.com/callback"},
})
if recPublic.Code != http.StatusCreated {
t.Fatalf("public register status = %d", recPublic.Code)
}
if _, ok := respPublic["client_secret"]; ok {
t.Error("public client registration should not receive a client_secret")
}
if respPublic["token_endpoint_auth_method"] != "none" {
t.Errorf("public client token_endpoint_auth_method = %v, want none", respPublic["token_endpoint_auth_method"])
}
}
func TestOAuthServer_ClientCredentialsGrant(t *testing.T) {
srv, _ := newTestOAuthServer(t)
mux := srv.HTTPHandler()
_, reg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
"redirect_uris": []string{"https://app.example.com/callback"},
"grant_types": []string{"client_credentials"},
"allowed_scopes": []string{"read", "write"},
})
clientID := reg["client_id"].(string)
clientSecret := reg["client_secret"].(string)
t.Run("valid credentials", func(t *testing.T) {
rec, resp := doForm(t, mux, "/oauth/token", url.Values{
"grant_type": {"client_credentials"},
"scope": {"read"},
}, clientID, clientSecret)
if rec.Code != http.StatusOK {
t.Fatalf("token status = %d, body = %s", rec.Code, rec.Body.String())
}
if resp["access_token"] == "" || resp["access_token"] == nil {
t.Error("expected non-empty access_token")
}
if _, ok := resp["refresh_token"]; ok {
t.Error("client_credentials must not issue a refresh_token (RFC 6749 §4.4.3)")
}
if resp["scope"] != "read" {
t.Errorf("scope = %v, want read", resp["scope"])
}
})
t.Run("scope not allowed for client", func(t *testing.T) {
rec, resp := doForm(t, mux, "/oauth/token", url.Values{
"grant_type": {"client_credentials"},
"scope": {"admin"},
}, clientID, clientSecret)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if resp["error"] != "invalid_scope" {
t.Errorf("error = %v, want invalid_scope", resp["error"])
}
})
t.Run("wrong secret", func(t *testing.T) {
rec, resp := doForm(t, mux, "/oauth/token", url.Values{
"grant_type": {"client_credentials"},
}, clientID, "wrong-secret")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if resp["error"] != "invalid_client" {
t.Errorf("error = %v, want invalid_client", resp["error"])
}
})
t.Run("public client cannot use client_credentials", func(t *testing.T) {
_, pubReg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
"redirect_uris": []string{"https://app.example.com/callback"},
})
pubClientID := pubReg["client_id"].(string)
rec, _ := doForm(t, mux, "/oauth/token", url.Values{
"grant_type": {"client_credentials"},
"client_id": {pubClientID},
}, "", "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 for public client attempting client_credentials", rec.Code)
}
})
}
func TestOAuthServer_ConfidentialClient_AuthCodeGrantRequiresSecret(t *testing.T) {
srv, auth := newTestOAuthServer(t)
mux := srv.HTTPHandler()
regResp, err := auth.Register(context.Background(), RegisterRequest{
Username: "nadia", Password: "p", Email: "nadia@example.com",
})
if err != nil {
t.Fatalf("Register() error = %v", err)
}
_, reg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
"redirect_uris": []string{"https://app.example.com/callback"},
"token_endpoint_auth_method": "client_secret_basic",
})
clientID := reg["client_id"].(string)
clientSecret := reg["client_secret"].(string)
verifier := "verifier-nadia-1234567890"
challenge := s256Challenge(verifier)
if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{
Code: "code-no-auth",
ClientID: clientID,
RedirectURI: "https://app.example.com/callback",
CodeChallenge: challenge,
SessionToken: regResp.Token,
Scopes: []string{"profile"},
ExpiresAt: futureTime(),
}); err != nil {
t.Fatalf("OAuthSaveCode() error = %v", err)
}
// No client credentials supplied -> must be rejected for a confidential client.
rec, resp := doForm(t, mux, "/oauth/token", url.Values{
"grant_type": {"authorization_code"},
"code": {"code-no-auth"},
"redirect_uri": {"https://app.example.com/callback"},
"client_id": {clientID},
"code_verifier": {verifier},
}, "", "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 without client auth, body=%s", rec.Code, rec.Body.String())
}
if resp["error"] != "invalid_client" {
t.Errorf("error = %v, want invalid_client", resp["error"])
}
// Same code, now with correct client credentials -> succeeds.
if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{
Code: "code-with-auth",
ClientID: clientID,
RedirectURI: "https://app.example.com/callback",
CodeChallenge: challenge,
SessionToken: regResp.Token,
Scopes: []string{"profile"},
ExpiresAt: futureTime(),
}); err != nil {
t.Fatalf("OAuthSaveCode() error = %v", err)
}
rec2, _ := doForm(t, mux, "/oauth/token", url.Values{
"grant_type": {"authorization_code"},
"code": {"code-with-auth"},
"redirect_uri": {"https://app.example.com/callback"},
"client_id": {clientID},
"code_verifier": {verifier},
}, clientID, clientSecret)
if rec2.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 with client auth, body=%s", rec2.Code, rec2.Body.String())
}
}
func TestOAuthServer_PublicClient_AuthCodeGrantUnaffected(t *testing.T) {
srv, auth := newTestOAuthServer(t)
mux := srv.HTTPHandler()
regResp, err := auth.Register(context.Background(), RegisterRequest{
Username: "oscar", Password: "p", Email: "oscar@example.com",
})
if err != nil {
t.Fatalf("Register() error = %v", err)
}
_, reg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
"redirect_uris": []string{"https://app.example.com/callback"},
})
clientID := reg["client_id"].(string)
if _, ok := reg["client_secret"]; ok {
t.Fatal("expected no client_secret for a default (public) registration")
}
verifier := "verifier-oscar-1234567890"
challenge := s256Challenge(verifier)
if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{
Code: "public-code",
ClientID: clientID,
RedirectURI: "https://app.example.com/callback",
CodeChallenge: challenge,
SessionToken: regResp.Token,
Scopes: []string{"profile"},
ExpiresAt: futureTime(),
}); err != nil {
t.Fatalf("OAuthSaveCode() error = %v", err)
}
// No credentials needed for a public client — PKCE alone is sufficient, unchanged.
rec, _ := doForm(t, mux, "/oauth/token", url.Values{
"grant_type": {"authorization_code"},
"code": {"public-code"},
"redirect_uri": {"https://app.example.com/callback"},
"client_id": {clientID},
"code_verifier": {verifier},
}, "", "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 for public client without credentials, body=%s", rec.Code, rec.Body.String())
}
}
func TestOAuthServer_ProtectedResourceMetadata(t *testing.T) {
srv, _ := newTestOAuthServer(t)
mux := srv.HTTPHandler()
rec, resp := doGet(mux, "/.well-known/oauth-protected-resource", "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if resp["resource"] != "https://auth.example.com" {
t.Errorf("resource = %v", resp["resource"])
}
servers, ok := resp["authorization_servers"].([]interface{})
if !ok || len(servers) != 1 || servers[0] != "https://auth.example.com" {
t.Errorf("authorization_servers = %v", resp["authorization_servers"])
}
}
func TestOAuthServer_OIDCDiscoveryAndIDToken(t *testing.T) {
srv, auth := newTestOAuthServer(t)
mux := srv.HTTPHandler()
// Discovery document.
rec, disc := doGet(mux, "/.well-known/openid-configuration", "")
if rec.Code != http.StatusOK {
t.Fatalf("discovery status = %d", rec.Code)
}
if disc["jwks_uri"] != "https://auth.example.com/oauth/jwks.json" {
t.Errorf("jwks_uri = %v", disc["jwks_uri"])
}
grantTypes, _ := disc["grant_types_supported"].([]interface{})
found := false
for _, g := range grantTypes {
if g == "client_credentials" {
found = true
}
}
if !found {
t.Errorf("expected client_credentials in grant_types_supported, got %v", grantTypes)
}
// JWKS.
rec, _ = doGet(mux, "/oauth/jwks.json", "")
var jwks struct {
Keys []struct {
Kid string `json:"kid"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &jwks)
if len(jwks.Keys) != 1 {
t.Fatalf("expected 1 JWKS key, got %d", len(jwks.Keys))
}
// End-to-end authorization_code flow with scope=openid, verifying the id_token
// signature against the published JWKS key.
regResp, err := auth.Register(context.Background(), RegisterRequest{
Username: "olivia", Password: "p", Email: "olivia@example.com",
})
if err != nil {
t.Fatalf("Register() error = %v", err)
}
_, clientReg := doJSON(t, mux, http.MethodPost, "/oauth/register", map[string]interface{}{
"redirect_uris": []string{"https://app.example.com/callback"},
})
clientID := clientReg["client_id"].(string)
verifier := "test-code-verifier-1234567890"
challenge := s256Challenge(verifier)
if err := auth.OAuthSaveCode(context.Background(), &OAuthCode{
Code: "oidc-code",
ClientID: clientID,
RedirectURI: "https://app.example.com/callback",
CodeChallenge: challenge,
SessionToken: regResp.Token,
Scopes: []string{"openid", "profile", "email"},
ExpiresAt: futureTime(),
}); err != nil {
t.Fatalf("OAuthSaveCode() error = %v", err)
}
tokRec, tokenResp := doForm(t, mux, "/oauth/token", url.Values{
"grant_type": {"authorization_code"},
"code": {"oidc-code"},
"redirect_uri": {"https://app.example.com/callback"},
"client_id": {clientID},
"code_verifier": {verifier},
}, "", "")
if tokRec.Code != http.StatusOK {
t.Fatalf("token exchange status = %d, body = %s", tokRec.Code, tokRec.Body.String())
}
idTokenStr, _ := tokenResp["id_token"].(string)
if idTokenStr == "" {
t.Fatal("expected id_token in response for scope containing openid")
}
nBytes, err := base64.RawURLEncoding.DecodeString(jwks.Keys[0].N)
if err != nil {
t.Fatalf("decode n: %v", err)
}
eBytes, err := base64.RawURLEncoding.DecodeString(jwks.Keys[0].E)
if err != nil {
t.Fatalf("decode e: %v", err)
}
pub := &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: int(new(big.Int).SetBytes(eBytes).Int64())}
parsed, err := jwt.Parse(idTokenStr, func(token *jwt.Token) (interface{}, error) {
return pub, nil
}, jwt.WithValidMethods([]string{"RS256"}))
if err != nil || !parsed.Valid {
t.Fatalf("id_token did not verify against JWKS key: %v", err)
}
claims := parsed.Claims.(jwt.MapClaims)
if claims["iss"] != "https://auth.example.com" {
t.Errorf("iss claim = %v", claims["iss"])
}
if claims["aud"] != clientID {
t.Errorf("aud claim = %v, want %v", claims["aud"], clientID)
}
if claims["preferred_username"] != "olivia" {
t.Errorf("preferred_username claim = %v", claims["preferred_username"])
}
if claims["email"] != "olivia@example.com" {
t.Errorf("email claim = %v", claims["email"])
}
}
func TestOAuthServer_Userinfo(t *testing.T) {
srv, auth := newTestOAuthServer(t)
mux := srv.HTTPHandler()
regResp, err := auth.Register(context.Background(), RegisterRequest{
Username: "pete", Password: "p", Email: "pete@example.com",
})
if err != nil {
t.Fatalf("Register() error = %v", err)
}
rec, info := doGet(mux, "/oauth/userinfo", regResp.Token)
if rec.Code != http.StatusOK {
t.Fatalf("userinfo status = %d, body = %s", rec.Code, rec.Body.String())
}
if info["preferred_username"] != "pete" {
t.Errorf("preferred_username = %v", info["preferred_username"])
}
rec2, _ := doGet(mux, "/oauth/userinfo", "not-a-real-token")
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 for invalid token", rec2.Code)
}
}
+28 -4
View File
@@ -221,7 +221,7 @@ func (h *Handler) handleRequest(conn *Connection, msg *Message) {
// handleRead processes a read operation
func (h *Handler) handleRead(conn *Connection, msg *Message, hookCtx *HookContext) {
// Execute before hook
if err := h.hooks.Execute(BeforeRead, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeRead, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeRead hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp)
@@ -273,7 +273,7 @@ func (h *Handler) handleRead(conn *Connection, msg *Message, hookCtx *HookContex
// handleCreate processes a create operation
func (h *Handler) handleCreate(conn *Connection, msg *Message, hookCtx *HookContext) {
// Execute before hook
if err := h.hooks.Execute(BeforeCreate, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeCreate hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp)
@@ -311,7 +311,7 @@ func (h *Handler) handleCreate(conn *Connection, msg *Message, hookCtx *HookCont
// handleUpdate processes an update operation
func (h *Handler) handleUpdate(conn *Connection, msg *Message, hookCtx *HookContext) {
// Execute before hook
if err := h.hooks.Execute(BeforeUpdate, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeUpdate hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp)
@@ -349,7 +349,7 @@ func (h *Handler) handleUpdate(conn *Connection, msg *Message, hookCtx *HookCont
// handleDelete processes a delete operation
func (h *Handler) handleDelete(conn *Connection, msg *Message, hookCtx *HookContext) {
// Execute before hook
if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil {
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeDelete hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp)
@@ -574,6 +574,18 @@ func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) {
}
}
// Execute BeforeScan hooks - pass query so hooks (e.g. row-level security) can modify it
if hookCtx.Metadata == nil {
hookCtx.Metadata = make(map[string]interface{})
}
hookCtx.Metadata["query"] = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
return nil, fmt.Errorf("BeforeScan hook failed: %w", err)
}
if modifiedQuery, ok := hookCtx.Metadata["query"].(common.SelectQuery); ok {
query = modifiedQuery
}
// Execute query
if err := query.ScanModel(hookCtx.Context); err != nil {
return nil, fmt.Errorf("failed to read record: %w", err)
@@ -624,6 +636,18 @@ func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata
}
}
// Execute BeforeScan hooks - pass query so hooks (e.g. row-level security) can modify it
if hookCtx.Metadata == nil {
hookCtx.Metadata = make(map[string]interface{})
}
hookCtx.Metadata["query"] = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
return nil, nil, fmt.Errorf("BeforeScan hook failed: %w", err)
}
if modifiedQuery, ok := hookCtx.Metadata["query"].(common.SelectQuery); ok {
query = modifiedQuery
}
// Execute query
if err := query.ScanModel(hookCtx.Context); err != nil {
return nil, nil, fmt.Errorf("failed to read records: %w", err)
+20
View File
@@ -35,6 +35,11 @@ const (
// AfterDelete is called after a delete operation
AfterDelete HookType = "after_delete"
// BeforeScan is called right before a read query is executed against the database,
// after all filters/sort/pagination have been applied. Use this for row-level
// security that needs to modify the query (stored in HookContext.Metadata["query"]).
BeforeScan HookType = "before_scan"
// BeforeSubscribe is called before creating a subscription
BeforeSubscribe HookType = "before_subscribe"
// AfterSubscribe is called after creating a subscription
@@ -54,6 +59,11 @@ const (
BeforeDisconnect HookType = "before_disconnect"
// AfterDisconnect is called after a connection is closed
AfterDisconnect HookType = "after_disconnect"
// BeforeOp fires immediately before every SQL operation (read, create, update, delete).
// Unlike BeforeHandle, which fires once before operation dispatch, BeforeOp fires at each
// individual SQL-operation hook point, so it runs once per statement executed.
BeforeOp HookType = "before_op"
)
// HookContext contains context information for hook execution
@@ -197,6 +207,16 @@ func (hr *HookRegistry) Execute(hookType HookType, ctx *HookContext) error {
return nil
}
// ExecuteBeforeOp executes the BeforeOp hook followed by the given SQL-operation hook
// (BeforeRead, BeforeCreate, BeforeUpdate, or BeforeDelete). BeforeOp always runs first
// so it can observe/veto every SQL operation regardless of type.
func (hr *HookRegistry) ExecuteBeforeOp(hookType HookType, ctx *HookContext) error {
if err := hr.Execute(BeforeOp, ctx); err != nil {
return err
}
return hr.Execute(hookType, ctx)
}
// HasHooks checks if any hooks are registered for a hook type
func (hr *HookRegistry) HasHooks(hookType HookType) bool {
hooks, exists := hr.hooks[hookType]
+10 -4
View File
@@ -27,25 +27,31 @@ func RegisterSecurityHooks(handler *Handler, securityList *security.SecurityList
return security.LoadSecurityRules(secCtx, securityList)
})
// Hook 2: AfterRead - Apply column-level security (masking)
// Hook 2: BeforeScan - Apply row-level security filters
handler.Hooks().Register(BeforeScan, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.ApplyRowSecurity(secCtx, securityList)
})
// Hook 3: AfterRead - Apply column-level security (masking)
handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.ApplyColumnSecurity(secCtx, securityList)
})
// Hook 3 (Optional): Audit logging
// Hook 4 (Optional): Audit logging
handler.Hooks().Register(AfterRead, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.LogDataAccess(secCtx)
})
// Hook 4: BeforeUpdate - enforce CanUpdate rule from context/registry
// Hook 5: BeforeUpdate - enforce CanUpdate rule from context/registry
handler.Hooks().Register(BeforeUpdate, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.CheckModelUpdateAllowed(secCtx)
})
// Hook 5: BeforeDelete - enforce CanDelete rule from context/registry
// Hook 6: BeforeDelete - enforce CanDelete rule from context/registry
handler.Hooks().Register(BeforeDelete, func(hookCtx *HookContext) error {
secCtx := newSecurityContext(hookCtx)
return security.CheckModelDeleteAllowed(secCtx)