Compare commits

...

68 Commits

Author SHA1 Message Date
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
Hein a85e572732 fix(hooks): reset Tx to pooled connection for post-commit hook calls
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m9s
Tests / Unit Tests (push) Failing after 2m40s
Tests / Integration Tests (push) Failing after 2m47s
Build , Vet Test, and Lint / Build (push) Successful in 5m56s
Build , Vet Test, and Lint / Lint Code (push) Failing after 6m37s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 6m48s
BeforeScan (restheadspec handleUpdate) and BeforeResponse (funcspec
list/single query handlers) fire after RunInTransaction commits, but
hookCtx.Tx still pointed at the now-dead transaction. Any hook that
executed a query against Tx (e.g. setUserViaContext) failed with
"sql: transaction has already been committed or rolled back".
2026-07-20 13:45:13 +02:00
Hein 598fd687f6 feat(security): add GetUserRef method for opaque user identifiers
Tests / Integration Tests (push) Failing after 1s
Tests / Unit Tests (push) Failing after 21s
Build , Vet Test, and Lint / Build (push) Successful in 50s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 54s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m6s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m21s
2026-07-10 13:47:32 +02:00
Hein eee83f9dc6 feat(security): add query mode handling for database operations
Tests / Integration Tests (push) Failing after 1s
Tests / Unit Tests (push) Failing after 24s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 59s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 58s
Build , Vet Test, and Lint / Build (push) Successful in 4m37s
Build , Vet Test, and Lint / Lint Code (push) Failing after 5m35s
* Introduced QueryMode to select between stored procedure and direct SQL execution.
* Implemented dbCapability to probe for stored procedure existence.
* Added table names configuration for direct SQL operations.
* Updated DatabaseTwoFactorProvider to support query mode and table names.
* Implemented direct SQL methods mirroring stored procedures for TOTP operations.
* Added tests for query mode logic and table names validation.
2026-07-07 15:29:29 +02:00
Hein 8a06aacfb2 fix(cors): update CORS headers handling for requests
Tests / Integration Tests (push) Failing after 1s
Tests / Unit Tests (push) Failing after 22s
Build , Vet Test, and Lint / Build (push) Successful in 1m1s
Build , Vet Test, and Lint / Lint Code (push) Successful in 1m19s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 1m35s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 1m36s
* Reflect request origin for Access-Control-Allow-Origin
* Set Vary header for caching based on origin
* Allow specific headers from preflight requests
* Enable credentials only for specific origins
2026-07-01 12:27:39 +02:00
Hein 705c4f8001 fix(manager): ensure HTTP1 is set when HTTP2 is disabled
Tests / Integration Tests (push) Failing after 1s
Tests / Unit Tests (push) Failing after 1m41s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 3m50s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 3m53s
Build , Vet Test, and Lint / Build (push) Successful in 3m55s
Build , Vet Test, and Lint / Lint Code (push) Successful in 4m3s
2026-06-30 13:54:56 +02:00
Hein d648614611 feat(config): add PanicHandler to Config for custom recovery 2026-06-30 13:49:51 +02:00
Hein 3f86eb0f06 feat(config): add HTTP2 field to ServerInstanceConfig and align with server.Config 2026-06-30 13:36:06 +02:00
Hein 3dac55cb19 fix: Set http2 based on prop 2026-06-30 13:29:55 +02:00
Hein bbb2c6d127 feat(server): add HTTP2 support in server configuration 2026-06-30 11:33:31 +02:00
Hein 3fec7b1a90 fix(handler): update Content-Range headers for API response
Build , Vet Test, and Lint / Lint Code (push) Failing after 0s
Build , Vet Test, and Lint / Build (push) Failing after 0s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after 1s
Tests / Unit Tests (push) Failing after 1s
Tests / Integration Tests (push) Failing after 0s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 48s
* change Content-Range format to include 'items'
* add X-Api-Range-From and X-Api-Modelname headers
* add X-Api-Range-Etotal header for total filtered items
2026-06-24 10:02:54 +02:00
Hein 910390f62d fix(headers): correct order of limit and offset parsing 2026-06-24 09:47:14 +02:00
Hein b9bed67bd7 feat(security): add program user ID and table to user context
Build , Vet Test, and Lint / Lint Code (push) Failing after 0s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after 0s
Build , Vet Test, and Lint / Build (push) Failing after 1s
Tests / Unit Tests (push) Failing after 0s
Tests / Integration Tests (push) Failing after 1s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Failing after 14m57s
2026-06-23 10:18:22 +02:00
Hein 11ef16f75a fix(sql_helpers): adjust parenthesis nesting depth comment 2026-06-23 09:41:40 +02:00
Hein 48b72a7631 fix(sql_helpers): enhance splitByAND to handle BETWEEN and quotes
* Add support for BETWEEN-aware AND detection
* Ensure AND inside single-quoted strings does not cause splits
* Update tests to cover new BETWEEN and quote scenarios
2026-06-23 09:41:27 +02:00
Hein 4c512acf25 test(function_api): add test for x-detailapi header response 2026-06-23 08:53:33 +02:00
Hein 07a402634e fix(function_api): enhance detail format with table metadata
* include table name and prefix in response
* add field metadata extraction for raw SQL results
2026-06-23 08:50:29 +02:00
Hein 0e8f8925c6 fix(reflection): replace reflect.Ptr with reflect.Pointer
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Failing after 0s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after 1s
Build , Vet Test, and Lint / Lint Code (push) Failing after 0s
Build , Vet Test, and Lint / Build (push) Failing after 0s
Tests / Unit Tests (push) Failing after 1s
Tests / Integration Tests (push) Failing after 1s
* Updated all instances of reflect.Ptr to reflect.Pointer for consistency in type checking.
2026-06-22 16:40:07 +02:00
Hein 5a359a160b fix(handler): update sendFormattedResponse to include table name and model 2026-06-22 16:38:21 +02:00
Hein a2799fa224 fix(handler): re-fetch records to capture DB-generated values
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Has been cancelled
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Has been cancelled
Build , Vet Test, and Lint / Lint Code (push) Has been cancelled
Build , Vet Test, and Lint / Build (push) Has been cancelled
Tests / Unit Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
2026-06-12 16:28:51 +02:00
Hein 1419542650 fix(handler): re-fetch records to capture DB-generated changes 2026-06-12 13:37:07 +02:00
Hein c120b49529 fix(router): prevent HTML escaping in JSON responses
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Has been cancelled
Build , Vet Test, and Lint / Lint Code (push) Has been cancelled
Build , Vet Test, and Lint / Build (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Unit Tests (push) Has been cancelled
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Has been cancelled
fix(sql_helpers): avoid prefix extraction in subqueries
2026-06-08 15:13:58 +02:00
Hein 66348dac97 test(handler): add tests for valid nested request verbs 2026-06-08 09:06:29 +02:00
Hein a87cd18b1b fix(handler): validate nested request structure for relations
* added checks for valid _request values in single and multiple relations
* introduced isValidNestedRequest function to encapsulate validation logic
fix(crud): expand operation handling for nested CUD
* added "add" to insert operations and "modify" to update operations
* included "remove" in delete operations
2026-06-08 09:02:29 +02:00
Hein 29449c93d5 fix(test): add tests for asymmetric join column handling
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Has been cancelled
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Has been cancelled
Build , Vet Test, and Lint / Lint Code (push) Has been cancelled
Build , Vet Test, and Lint / Build (push) Has been cancelled
Tests / Unit Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
2026-06-07 19:13:59 +02:00
Hein 3b6e5c75be fix(handler): update foreign key field resolution logic
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Has been cancelled
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Has been cancelled
Build , Vet Test, and Lint / Lint Code (push) Has been cancelled
Build , Vet Test, and Lint / Build (push) Has been cancelled
Tests / Unit Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
* Adjust foreign key field name selection for has-many/has-one relationships
* Improve logging to clarify foreign key and child field usage
2026-06-07 14:20:55 +02:00
Hein 549ccb8468 fix(handler): fetch updated records after transaction commits
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Has been cancelled
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Has been cancelled
Build , Vet Test, and Lint / Lint Code (push) Has been cancelled
Build , Vet Test, and Lint / Build (push) Has been cancelled
Tests / Unit Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
* Update selection queries to use model columns
* Ensure updated records are fetched and returned in responses
2026-06-05 11:12:04 +02:00
Hein 1af9c76337 fix(handler): fetch updated record after transaction commits
Tests / Unit Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Has been cancelled
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Has been cancelled
Build , Vet Test, and Lint / Lint Code (push) Has been cancelled
Build , Vet Test, and Lint / Build (push) Has been cancelled
2026-06-04 18:23:18 +02:00
Hein 938a2ef3d9 fix(staticweb): add fallback for extensionless file paths
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after 6s
Tests / Integration Tests (push) Failing after 13m59s
Tests / Unit Tests (push) Failing after 14m11s
Build , Vet Test, and Lint / Build (push) Failing after 14m21s
Build , Vet Test, and Lint / Lint Code (push) Failing after 14m31s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Failing after 14m45s
2026-05-27 18:41:43 +02:00
Hein 69cc3e2839 fix(db): update Returning method to accept multiple columns 2026-05-27 14:11:20 +02:00
Hein 4018af0636 fix(validation): enhance filter logic for column validation
* adjust handling of "all" filter to consider filtered columns
fix(function_api): improve variable substitution in SQL queries
* add safeSubstituteVar for context-aware value sanitization
2026-05-27 12:17:31 +02:00
Hein c4e79d6950 fix(validation): use strings.EqualFold for case-insensitive comparison 2026-05-27 12:07:08 +02:00
Hein 982a0e62ac fix(validation): add Columns method to retrieve valid column names 2026-05-27 12:06:46 +02:00
Hein 5d459c95a7 fix(headers): reorder import statements for clarity 2026-05-27 11:28:39 +02:00
Hein e9f7726e43 fix(headers): sort combined parameters before processing 2026-05-27 11:28:22 +02:00
Hein 3d2251317a fix(headers): remove unused utf8 validation in DecodeParam
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after 1s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Failing after 1s
Build , Vet Test, and Lint / Lint Code (push) Failing after 1s
Build , Vet Test, and Lint / Build (push) Failing after 1s
Tests / Unit Tests (push) Failing after 0s
Tests / Integration Tests (push) Failing after 1s
2026-05-26 10:31:34 +02:00
Hein 1ce0ab1ab4 fix(validation): improve preload column validation logic
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Failing after -35m6s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after -35m6s
Build , Vet Test, and Lint / Lint Code (push) Failing after -35m6s
Build , Vet Test, and Lint / Build (push) Failing after -35m6s
Tests / Unit Tests (push) Failing after -35m7s
Tests / Integration Tests (push) Failing after -35m7s
2026-05-21 20:18:01 +02:00
Hein 1f9b230f7f fix(validation): improve preload column validation logic
* Use related model's validator for filtering preload columns
* Ensure valid columns are checked against the correct validator
2026-05-21 20:16:53 +02:00
Hein c42c6b28e3 fix(errortracking): update error context handling in Sentry
* Replace event.Extra with event.Contexts["extra"] for error and message captures.
* Consolidate extra context handling in CapturePanic method.
2026-05-21 14:03:01 +02:00
Hein 57e7503389 feat(staticweb): enhance fallback logic for extensionless paths
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Failing after -35m7s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after -35m7s
Build , Vet Test, and Lint / Lint Code (push) Failing after -35m7s
Build , Vet Test, and Lint / Build (push) Failing after -35m7s
Tests / Unit Tests (push) Failing after -35m8s
Tests / Integration Tests (push) Failing after -35m8s
* Added support for serving index.html for extensionless paths
* Updated isStaticAsset to exclude paths without extensions from static asset checks
2026-05-21 13:30:39 +02:00
Hein 0308644075 feat(auth): add authenticate callback for fallback logic
* Implement SetAuthenticateCallback in authenticators
* Update Authenticate methods to use callback on failure
2026-05-21 11:27:51 +02:00
Hein e5984f5205 feat(security): add cookie support for login and logout methods
* Implement LoginWithCookie and LogoutWithCookie in stubAuthenticator, mockAuth, mockSecurityProvider, and MockAuthenticator
* Update tests to use cookie-based authentication
2026-05-21 09:51:30 +02:00
Hein 76909ae869 feat(security): add cookie support for login and logout methods
* Implement LoginWithCookie and LogoutWithCookie in CompositeSecurityProvider
* Update Authenticator interface to include cookie methods
* Add cookie support in HeaderAuthenticator and JWTAuthenticator
2026-05-21 09:48:46 +02:00
Hein c90c2984ac feat(security): add cookie session support to DatabaseAuthenticator
* Introduce enableCookieSession option for session management
* Implement LoginWithCookie and LogoutWithCookie methods
* Update Authenticate method to support session token from cookie
2026-05-21 09:14:50 +02:00
Hein 1ab4ae33e7 feat(security): implement ChainAuthenticator for sequential authentication 2026-05-21 08:35:39 +02:00
Hein 905457964c fix(restheadspec): remove redundant column selection in query 2026-05-21 08:34:09 +02:00
Hein c42d09238f fix: better error detail for failed sql
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after -35m9s
Build , Vet Test, and Lint / Build (push) Failing after -35m9s
Tests / Unit Tests (push) Failing after -35m10s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Failing after -35m9s
Build , Vet Test, and Lint / Lint Code (push) Failing after -35m9s
Tests / Integration Tests (push) Failing after -35m10s
2026-05-20 13:06:26 +02:00
Hein 0647a88aba fix(restheadspec): qualify primary key and sort columns with table alias 2026-05-20 12:41:16 +02:00
Hein 3d2e11eeed fix(restheadspec): always respond 200 OK regardless of result count in sendFormattedResponse
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -33m27s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -32m51s
Build , Vet Test, and Lint / Lint Code (push) Failing after -33m3s
Build , Vet Test, and Lint / Build (push) Successful in -33m10s
Tests / Unit Tests (push) Successful in -33m58s
Tests / Integration Tests (push) Failing after -34m20s
2026-05-19 09:46:25 +02:00
Hein 4493bfa40f feat(reflection): add IsEmptyValue helper; guard CUD ops against missing PK
Add reflection.IsEmptyValue to detect nil, empty string, and zero numbers.
Use it in recursive CUD processing to skip update/delete when the primary
key is absent, logging a warning instead of proceeding with an invalid operation.
2026-05-19 09:14:19 +02:00
Hein b157379ff8 fix(restheadspec): return 200 OK with empty body instead of 204 on zero results
Frontend clients are sensitive to 204 No Content responses; always return 200
with an empty array/object and rely on X-No-Data-Found header to signal absence
of records.

Also treat "change" as an alias for "update" in recursive CUD processing.
2026-05-19 08:56:11 +02:00
Hein 52752d9c8b fix(bun): adjust field alignment in BunSelectQuery struct
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -33m53s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -33m29s
Build , Vet Test, and Lint / Lint Code (push) Failing after -33m33s
Build , Vet Test, and Lint / Build (push) Successful in -33m41s
Tests / Unit Tests (push) Successful in -34m28s
Tests / Integration Tests (push) Failing after -34m36s
2026-05-18 17:12:32 +02:00
Hein baca5ad29e fix(bun): add relation alias handling for separate-query preloads
* implement preloadRelationAlias to rewrite WHERE conditions
* update Where method to handle relation alias in queries
2026-05-18 17:12:21 +02:00
Hein 53ab22ce02 fix(nestedCUD): handle error in processChildRelations gracefully 2026-05-18 16:14:24 +02:00
Hein 09a3dc92b9 fix(restheadspec): normalize empty results to objects instead of arrays 2026-05-18 14:37:46 +02:00
Hein 6590cd789a fix(nestedCUD): re-select rows after insert/update for accurate state
* Ensure result.Data reflects DB-generated defaults after insert.
* Update result.Data with current DB state after update.
2026-05-18 13:10:13 +02:00
Hein 4244e838b1 fix(reflection): enhance GetForeignKeyColumn logic for self-referential models
* Add support for self-referential models in GetForeignKeyColumn
* Update comments for clarity on foreign key resolution strategies
* Introduce selfRefItem struct for testing self-referential behavior
2026-05-18 13:03:07 +02:00
Hein c42fa11c1a fix(reflection): update GetForeignKeyColumn to return multiple columns
* Change return type to []string for composite keys
* Adjust related logic in injectForeignKeys method
* Update tests to validate new behavior for composite foreign keys
2026-05-18 12:39:06 +02:00
Hein 85bb0f7874 fix(funcspec): update meta variable replacement in SQL query 2026-05-18 12:13:06 +02:00
Hein cd65946191 fix(database): add Scan method to insert query interfaces
* Implement Scan method for BunInsertQuery, GormInsertQuery, and PgSQLInsertQuery
* Update mock implementations to support Scan method
* Introduce GetForeignKeyColumn utility for foreign key resolution
* Add tests for GetForeignKeyColumn functionality
2026-05-18 12:04:50 +02:00
Hein cb416d49c4 fix(headers): handle decoding errors in header values
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -33m58s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -33m22s
Build , Vet Test, and Lint / Lint Code (push) Failing after -33m34s
Build , Vet Test, and Lint / Build (push) Successful in -33m45s
Tests / Unit Tests (push) Failing after -34m38s
Tests / Integration Tests (push) Failing after -34m48s
* return original value if decoding fails
* decode base64 strings when appropriate
2026-05-15 16:59:06 +02:00
Hein cb921f2c5e fix(websocketspec): add transaction access to HookContext 2026-05-15 14:59:34 +02:00
Hein 1ebe0d7ac3 fix(funcspec): refine filter application logic for SQL queries
* update filter checks to only consider SELECT list
* add test for function parameters not matching filters
2026-05-15 14:28:12 +02:00
Hein ae9e06c98b fix(sql_helpers): strip empty RHS conditions from SQL strings
* Add regex patterns to identify and remove empty comparisons
* Implement tests for stripping empty RHS conditions
fix(handler): prevent duplicate JOIN aliases from preload
* Skip custom SQL JOINs if alias already provided by preload
* Split multiple JOIN clauses for individual alias handling
2026-05-15 13:35:24 +02:00
Hein 2ae4d07544 fix(funcspec): remove AllowQueryParamFilters and related logic
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in -26m44s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in -26m26s
Build , Vet Test, and Lint / Build (push) Successful in -34m6s
Build , Vet Test, and Lint / Lint Code (push) Successful in -32m6s
Tests / Integration Tests (push) Failing after -34m50s
Tests / Unit Tests (push) Successful in -30m42s
* Simplify SqlQueryOptions by removing AllowQueryParamFilters
* Update mergeQueryParams to avoid applying filters for JSON arguments
* Add tests for sqlStripStringLiterals and query param handling
2026-05-15 09:25:55 +02:00
Hein 49639b6c19 fix(funcspec): add support for query param filters
* Introduced AllowQueryParamFilters option in SqlQueryOptions
* Implemented applyQueryParamFilters method to handle field filters
2026-05-15 09:07:21 +02:00
Hein 8733176cba fix(funcspec): enhance quote detection for parameters 2026-05-15 08:26:59 +02:00
84 changed files with 7688 additions and 1373 deletions
+2 -2
View File
@@ -524,9 +524,9 @@ For documentation, see [pkg/cache/README.md](pkg/cache/README.md).
#### Security
Authentication and authorization framework with hooks integration.
Authentication and authorization framework with hooks integration. Database-backed providers use PostgreSQL stored procedures by default, with a portable Direct mode (plain Go/SQL) for SQLite, MySQL, or Postgres without the procedures installed.
For documentation, see [pkg/security/README.md](pkg/security/README.md).
For documentation, see [pkg/security/README.md](pkg/security/README.md) (see "Direct Mode" for the SQLite/portable-SQL path).
#### Middleware
+51 -51
View File
@@ -1,48 +1,46 @@
module github.com/bitechdev/ResolveSpec
go 1.24.0
toolchain go1.24.6
go 1.25.7
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/getsentry/sentry-go v0.40.0
github.com/getsentry/sentry-go v0.46.2
github.com/glebarez/sqlite v1.11.0
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.8.0
github.com/klauspost/compress v1.18.2
github.com/mark3labs/mcp-go v0.46.0
github.com/mattn/go-sqlite3 v1.14.33
github.com/microsoft/go-mssqldb v1.9.5
github.com/jackc/pgx/v5 v5.9.2
github.com/klauspost/compress v1.18.6
github.com/mark3labs/mcp-go v0.54.0
github.com/mattn/go-sqlite3 v1.14.44
github.com/microsoft/go-mssqldb v1.10.0
github.com/mochi-mqtt/server/v2 v2.7.9
github.com/nats-io/nats.go v1.48.0
github.com/nats-io/nats.go v1.52.0
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.17.2
github.com/redis/go-redis/v9 v9.19.0
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.40.0
github.com/tidwall/gjson v1.18.0
github.com/tidwall/gjson v1.19.0
github.com/tidwall/sjson v1.2.5
github.com/uptrace/bun v1.2.16
github.com/uptrace/bun v1.2.18
github.com/uptrace/bun/dialect/mssqldialect v1.2.16
github.com/uptrace/bun/dialect/pgdialect v1.2.16
github.com/uptrace/bun/dialect/sqlitedialect v1.2.16
github.com/uptrace/bun/driver/sqliteshim v1.2.16
github.com/uptrace/bunrouter v1.0.23
go.mongodb.org/mongo-driver v1.17.6
go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/trace v1.38.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.46.0
golang.org/x/oauth2 v0.34.0
golang.org/x/time v0.14.0
go.mongodb.org/mongo-driver v1.17.9
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.51.0
golang.org/x/oauth2 v0.36.0
golang.org/x/time v0.15.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/driver/sqlserver v1.6.3
@@ -62,7 +60,7 @@ require (
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // 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
@@ -71,17 +69,17 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -89,7 +87,7 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
@@ -97,25 +95,26 @@ require (
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/montanaflynn/stats v0.9.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/xid v1.4.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
@@ -137,28 +136,29 @@ require (
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/grpc v1.75.0 // 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
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
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.42.2 // indirect
modernc.org/sqlite v1.50.1 // indirect
)
replace github.com/uptrace/bun => github.com/warkanum/bun v1.2.17
+127
View File
@@ -7,27 +7,33 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQ
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/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/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/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/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/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/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=
@@ -36,6 +42,8 @@ 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=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -62,6 +70,8 @@ github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr
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=
@@ -86,8 +96,12 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
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=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -103,11 +117,14 @@ 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-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=
@@ -122,6 +139,8 @@ 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=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -135,6 +154,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
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=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -145,6 +166,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw
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=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
@@ -162,6 +185,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/
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/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=
@@ -177,13 +202,21 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S
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=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
@@ -206,14 +239,20 @@ github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3P
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=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -224,6 +263,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
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=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
@@ -242,22 +283,33 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
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/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=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
@@ -294,6 +346,8 @@ github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3
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=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
@@ -310,10 +364,22 @@ 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=
@@ -339,36 +405,61 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
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=
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/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=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
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=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -385,8 +476,12 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
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=
@@ -395,6 +490,8 @@ 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=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -414,8 +511,12 @@ 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=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -425,6 +526,8 @@ 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=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -450,6 +553,8 @@ 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=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -467,6 +572,7 @@ 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/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=
@@ -483,8 +589,12 @@ 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=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -493,16 +603,24 @@ 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/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=
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=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -528,28 +646,37 @@ 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/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/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
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/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/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=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+115 -48
View File
@@ -39,7 +39,7 @@ func (h *QueryDebugHook) AfterQuery(ctx context.Context, event *bun.QueryEvent)
// This helps identify which specific field is causing scanning issues
func debugScanIntoStruct(rows interface{}, dest interface{}) error {
v := reflect.ValueOf(dest)
if v.Kind() != reflect.Ptr {
if v.Kind() != reflect.Pointer {
return fmt.Errorf("dest must be a pointer")
}
@@ -59,7 +59,7 @@ func debugScanIntoStruct(rows interface{}, dest interface{}) error {
logger.Debug(" Slice element type: %s", elemType)
// If slice of pointers, get the underlying type
if elemType.Kind() == reflect.Ptr {
if elemType.Kind() == reflect.Pointer {
structType = elemType.Elem()
} else {
structType = elemType
@@ -289,19 +289,20 @@ func (b *BunAdapter) DriverName() string {
// BunSelectQuery implements SelectQuery for Bun
type BunSelectQuery struct {
query *bun.SelectQuery
db bun.IDB // Store DB connection for count queries
hasModel bool // Track if Model() was called
schema string // Separated schema name
tableName string // Just the table name, without schema
entity string
tableAlias string
driverName string // Database driver name (postgres, sqlite, mssql)
inJoinContext bool // Track if we're in a JOIN relation context
joinTableAlias string // Alias to use for JOIN conditions
skipAutoDetect bool // Skip auto-detection to prevent circular calls
customPreloads map[string][]func(common.SelectQuery) common.SelectQuery // Relations to load with custom implementation
metricsEnabled bool
query *bun.SelectQuery
db bun.IDB // Store DB connection for count queries
hasModel bool // Track if Model() was called
schema string // Separated schema name
tableName string // Just the table name, without schema
entity string
tableAlias string
driverName string // Database driver name (postgres, sqlite, mssql)
inJoinContext bool // Track if we're in a JOIN relation context
joinTableAlias string // Alias to use for JOIN conditions
skipAutoDetect bool // Skip auto-detection to prevent circular calls
preloadRelationAlias string // Relation alias used in separate-query preloads (e.g. "tprp" for relation "TPRP")
customPreloads map[string][]func(common.SelectQuery) common.SelectQuery // Relations to load with custom implementation
metricsEnabled bool
}
func (b *BunSelectQuery) Model(model interface{}) common.SelectQuery {
@@ -346,12 +347,14 @@ func (b *BunSelectQuery) ColumnExpr(query string, args ...interface{}) common.Se
}
func (b *BunSelectQuery) Where(query string, args ...interface{}) common.SelectQuery {
// If we're in a JOIN context, add table prefix to unqualified columns
if b.inJoinContext && b.joinTableAlias != "" {
query = addTablePrefix(query, b.joinTableAlias)
} else if b.preloadRelationAlias != "" && b.tableName != "" {
// Separate-query preload: the caller may have written conditions using the
// relation name as a prefix (e.g. "TPRP.col"). Bun uses the real table name
// as the alias, so rewrite any such references to use tableName instead.
query = replaceRelationAlias(query, b.preloadRelationAlias, b.tableName)
} else if b.tableAlias != "" && b.tableName != "" {
// If we have a table alias defined, check if the query references a different alias
// This can happen in preloads where the user expects a certain alias but Bun generates another
query = normalizeTableAlias(query, b.tableAlias, b.tableName)
}
b.query = b.query.Where(query, args...)
@@ -487,6 +490,38 @@ func normalizeTableAlias(query, expectedAlias, tableName string) string {
return modified
}
// replaceRelationAlias rewrites WHERE conditions written with a relation alias prefix
// (e.g. "TPRP.col") to use the real table name that bun uses in separate queries
// (e.g. "t_proposalinstance.col"). Only called for separate-query preload wrappers.
func replaceRelationAlias(query, relationAlias, tableName string) string {
if relationAlias == "" || tableName == "" || query == "" {
return query
}
parts := strings.FieldsFunc(query, func(r rune) bool {
return r == ' ' || r == '(' || r == ')' || r == ','
})
modified := query
for _, part := range parts {
if dotIndex := strings.Index(part, "."); dotIndex > 0 {
prefix := part[:dotIndex]
column := part[dotIndex+1:]
if strings.EqualFold(prefix, relationAlias) {
logger.Debug("Replacing relation alias '%s' with table name '%s' in preload WHERE condition", prefix, tableName)
modified = strings.ReplaceAll(modified, part, tableName+"."+column)
}
}
}
return modified
}
func isJoinKeyword(word string) bool {
switch strings.ToUpper(word) {
case "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", "CROSS":
return true
}
return false
}
func (b *BunSelectQuery) WhereOr(query string, args ...interface{}) common.SelectQuery {
b.query = b.query.WhereOr(query, args...)
return b
@@ -517,7 +552,7 @@ func (b *BunSelectQuery) Join(query string, args ...interface{}) common.SelectQu
if prefix != "" && !strings.Contains(strings.ToUpper(query), " AS ") {
// If query doesn't already have AS, check if it's a simple table name
parts := strings.Fields(query)
if len(parts) > 0 && !strings.HasPrefix(strings.ToUpper(parts[0]), "JOIN") {
if len(parts) > 0 && !isJoinKeyword(parts[0]) {
// Simple table name, add prefix: "table AS prefix"
joinClause = fmt.Sprintf("%s AS %s", parts[0], prefix)
if len(parts) > 1 {
@@ -552,7 +587,7 @@ func (b *BunSelectQuery) LeftJoin(query string, args ...interface{}) common.Sele
joinClause := query
if prefix != "" && !strings.Contains(strings.ToUpper(query), " AS ") {
parts := strings.Fields(query)
if len(parts) > 0 && !strings.HasPrefix(strings.ToUpper(parts[0]), "LEFT") && !strings.HasPrefix(strings.ToUpper(parts[0]), "JOIN") {
if len(parts) > 0 && !isJoinKeyword(parts[0]) {
joinClause = fmt.Sprintf("%s AS %s", parts[0], prefix)
if len(parts) > 1 {
joinClause += " " + strings.Join(parts[1:], " ")
@@ -668,8 +703,20 @@ func (b *BunSelectQuery) PreloadRelation(relation string, apply ...func(common.S
wrapper.tableAlias = provider.TableAlias()
logger.Debug("Preload relation '%s' using table alias: %s", relation, wrapper.tableAlias)
}
}
// Fallback: if the model didn't provide a table name, ask bun directly.
if wrapper.tableName == "" {
wrapper.schema, wrapper.tableName = parseTableName(sq.GetTableName(), b.driverName)
}
// For separate-query preloads (has-many), bun aliases the related table using
// the actual table name, not the relation name. Record the relation alias so
// Where() can rewrite conditions like "TPRP.col" to "t_proposalinstance.col".
wrapper.preloadRelationAlias = strings.ToLower(relation)
logger.Debug("Preload relation '%s' registered alias '%s' for separate-query WHERE rewriting", relation, wrapper.preloadRelationAlias)
// Start with the interface value (not pointer)
current := common.SelectQuery(wrapper)
@@ -700,7 +747,7 @@ func checkIfRelationAlreadyLoaded(parents reflect.Value, relationName string) (r
// Get the first parent to check the relation field
firstParent := parents.Index(0)
if firstParent.Kind() == reflect.Ptr {
if firstParent.Kind() == reflect.Pointer {
firstParent = firstParent.Elem()
}
@@ -715,7 +762,7 @@ func checkIfRelationAlreadyLoaded(parents reflect.Value, relationName string) (r
// Check if any parent has a non-empty slice
for i := 0; i < parents.Len(); i++ {
parent := parents.Index(i)
if parent.Kind() == reflect.Ptr {
if parent.Kind() == reflect.Pointer {
parent = parent.Elem()
}
field := parent.FieldByName(relationName)
@@ -724,7 +771,7 @@ func checkIfRelationAlreadyLoaded(parents reflect.Value, relationName string) (r
allRelated := reflect.MakeSlice(field.Type(), 0, field.Len()*parents.Len())
for j := 0; j < parents.Len(); j++ {
p := parents.Index(j)
if p.Kind() == reflect.Ptr {
if p.Kind() == reflect.Pointer {
p = p.Elem()
}
f := p.FieldByName(relationName)
@@ -737,7 +784,7 @@ func checkIfRelationAlreadyLoaded(parents reflect.Value, relationName string) (r
return allRelated, true
}
}
} else if relationField.Kind() == reflect.Ptr {
} else if relationField.Kind() == reflect.Pointer {
// Check if it's a pointer (has-one/belongs-to)
if !relationField.IsNil() {
// Already loaded! Collect all related records from all parents
@@ -745,7 +792,7 @@ func checkIfRelationAlreadyLoaded(parents reflect.Value, relationName string) (r
allRelated := reflect.MakeSlice(reflect.SliceOf(relatedType), 0, parents.Len())
for j := 0; j < parents.Len(); j++ {
p := parents.Index(j)
if p.Kind() == reflect.Ptr {
if p.Kind() == reflect.Pointer {
p = p.Elem()
}
f := p.FieldByName(relationName)
@@ -769,7 +816,7 @@ func (b *BunSelectQuery) loadCustomPreloads(ctx context.Context) error {
// Get the actual data from the model
modelValue := reflect.ValueOf(model.Value())
if modelValue.Kind() == reflect.Ptr {
if modelValue.Kind() == reflect.Pointer {
modelValue = modelValue.Elem()
}
@@ -837,7 +884,7 @@ func (b *BunSelectQuery) loadRelationLevel(ctx context.Context, parentRecords re
// Get the first record to inspect the struct type
firstRecord := parentRecords.Index(0)
if firstRecord.Kind() == reflect.Ptr {
if firstRecord.Kind() == reflect.Pointer {
firstRecord = firstRecord.Elem()
}
@@ -883,7 +930,7 @@ func (b *BunSelectQuery) loadRelationLevel(ctx context.Context, parentRecords re
if isSlice {
relatedType = relatedType.Elem()
}
if relatedType.Kind() == reflect.Ptr {
if relatedType.Kind() == reflect.Pointer {
relatedType = relatedType.Elem()
}
@@ -971,7 +1018,7 @@ func extractForeignKeyValues(records reflect.Value, fkFieldName string) ([]inter
for i := 0; i < records.Len(); i++ {
record := records.Index(i)
if record.Kind() == reflect.Ptr {
if record.Kind() == reflect.Pointer {
record = record.Elem()
}
@@ -1036,7 +1083,7 @@ func associateRelatedRecords(parents, related reflect.Value, fieldName string, r
for i := 0; i < related.Len(); i++ {
relRecord := related.Index(i)
relRecordElem := relRecord
if relRecordElem.Kind() == reflect.Ptr {
if relRecordElem.Kind() == reflect.Pointer {
relRecordElem = relRecordElem.Elem()
}
@@ -1062,7 +1109,7 @@ func associateRelatedRecords(parents, related reflect.Value, fieldName string, r
for i := 0; i < parents.Len(); i++ {
parentPtr := parents.Index(i)
parent := parentPtr
if parent.Kind() == reflect.Ptr {
if parent.Kind() == reflect.Pointer {
parent = parent.Elem()
}
@@ -1268,6 +1315,7 @@ func (b *BunSelectQuery) Scan(ctx context.Context, dest interface{}) (err error)
if err != nil {
sqlStr := b.query.String()
logger.Error("BunSelectQuery.Scan failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
return err
}
@@ -1284,11 +1332,11 @@ func (b *BunSelectQuery) ScanModel(ctx context.Context) (err error) {
modelInfo = fmt.Sprintf("Model type: %T", modelValue)
v := reflect.ValueOf(modelValue)
if v.Kind() == reflect.Ptr {
if v.Kind() == reflect.Pointer {
v = v.Elem()
}
if v.Kind() == reflect.Slice {
if v.Type().Elem().Kind() == reflect.Ptr {
if v.Type().Elem().Kind() == reflect.Pointer {
modelInfo += fmt.Sprintf(", Slice of: %s", v.Type().Elem().Elem().Name())
} else {
modelInfo += fmt.Sprintf(", Slice of: %s", v.Type().Elem().Name())
@@ -1324,7 +1372,7 @@ func (b *BunSelectQuery) ScanModel(ctx context.Context) (err error) {
if err != nil {
sqlStr := b.query.String()
logger.Error("BunSelectQuery.ScanModel failed. SQL: %s. Error: %v", sqlStr, err)
return err
return common.WrapSQLError(err, sqlStr)
}
// After main query, load custom preloads using separate queries
@@ -1354,6 +1402,7 @@ func (b *BunSelectQuery) Count(ctx context.Context) (count int, err error) {
if err != nil {
sqlStr := b.query.String()
logger.Error("BunSelectQuery.Count failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
return
}
@@ -1367,6 +1416,7 @@ func (b *BunSelectQuery) Count(ctx context.Context) (count int, err error) {
if err != nil {
sqlStr := countQuery.String()
logger.Error("BunSelectQuery.Count (subquery) failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
return
}
@@ -1384,6 +1434,7 @@ func (b *BunSelectQuery) Exists(ctx context.Context) (exists bool, err error) {
if err != nil {
sqlStr := b.query.String()
logger.Error("BunSelectQuery.Exists failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
return
}
@@ -1438,11 +1489,23 @@ func (b *BunInsertQuery) OnConflict(action string) common.InsertQuery {
func (b *BunInsertQuery) Returning(columns ...string) common.InsertQuery {
if len(columns) > 0 {
b.query = b.query.Returning(columns[0])
b.query = b.query.Returning(strings.Join(columns, ", "))
}
return b
}
func (b *BunInsertQuery) prepareValues() {
if len(b.values) > 0 {
if !b.hasModel {
b.query = b.query.Model(&b.values)
} else {
for k, v := range b.values {
b.query = b.query.Value(k, "?", v)
}
}
}
}
func (b *BunInsertQuery) Exec(ctx context.Context) (res common.Result, err error) {
defer func() {
if r := recover(); r != nil {
@@ -1450,23 +1513,25 @@ func (b *BunInsertQuery) Exec(ctx context.Context) (res common.Result, err error
}
}()
startedAt := time.Now()
if len(b.values) > 0 {
if !b.hasModel {
// If no model was set, use the values map as the model
// Bun can insert map[string]interface{} directly
b.query = b.query.Model(&b.values)
} else {
// If model was set, use Value() to add individual values
for k, v := range b.values {
b.query = b.query.Value(k, "?", v)
}
}
}
b.prepareValues()
result, err := b.query.Exec(ctx)
recordQueryMetrics(b.metricsEnabled, "INSERT", b.schema, b.entity, b.tableName, startedAt, err)
return &BunResult{result: result}, err
}
func (b *BunInsertQuery) Scan(ctx context.Context, dest interface{}) (err error) {
defer func() {
if r := recover(); r != nil {
err = logger.HandlePanic("BunInsertQuery.Scan", r)
}
}()
startedAt := time.Now()
b.prepareValues()
err = b.query.Scan(ctx, dest)
recordQueryMetrics(b.metricsEnabled, "INSERT", b.schema, b.entity, b.tableName, startedAt, err)
return err
}
// BunUpdateQuery implements UpdateQuery for Bun
type BunUpdateQuery struct {
query *bun.UpdateQuery
@@ -1541,7 +1606,7 @@ func (b *BunUpdateQuery) Where(query string, args ...interface{}) common.UpdateQ
func (b *BunUpdateQuery) Returning(columns ...string) common.UpdateQuery {
if len(columns) > 0 {
b.query = b.query.Returning(columns[0])
b.query = b.query.Returning(strings.Join(columns, ", "))
}
return b
}
@@ -1558,6 +1623,7 @@ func (b *BunUpdateQuery) Exec(ctx context.Context) (res common.Result, err error
// Log SQL string for debugging
sqlStr := b.query.String()
logger.Error("BunUpdateQuery.Exec failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(b.metricsEnabled, "UPDATE", b.schema, b.entity, b.tableName, startedAt, err)
return &BunResult{result: result}, err
@@ -1609,6 +1675,7 @@ func (b *BunDeleteQuery) Exec(ctx context.Context) (res common.Result, err error
// Log SQL string for debugging
sqlStr := b.query.String()
logger.Error("BunDeleteQuery.Exec failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(b.metricsEnabled, "DELETE", b.schema, b.entity, b.tableName, startedAt, err)
return &BunResult{result: result}, err
+89 -10
View File
@@ -3,11 +3,13 @@ package database
import (
"context"
"fmt"
"reflect"
"strings"
"sync"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger"
@@ -581,6 +583,7 @@ func (g *GormSelectQuery) Scan(ctx context.Context, dest interface{}) (err error
return tx.Find(dest)
})
logger.Error("GormSelectQuery.Scan failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(g.metricsEnabled, "SELECT", g.schema, g.entity, g.tableName, startedAt, err)
return err
@@ -611,6 +614,7 @@ func (g *GormSelectQuery) ScanModel(ctx context.Context) (err error) {
return tx.Find(g.db.Statement.Model)
})
logger.Error("GormSelectQuery.ScanModel failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(g.metricsEnabled, "SELECT", g.schema, g.entity, g.tableName, startedAt, err)
return err
@@ -640,6 +644,7 @@ func (g *GormSelectQuery) Count(ctx context.Context) (count int, err error) {
return tx.Count(&count64)
})
logger.Error("GormSelectQuery.Count failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(g.metricsEnabled, "COUNT", g.schema, g.entity, g.tableName, startedAt, err)
return int(count64), err
@@ -669,6 +674,7 @@ func (g *GormSelectQuery) Exists(ctx context.Context) (exists bool, err error) {
return tx.Limit(1).Count(&count)
})
logger.Error("GormSelectQuery.Exists failed. SQL: %s. Error: %v", sqlStr, err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(g.metricsEnabled, "EXISTS", g.schema, g.entity, g.tableName, startedAt, err)
return count > 0, err
@@ -676,15 +682,16 @@ func (g *GormSelectQuery) Exists(ctx context.Context) (exists bool, err error) {
// GormInsertQuery implements InsertQuery for GORM
type GormInsertQuery struct {
db *gorm.DB
reconnect func(...*gorm.DB) error
model interface{}
values map[string]interface{}
schema string
tableName string
entity string
driverName string
metricsEnabled bool
db *gorm.DB
reconnect func(...*gorm.DB) error
model interface{}
values map[string]interface{}
schema string
tableName string
entity string
driverName string
metricsEnabled bool
returningColumns []string
}
func (g *GormInsertQuery) Model(model interface{}) common.InsertQuery {
@@ -718,7 +725,7 @@ func (g *GormInsertQuery) OnConflict(action string) common.InsertQuery {
}
func (g *GormInsertQuery) Returning(columns ...string) common.InsertQuery {
// GORM doesn't have explicit RETURNING, but updates the model
g.returningColumns = columns
return g
}
@@ -749,6 +756,76 @@ func (g *GormInsertQuery) Exec(ctx context.Context) (res common.Result, err erro
return &GormResult{result: result}, result.Error
}
func (g *GormInsertQuery) Scan(ctx context.Context, dest interface{}) (err error) {
defer func() {
if r := recover(); r != nil {
err = logger.HandlePanic("GormInsertQuery.Scan", r)
}
}()
startedAt := time.Now()
var returningCols []clause.Column
for _, col := range g.returningColumns {
returningCols = append(returningCols, clause.Column{Name: col})
}
db := g.db.WithContext(ctx)
if len(returningCols) > 0 {
db = db.Clauses(clause.Returning{Columns: returningCols})
}
var result *gorm.DB
switch {
case g.model != nil:
result = db.Create(g.model)
case g.values != nil:
result = db.Create(g.values)
default:
result = db.Create(map[string]interface{}{})
}
if isDBClosed(result.Error) && g.reconnect != nil {
if reconnErr := g.reconnect(g.db); reconnErr == nil {
result = db.Create(g.model)
}
}
recordQueryMetrics(g.metricsEnabled, "INSERT", g.schema, g.entity, g.tableName, startedAt, result.Error)
if result.Error != nil {
return result.Error
}
// Extract the returning column value from the model or values map
if len(g.returningColumns) == 1 {
col := g.returningColumns[0]
if g.model != nil {
val := reflect.ValueOf(g.model)
if val.Kind() == reflect.Pointer {
val = val.Elem()
}
if val.Kind() == reflect.Struct {
for i := 0; i < val.NumField(); i++ {
f := val.Type().Field(i)
dbTag := strings.Split(f.Tag.Get("bun"), ",")[0]
jsonTag := strings.Split(f.Tag.Get("json"), ",")[0]
if strings.EqualFold(f.Name, col) || dbTag == col || jsonTag == col {
reflect.ValueOf(dest).Elem().Set(val.Field(i))
return nil
}
}
}
}
if g.values != nil {
if v, ok := g.values[col]; ok {
reflect.ValueOf(dest).Elem().Set(reflect.ValueOf(v))
return nil
}
}
}
return nil
}
// GormUpdateQuery implements UpdateQuery for GORM
type GormUpdateQuery struct {
db *gorm.DB
@@ -858,6 +935,7 @@ func (g *GormUpdateQuery) Exec(ctx context.Context) (res common.Result, err erro
return tx.Updates(g.updates)
})
logger.Error("GormUpdateQuery.Exec failed. SQL: %s. Error: %v", sqlStr, result.Error)
return &GormResult{result: result}, common.WrapSQLError(result.Error, sqlStr)
}
recordQueryMetrics(g.metricsEnabled, "UPDATE", g.schema, g.entity, g.tableName, startedAt, result.Error)
return &GormResult{result: result}, result.Error
@@ -919,6 +997,7 @@ func (g *GormDeleteQuery) Exec(ctx context.Context) (res common.Result, err erro
return tx.Delete(g.model)
})
logger.Error("GormDeleteQuery.Exec failed. SQL: %s. Error: %v", sqlStr, result.Error)
return &GormResult{result: result}, common.WrapSQLError(result.Error, sqlStr)
}
recordQueryMetrics(g.metricsEnabled, "DELETE", g.schema, g.entity, g.tableName, startedAt, result.Error)
return &GormResult{result: result}, result.Error
+74 -22
View File
@@ -138,7 +138,7 @@ func (p *PgSQLAdapter) Exec(ctx context.Context, query string, args ...interface
if err != nil {
logger.Error("PgSQL Exec failed: %v", err)
recordQueryMetrics(p.metricsEnabled, operation, schema, entity, table, startedAt, err)
return nil, err
return nil, common.WrapSQLError(err, query)
}
recordQueryMetrics(p.metricsEnabled, operation, schema, entity, table, startedAt, nil)
return &PgSQLResult{result: result}, nil
@@ -164,7 +164,7 @@ func (p *PgSQLAdapter) Query(ctx context.Context, dest interface{}, query string
if err != nil {
logger.Error("PgSQL Query failed: %v", err)
recordQueryMetrics(p.metricsEnabled, operation, schema, entity, table, startedAt, err)
return err
return common.WrapSQLError(err, query)
}
defer rows.Close()
@@ -511,7 +511,7 @@ func (p *PgSQLSelectQuery) Scan(ctx context.Context, dest interface{}) (err erro
if err != nil {
logger.Error("PgSQL SELECT failed: %v", err)
recordQueryMetrics(p.metricsEnabled, "SELECT", p.schema, p.entity, p.tableName, startedAt, err)
return err
return common.WrapSQLError(err, query)
}
defer rows.Close()
@@ -534,8 +534,8 @@ func (p *PgSQLSelectQuery) ScanModel(ctx context.Context) error {
return p.Scan(ctx, p.model)
}
// countInternal executes the COUNT query and returns the result without recording metrics.
func (p *PgSQLSelectQuery) countInternal(ctx context.Context) (int, error) {
// countInternal executes the COUNT query and returns the result and the SQL string without recording metrics.
func (p *PgSQLSelectQuery) countInternal(ctx context.Context) (rowCount int, querySQL string, retErr error) {
var sb strings.Builder
sb.WriteString("SELECT COUNT(*) FROM ")
sb.WriteString(p.tableName)
@@ -571,9 +571,9 @@ func (p *PgSQLSelectQuery) countInternal(ctx context.Context) (int, error) {
var count int
if err := row.Scan(&count); err != nil {
return 0, err
return 0, query, err
}
return count, nil
return count, query, nil
}
func (p *PgSQLSelectQuery) Count(ctx context.Context) (count int, err error) {
@@ -584,9 +584,11 @@ func (p *PgSQLSelectQuery) Count(ctx context.Context) (count int, err error) {
}
}()
startedAt := time.Now()
count, err = p.countInternal(ctx)
var sqlStr string
count, sqlStr, err = p.countInternal(ctx)
if err != nil {
logger.Error("PgSQL COUNT failed: %v", err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(p.metricsEnabled, "COUNT", p.schema, p.entity, p.tableName, startedAt, err)
return count, err
@@ -600,9 +602,11 @@ func (p *PgSQLSelectQuery) Exists(ctx context.Context) (exists bool, err error)
}
}()
startedAt := time.Now()
count, err := p.countInternal(ctx)
var sqlStr string
count, sqlStr, err := p.countInternal(ctx)
if err != nil {
logger.Error("PgSQL EXISTS failed: %v", err)
err = common.WrapSQLError(err, sqlStr)
}
recordQueryMetrics(p.metricsEnabled, "EXISTS", p.schema, p.entity, p.tableName, startedAt, err)
return count > 0, err
@@ -702,12 +706,60 @@ func (p *PgSQLInsertQuery) Exec(ctx context.Context) (res common.Result, err err
if err != nil {
logger.Error("PgSQL INSERT failed: %v", err)
return nil, err
return nil, common.WrapSQLError(err, query)
}
return &PgSQLResult{result: result}, nil
}
func (p *PgSQLInsertQuery) Scan(ctx context.Context, dest interface{}) (err error) {
startedAt := time.Now()
defer func() {
if r := recover(); r != nil {
err = logger.HandlePanic("PgSQLInsertQuery.Scan", r)
}
recordQueryMetrics(p.metricsEnabled, "INSERT", p.schema, p.entity, p.tableName, startedAt, err)
}()
if len(p.values) == 0 {
return fmt.Errorf("no values to insert")
}
columns := make([]string, 0, len(p.values))
placeholders := make([]string, 0, len(p.values))
args := make([]interface{}, 0, len(p.values))
i := 1
for _, col := range p.valueOrder {
columns = append(columns, col)
placeholders = append(placeholders, fmt.Sprintf("$%d", i))
args = append(args, p.values[col])
i++
}
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
p.tableName,
strings.Join(columns, ", "),
strings.Join(placeholders, ", "))
if len(p.returning) > 0 {
query += " RETURNING " + strings.Join(p.returning, ", ")
}
logger.Debug("PgSQL INSERT (Scan): %s [args: %v]", query, args)
var row *sql.Row
if p.tx != nil {
row = p.tx.QueryRowContext(ctx, query, args...)
} else {
row = p.db.QueryRowContext(ctx, query, args...)
}
if err := row.Scan(dest); err != nil {
return common.WrapSQLError(err, query)
}
return nil
}
// PgSQLUpdateQuery implements UpdateQuery for PostgreSQL
type PgSQLUpdateQuery struct {
db *sql.DB
@@ -884,7 +936,7 @@ func (p *PgSQLUpdateQuery) Exec(ctx context.Context) (res common.Result, err err
if err != nil {
logger.Error("PgSQL UPDATE failed: %v", err)
return nil, err
return nil, common.WrapSQLError(err, query)
}
return &PgSQLResult{result: result}, nil
@@ -962,7 +1014,7 @@ func (p *PgSQLDeleteQuery) Exec(ctx context.Context) (res common.Result, err err
if err != nil {
logger.Error("PgSQL DELETE failed: %v", err)
return nil, err
return nil, common.WrapSQLError(err, query)
}
return &PgSQLResult{result: result}, nil
@@ -1043,7 +1095,7 @@ func (p *PgSQLTxAdapter) Exec(ctx context.Context, query string, args ...interfa
if err != nil {
logger.Error("PgSQL Tx Exec failed: %v", err)
recordQueryMetrics(p.metricsEnabled, operation, schema, entity, table, startedAt, err)
return nil, err
return nil, common.WrapSQLError(err, query)
}
recordQueryMetrics(p.metricsEnabled, operation, schema, entity, table, startedAt, nil)
return &PgSQLResult{result: result}, nil
@@ -1057,7 +1109,7 @@ func (p *PgSQLTxAdapter) Query(ctx context.Context, dest interface{}, query stri
if err != nil {
logger.Error("PgSQL Tx Query failed: %v", err)
recordQueryMetrics(p.metricsEnabled, operation, schema, entity, table, startedAt, err)
return err
return common.WrapSQLError(err, query)
}
defer rows.Close()
@@ -1143,7 +1195,7 @@ func (p *PgSQLSelectQuery) applySubqueryPreloads(ctx context.Context, dest inter
// Use reflection to process the destination
destValue := reflect.ValueOf(dest)
if destValue.Kind() != reflect.Ptr {
if destValue.Kind() != reflect.Pointer {
return fmt.Errorf("dest must be a pointer")
}
@@ -1170,7 +1222,7 @@ func (p *PgSQLSelectQuery) applySubqueryPreloads(ctx context.Context, dest inter
// loadPreloadsForRecord loads all preload relationships for a single record
func (p *PgSQLSelectQuery) loadPreloadsForRecord(ctx context.Context, record reflect.Value, preloads []preloadConfig) error {
if record.Kind() == reflect.Ptr {
if record.Kind() == reflect.Pointer {
if record.IsNil() {
return nil
}
@@ -1247,7 +1299,7 @@ func (p *PgSQLSelectQuery) executePreloadQuery(ctx context.Context, field reflec
} else {
// Single struct - create a pointer if needed
var target reflect.Value
if field.Kind() == reflect.Ptr {
if field.Kind() == reflect.Pointer {
target = reflect.New(field.Type().Elem())
} else {
target = reflect.New(field.Type())
@@ -1260,7 +1312,7 @@ func (p *PgSQLSelectQuery) executePreloadQuery(ctx context.Context, field reflec
}
// Set the field
if field.Kind() == reflect.Ptr {
if field.Kind() == reflect.Pointer {
field.Set(target)
} else {
field.Set(target.Elem())
@@ -1277,7 +1329,7 @@ func (p *PgSQLSelectQuery) getRelationMetadata(fieldName string) *relationMetada
}
modelType := reflect.TypeOf(p.model)
if modelType.Kind() == reflect.Ptr {
if modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
@@ -1326,7 +1378,7 @@ func (p *PgSQLSelectQuery) getRelationMetadataFromField(modelType reflect.Type,
if fieldType.Kind() == reflect.Slice {
fieldType = fieldType.Elem()
}
if fieldType.Kind() == reflect.Ptr {
if fieldType.Kind() == reflect.Pointer {
fieldType = fieldType.Elem()
}
@@ -1359,7 +1411,7 @@ func scanRows(rows *sql.Rows, dest interface{}) error {
// Get destination type
destValue := reflect.ValueOf(dest)
if destValue.Kind() != reflect.Ptr {
if destValue.Kind() != reflect.Pointer {
return fmt.Errorf("dest must be a pointer")
}
@@ -1414,7 +1466,7 @@ func scanRowsToMapSlice(rows *sql.Rows, columns []string, destValue reflect.Valu
// scanRowsToStructSlice scans rows into a slice of structs
func scanRowsToStructSlice(rows *sql.Rows, columns []string, destValue reflect.Value) error {
elemType := destValue.Type().Elem()
isPtr := elemType.Kind() == reflect.Ptr
isPtr := elemType.Kind() == reflect.Pointer
if isPtr {
elemType = elemType.Elem()
@@ -71,7 +71,7 @@ func entityNameFromModel(model interface{}, table string) string {
}
modelType := reflect.TypeOf(model)
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
@@ -108,7 +108,7 @@ func tableNameProviderFromModel(model interface{}) (common.TableNameProvider, bo
}
modelType := reflect.TypeOf(model)
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
+3 -1
View File
@@ -174,7 +174,9 @@ func (h *HTTPResponseWriter) Write(data []byte) (int, error) {
func (h *HTTPResponseWriter) WriteJSON(data interface{}) error {
h.SetHeader("Content-Type", "application/json")
return json.NewEncoder(h.resp).Encode(data)
enc := json.NewEncoder(h.resp)
enc.SetEscapeHTML(false)
return enc.Encode(data)
}
// UnderlyingResponseWriter returns the underlying http.ResponseWriter
+21 -14
View File
@@ -115,32 +115,39 @@ func GetHeadSpecHeaders() []string {
// SetCORSHeaders sets CORS headers on a response writer
func SetCORSHeaders(w ResponseWriter, r Request, config CORSConfig) {
// Set allowed origins
// if len(config.AllowedOrigins) > 0 {
// w.SetHeader("Access-Control-Allow-Origin", strings.Join(config.AllowedOrigins, ", "))
// }
// Todo origin list parsing
w.SetHeader("Access-Control-Allow-Origin", "*")
// Reflect the request origin; fall back to wildcard only when no origin is present
origin := r.Header("Origin")
if origin == "" {
origin = "*"
} else {
// Vary must be set so caches don't serve one origin's response to another
httpW := w.UnderlyingResponseWriter()
httpW.Header().Set("Vary", "Origin")
}
w.SetHeader("Access-Control-Allow-Origin", origin)
// Set allowed methods
if len(config.AllowedMethods) > 0 {
w.SetHeader("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
}
// Set allowed headers
// if len(config.AllowedHeaders) > 0 {
// w.SetHeader("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
// }
w.SetHeader("Access-Control-Allow-Headers", "*")
// Reflect the preflight request headers when present; otherwise use the explicit config list
requestedHeaders := r.Header("Access-Control-Request-Headers")
if requestedHeaders != "" {
w.SetHeader("Access-Control-Allow-Headers", requestedHeaders)
} else if len(config.AllowedHeaders) > 0 {
w.SetHeader("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
}
// Set max age
if config.MaxAge > 0 {
w.SetHeader("Access-Control-Max-Age", fmt.Sprintf("%d", config.MaxAge))
}
// Allow credentials
w.SetHeader("Access-Control-Allow-Credentials", "true")
// Allow credentials only when a specific origin is reflected (not wildcard)
if origin != "*" {
w.SetHeader("Access-Control-Allow-Credentials", "true")
}
// Expose headers that clients can read
exposeHeaders := config.AllowedHeaders
+9 -9
View File
@@ -25,7 +25,7 @@ func ValidateAndUnwrapModel(model interface{}) (*ValidateAndUnwrapModelResult, e
originalType := modelType
// Unwrap pointers, slices, and arrays to get to the base struct type
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
@@ -126,15 +126,15 @@ func GetRelationshipInfo(modelType reflect.Type, relationName string) *Relations
// Get related model type
if field.Type.Kind() == reflect.Slice {
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
if elemType.Kind() == reflect.Pointer {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
} else if field.Type.Kind() == reflect.Ptr || field.Type.Kind() == reflect.Struct {
} else if field.Type.Kind() == reflect.Pointer || field.Type.Kind() == reflect.Struct {
elemType := field.Type
if elemType.Kind() == reflect.Ptr {
if elemType.Kind() == reflect.Pointer {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
@@ -155,16 +155,16 @@ func GetRelationshipInfo(modelType reflect.Type, relationName string) *Relations
info.RelationType = "hasMany"
// Get the element type for slice
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
if elemType.Kind() == reflect.Pointer {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
info.RelatedModel = reflect.New(elemType).Elem().Interface()
}
} else if field.Type.Kind() == reflect.Ptr || field.Type.Kind() == reflect.Struct {
} else if field.Type.Kind() == reflect.Pointer || field.Type.Kind() == reflect.Struct {
info.RelationType = "belongsTo"
elemType := field.Type
if elemType.Kind() == reflect.Ptr {
if elemType.Kind() == reflect.Pointer {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
@@ -177,7 +177,7 @@ func GetRelationshipInfo(modelType reflect.Type, relationName string) *Relations
// Get the element type for many2many (always slice)
if field.Type.Kind() == reflect.Slice {
elemType := field.Type.Elem()
if elemType.Kind() == reflect.Ptr {
if elemType.Kind() == reflect.Pointer {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
@@ -239,7 +239,7 @@ func GetTableNameFromModel(model interface{}) string {
modelType := reflect.TypeOf(model)
// Unwrap pointers
for modelType != nil && modelType.Kind() == reflect.Ptr {
for modelType != nil && modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
+4 -1
View File
@@ -75,6 +75,7 @@ type InsertQuery interface {
// Execution
Exec(ctx context.Context) (Result, error)
Scan(ctx context.Context, dest interface{}) error
}
// UpdateQuery interface for building UPDATE queries
@@ -177,7 +178,9 @@ func (s *StandardResponseWriter) Write(data []byte) (int, error) {
func (s *StandardResponseWriter) WriteJSON(data interface{}) error {
s.SetHeader("Content-Type", "application/json")
return json.NewEncoder(s.w).Encode(data)
enc := json.NewEncoder(s.w)
enc.SetEscapeHTML(false)
return enc.Encode(data)
}
func (s *StandardResponseWriter) UnderlyingResponseWriter() http.ResponseWriter {
+86 -44
View File
@@ -69,7 +69,7 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
// Get model type for reflection
modelType := reflect.TypeOf(model)
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
@@ -113,7 +113,7 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
// Process based on operation
switch strings.ToLower(operation) {
case "insert", "create":
case "insert", "create", "add":
// Only perform insert if we have data to insert
if hasData {
id, err := p.processInsert(ctx, regularData, tableName)
@@ -125,6 +125,13 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
result.AffectedRows = 1
result.Data = regularData
// Re-select the inserted row so result.Data reflects DB-generated defaults.
if row, err := p.processSelect(ctx, tableName, id); err != nil {
logger.Warn("Select after insert failed: table=%s, id=%v, error=%v", tableName, id, err)
} else if len(row) > 0 {
result.Data = row
}
// Process child relations after parent insert (to get parent ID)
if err := p.processChildRelations(ctx, "insert", id, relationFields, result.RelationData, modelType, parentIDs); err != nil {
logger.Error("Failed to process child relations after insert: table=%s, parentID=%v, relations=%+v, error=%v", tableName, id, relationFields, err)
@@ -134,8 +141,12 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
logger.Debug("Skipping insert for %s - no data columns besides _request", tableName)
}
case "update":
case "update", "change", "modify":
// Only perform update if we have data to update
if reflection.IsEmptyValue(data[pkName]) {
logger.Warn("Skipping update for %s - no primary key", tableName)
return result, nil
}
if hasData {
rows, err := p.processUpdate(ctx, regularData, tableName, data[pkName])
if err != nil {
@@ -146,9 +157,16 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
result.AffectedRows = rows
result.Data = regularData
// Re-select the updated row so result.Data reflects current DB state.
if row, err := p.processSelect(ctx, tableName, result.ID); err != nil {
logger.Warn("Select after update failed: table=%s, id=%v, error=%v", tableName, result.ID, err)
} else if len(row) > 0 {
result.Data = row
}
// Process child relations for update
if err := p.processChildRelations(ctx, "update", data[pkName], relationFields, result.RelationData, modelType, parentIDs); err != nil {
logger.Error("Failed to process child relations after update: table=%s, parentID=%v, relations=%+v, error=%v", tableName, data[pkName], relationFields, err)
logger.Error("Failed to process child relations after update: table=%s, parentID=%v, relations=%+v, error=%v", tableName, data[pkName], regularData, err)
return nil, fmt.Errorf("failed to process child relations: %w", err)
}
} else {
@@ -156,11 +174,16 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
result.ID = data[pkName]
}
case "delete":
case "delete", "remove":
if reflection.IsEmptyValue(data[pkName]) {
logger.Warn("Skipping delete for %s - no primary key", tableName)
return result, nil
}
// Process child relations first (for referential integrity)
if err := p.processChildRelations(ctx, "delete", data[pkName], relationFields, result.RelationData, modelType, parentIDs); err != nil {
logger.Error("Failed to process child relations before delete: table=%s, id=%v, relations=%+v, error=%v", tableName, data[pkName], relationFields, err)
return nil, fmt.Errorf("failed to process child relations before delete: %w", err)
return nil, fmt.Errorf("failed to process child relations: %w", err)
}
rows, err := p.processDelete(ctx, tableName, data[pkName])
@@ -201,7 +224,7 @@ func (p *NestedCUDProcessor) filterValidFields(data map[string]interface{}, mode
}
modelType := reflect.TypeOf(model)
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
@@ -234,27 +257,37 @@ func (p *NestedCUDProcessor) injectForeignKeys(data map[string]interface{}, mode
return
}
// Iterate through model fields to find foreign key fields
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
jsonTag := field.Tag.Get("json")
jsonName := strings.Split(jsonTag, ",")[0]
pkCol := reflection.GetPrimaryKeyName(reflect.New(modelType).Interface())
// Check if this field is a foreign key and we have a parent ID for it
// Common patterns: DepartmentID, ManagerID, ProjectID, etc.
for parentKey, parentID := range parentIDs {
// Match field name patterns like "department_id" with parent key "department"
if strings.EqualFold(jsonName, parentKey+"_id") ||
strings.EqualFold(jsonName, parentKey+"id") ||
strings.EqualFold(field.Name, parentKey+"ID") {
// Use the DB column name as the key, since data is keyed by DB column names
dbColName := reflection.GetColumnName(field)
if _, exists := data[dbColName]; !exists {
logger.Debug("Injecting foreign key: %s = %v", dbColName, parentID)
data[dbColName] = parentID
for parentKey, parentID := range parentIDs {
dbColNames := reflection.GetForeignKeyColumn(modelType, parentKey)
if len(dbColNames) == 0 {
// No explicit tag found — fall back to naming convention by scanning scalar fields.
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
jsonName := strings.Split(field.Tag.Get("json"), ",")[0]
if strings.EqualFold(jsonName, "rid"+parentKey) ||
strings.EqualFold(jsonName, "rid_"+parentKey) ||
strings.EqualFold(jsonName, "id_"+parentKey) ||
strings.EqualFold(jsonName, parentKey+"_id") ||
strings.EqualFold(jsonName, parentKey+"id") ||
strings.EqualFold(field.Name, parentKey+"ID") {
dbColNames = []string{reflection.GetColumnName(field)}
break
}
}
}
for _, dbColName := range dbColNames {
if pkCol != "" && strings.EqualFold(dbColName, pkCol) {
continue
}
if _, exists := data[dbColName]; !exists {
logger.Debug("Injecting foreign key: %s = %v", dbColName, parentID)
data[dbColName] = parentID
}
}
}
}
@@ -272,27 +305,32 @@ func (p *NestedCUDProcessor) processInsert(
query = query.Value(key, ConvertSliceForBun(value))
}
pkName := reflection.GetPrimaryKeyName(tableName)
// Add RETURNING clause to get the inserted ID
query = query.Returning(pkName)
result, err := query.Exec(ctx)
if err != nil {
var id interface{}
if err := query.Scan(ctx, &id); err != nil {
logger.Error("Insert execution failed: table=%s, data=%+v, error=%v", tableName, data, err)
return nil, fmt.Errorf("insert exec failed: %w", err)
}
// Try to get the ID
var id interface{}
if lastID, err := result.LastInsertId(); err == nil && lastID > 0 {
id = lastID
} else if data[pkName] != nil {
id = data[pkName]
}
logger.Debug("Insert successful, ID: %v, rows affected: %d", id, result.RowsAffected())
logger.Debug("Insert successful, ID: %v", id)
return id, nil
}
// processSelect fetches the row identified by id from tableName into a flat map.
// Used to populate result.Data with the actual DB state after insert/update.
func (p *NestedCUDProcessor) processSelect(ctx context.Context, tableName string, id interface{}) (map[string]interface{}, error) {
pkName := reflection.GetPrimaryKeyName(tableName)
var row map[string]interface{}
if err := p.db.NewSelect().
Table(tableName).
Where(fmt.Sprintf("%s = ?", QuoteIdent(pkName)), id).
Scan(ctx, &row); err != nil {
return nil, fmt.Errorf("select after write failed: %w", err)
}
return row, nil
}
// processUpdate handles update operation
func (p *NestedCUDProcessor) processUpdate(
ctx context.Context,
@@ -372,7 +410,7 @@ func (p *NestedCUDProcessor) processChildRelations(
if relatedModelType.Kind() == reflect.Slice {
relatedModelType = relatedModelType.Elem()
}
if relatedModelType.Kind() == reflect.Ptr {
if relatedModelType.Kind() == reflect.Pointer {
relatedModelType = relatedModelType.Elem()
}
@@ -433,13 +471,17 @@ func (p *NestedCUDProcessor) processChildRelations(
// Priority: Use foreign key field name if specified
var foreignKeyFieldName string
if relInfo.ForeignKey != "" {
// Get the JSON name for the foreign key field in the child model
foreignKeyFieldName = reflection.GetJSONNameForField(relatedModelType, relInfo.ForeignKey)
if foreignKeyFieldName == "" {
// Fallback to lowercase field name
foreignKeyFieldName = strings.ToLower(relInfo.ForeignKey)
// For has-many/has-one: join:parentCol=childCol
// ForeignKey = parent side, References = child side (where we actually set the value)
childField := relInfo.ForeignKey
if (relInfo.RelationType == "hasMany" || relInfo.RelationType == "hasOne") && relInfo.References != "" {
childField = relInfo.References
}
logger.Debug("Using foreign key field for direct assignment: %s (from FK %s)", foreignKeyFieldName, relInfo.ForeignKey)
foreignKeyFieldName = reflection.GetJSONNameForField(relatedModelType, childField)
if foreignKeyFieldName == "" {
foreignKeyFieldName = strings.ToLower(childField)
}
logger.Debug("Using foreign key field for direct assignment: %s (from FK %s -> child %s)", foreignKeyFieldName, relInfo.ForeignKey, childField)
}
// Get the primary key name for the child model to avoid overwriting it in recursive relationships
@@ -548,7 +590,7 @@ func shouldUseNestedProcessorDepth(data map[string]interface{}, model interface{
// Get model type
modelType := reflect.TypeOf(model)
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
+221 -1
View File
@@ -101,12 +101,18 @@ func (m *mockInsertQuery) Value(column string, value interface{}) InsertQuery {
func (m *mockInsertQuery) OnConflict(action string) InsertQuery { return m }
func (m *mockInsertQuery) Returning(columns ...string) InsertQuery { return m }
func (m *mockInsertQuery) Exec(ctx context.Context) (Result, error) {
// Record the insert call
m.db.insertCalls = append(m.db.insertCalls, m.values)
m.db.lastID++
return &mockResult{lastID: m.db.lastID, rowsAffected: 1}, nil
}
func (m *mockInsertQuery) Scan(ctx context.Context, dest interface{}) error {
m.db.insertCalls = append(m.db.insertCalls, m.values)
m.db.lastID++
reflect.ValueOf(dest).Elem().Set(reflect.ValueOf(m.db.lastID))
return nil
}
// Mock UpdateQuery
type mockUpdateQuery struct {
db *mockDatabase
@@ -707,6 +713,220 @@ func TestInjectForeignKeys(t *testing.T) {
}
}
// Models for asymmetric join column tests (mirrors the bun has-many join:parentCol=childCol pattern).
// ActionOption has-many ActionOptionLinks via join:rid_actionoption=rid_actionoption_child.
// The child column ("rid_actionoption_child") differs from the parent column ("rid_actionoption").
type ActionOption struct {
RidActionoption int64 `json:"rid_actionoption" bun:"rid_actionoption,pk"`
Label string `json:"label"`
Links []*ActionOptionLink `json:"aol_rid_actionoption_child,omitempty"`
}
func (a ActionOption) TableName() string { return "action_options" }
func (a ActionOption) GetIDName() string { return "RidActionoption" }
type ActionOptionLink struct {
RidActionoptionlink int64 `json:"rid_actionoptionlink" bun:"rid_actionoptionlink,pk"`
RidActionoptionChild int64 `json:"rid_actionoption_child" bun:"rid_actionoption_child"`
Label string `json:"label"`
// Note: no field named "rid_actionoption" — that is the parent's column.
}
func (a ActionOptionLink) TableName() string { return "action_option_links" }
func (a ActionOptionLink) GetIDName() string { return "RidActionoptionlink" }
// TestProcessNestedCUD_AsymmetricJoinColumns verifies that for a has-many relation with
// join:parentCol=childCol, the child rows are stamped with the child-side column (References),
// not the parent-side column (ForeignKey).
func TestProcessNestedCUD_AsymmetricJoinColumns(t *testing.T) {
db := newMockDatabase()
registry := &mockModelRegistry{}
relProvider := newMockRelationshipProvider()
// Mirrors: bun:"rel:has-many,join:rid_actionoption=rid_actionoption_child"
relProvider.RegisterRelation("ActionOption", "aol_rid_actionoption_child", &RelationshipInfo{
FieldName: "Links",
JSONName: "aol_rid_actionoption_child",
RelationType: "hasMany",
ForeignKey: "rid_actionoption", // parent-side column (left of join:)
References: "rid_actionoption_child", // child-side column (right of join:)
RelatedModel: ActionOptionLink{},
})
processor := NewNestedCUDProcessor(db, registry, relProvider)
data := map[string]interface{}{
"label": "option-a",
"aol_rid_actionoption_child": []interface{}{
map[string]interface{}{"label": "link-1"},
},
}
_, err := processor.ProcessNestedCUD(
context.Background(),
"insert",
data,
ActionOption{},
nil,
"action_options",
)
if err != nil {
t.Fatalf("ProcessNestedCUD failed: %v", err)
}
if len(db.insertCalls) < 2 {
t.Fatalf("Expected at least 2 insert calls (parent + child), got %d", len(db.insertCalls))
}
childInsert := db.insertCalls[1]
// The fix: child must receive "rid_actionoption_child", NOT "rid_actionoption".
if childInsert["rid_actionoption_child"] == nil {
t.Error("Expected child to have rid_actionoption_child set (child-side FK column)")
}
if childInsert["rid_actionoption"] != nil {
t.Errorf("Child must not receive parent-side column rid_actionoption, got %v", childInsert["rid_actionoption"])
}
}
// TestProcessNestedCUD_BelongsToUnchanged verifies that the fix does not regress belongsTo
// relations, where ForeignKey is already the local (child) column.
func TestProcessNestedCUD_BelongsToUnchanged(t *testing.T) {
db := newMockDatabase()
registry := &mockModelRegistry{}
relProvider := newMockRelationshipProvider()
// For belongsTo, ForeignKey is the column on the child; References is on the parent.
// The old and new code must behave identically here.
relProvider.RegisterRelation("Employee", "department", &RelationshipInfo{
FieldName: "Department",
JSONName: "department",
RelationType: "belongsTo",
ForeignKey: "DepartmentID", // child's own column
References: "ID", // parent's PK
RelatedModel: Department{},
})
relProvider.RegisterRelation("Department", "employees", &RelationshipInfo{
FieldName: "Employees",
JSONName: "employees",
RelationType: "has_many",
ForeignKey: "DepartmentID",
RelatedModel: Employee{},
})
processor := NewNestedCUDProcessor(db, registry, relProvider)
data := map[string]interface{}{
"name": "Engineering",
"employees": []interface{}{
map[string]interface{}{"name": "Alice"},
},
}
_, err := processor.ProcessNestedCUD(
context.Background(),
"insert",
data,
Department{},
nil,
"departments",
)
if err != nil {
t.Fatalf("ProcessNestedCUD failed: %v", err)
}
if len(db.insertCalls) < 2 {
t.Fatalf("Expected at least 2 inserts, got %d", len(db.insertCalls))
}
// Employees relation uses has_many (old-style) so it goes through the parentIDs injection path,
// not the foreignKeyFieldName path. Just confirm no panic and employee is inserted.
if db.insertCalls[0]["name"] != "Engineering" {
t.Errorf("Expected department name 'Engineering', got %v", db.insertCalls[0]["name"])
}
}
func TestProcessNestedCUD_AddAlias(t *testing.T) {
db := newMockDatabase()
registry := &mockModelRegistry{}
relProvider := newMockRelationshipProvider()
processor := NewNestedCUDProcessor(db, registry, relProvider)
data := map[string]interface{}{
"_request": "add",
"name": "New Department",
}
result, err := processor.ProcessNestedCUD(context.Background(), "insert", data, Department{}, nil, "departments")
if err != nil {
t.Fatalf("ProcessNestedCUD with _request=add failed: %v", err)
}
if result.ID == nil {
t.Error("Expected result.ID to be set after add")
}
if len(db.insertCalls) != 1 {
t.Errorf("Expected 1 insert call, got %d", len(db.insertCalls))
}
}
func TestProcessNestedCUD_RemoveAlias(t *testing.T) {
db := newMockDatabase()
registry := &mockModelRegistry{}
relProvider := newMockRelationshipProvider()
processor := NewNestedCUDProcessor(db, registry, relProvider)
data := map[string]interface{}{
"_request": "remove",
"ID": int64(42),
}
_, err := processor.ProcessNestedCUD(context.Background(), "delete", data, Department{}, nil, "departments")
if err != nil {
t.Fatalf("ProcessNestedCUD with _request=remove failed: %v", err)
}
if len(db.deleteCalls) != 1 {
t.Errorf("Expected 1 delete call, got %d", len(db.deleteCalls))
}
}
func TestProcessNestedCUD_NestedAddRemoveAliases(t *testing.T) {
db := newMockDatabase()
registry := &mockModelRegistry{}
relProvider := newMockRelationshipProvider()
relProvider.RegisterRelation("Department", "employees", &RelationshipInfo{
FieldName: "Employees",
JSONName: "employees",
RelationType: "has_many",
ForeignKey: "DepartmentID",
RelatedModel: Employee{},
})
processor := NewNestedCUDProcessor(db, registry, relProvider)
data := map[string]interface{}{
"ID": int64(1),
"name": "Engineering",
"employees": []interface{}{
map[string]interface{}{"_request": "add", "name": "Alice"},
map[string]interface{}{"_request": "remove", "ID": int64(5)},
},
}
_, err := processor.ProcessNestedCUD(context.Background(), "update", data, Department{}, nil, "departments")
if err != nil {
t.Fatalf("ProcessNestedCUD with nested add/remove failed: %v", err)
}
if len(db.insertCalls) != 1 {
t.Errorf("Expected 1 insert (add alias) for employee, got %d", len(db.insertCalls))
}
if len(db.deleteCalls) != 1 {
t.Errorf("Expected 1 delete (remove alias) for employee, got %d", len(db.deleteCalls))
}
}
func TestGetPrimaryKeyName(t *testing.T) {
dept := Department{}
pkName := reflection.GetPrimaryKeyName(dept)
+98 -18
View File
@@ -59,6 +59,38 @@ func IsSQLExpression(cond string) bool {
return false
}
// reEmptyCompMid matches a simple column comparison with an empty RHS that is immediately
// followed by AND/OR (only whitespace between the operator and the next keyword).
// Removing the match leaves the preceding AND/OR connector intact.
// Example: "cond1 and col = \n and cond2" → "cond1 and cond2"
var reEmptyCompMid = regexp.MustCompile(`(?i)[\w.]+\s*(?:=|<>|!=|>=|<=|>|<)\s+(?:and|or)\s+`)
// reEmptyCompEnd matches AND/OR + a simple column comparison with an empty RHS at the end
// of the string (or sub-clause).
// Example: "cond1 and col = " → "cond1"
var reEmptyCompEnd = regexp.MustCompile(`(?i)\s+(?:and|or)\s+[\w.]+\s*(?:=|<>|!=|>=|<=|>|<)\s*$`)
// stripEmptyComparisonClauses removes comparison conditions that have no right-hand side
// value from a raw SQL string. Operates on the whole string so it also cleans up conditions
// inside subqueries, not just top-level AND splits.
func stripEmptyComparisonClauses(sql string) string {
sql = reEmptyCompMid.ReplaceAllLiteralString(sql, "")
sql = reEmptyCompEnd.ReplaceAllLiteralString(sql, "")
return sql
}
// hasEmptyRHS returns true when a condition ends with a comparison operator and has no
// right-hand side value — e.g., "col = ", "com.rid_parent = ", "col >= ".
func hasEmptyRHS(cond string) bool {
cond = strings.TrimSpace(cond)
for _, op := range []string{"<>", "!=", ">=", "<=", "=", ">", "<"} {
if strings.HasSuffix(cond, op) {
return true
}
}
return false
}
// IsTrivialCondition checks if a condition is trivial and always evaluates to true
// These conditions should be removed from WHERE clauses as they have no filtering effect
func IsTrivialCondition(cond string) bool {
@@ -147,6 +179,14 @@ func SanitizeWhereClause(where string, tableName string, options ...*RequestOpti
return ""
}
// Strip comparison conditions with empty RHS throughout the SQL string (including
// inside subqueries), before condition splitting.
where = stripEmptyComparisonClauses(where)
if where == "" {
return ""
}
where = strings.TrimSpace(where)
// Check if the original clause has outer parentheses and contains OR operators
// If so, we need to preserve the outer parentheses to prevent OR logic from escaping
hasOuterParens := false
@@ -212,6 +252,12 @@ func SanitizeWhereClause(where string, tableName string, options ...*RequestOpti
continue
}
// Skip conditions with no right-hand side value (e.g. "col = " with empty value)
if hasEmptyRHS(condToCheck) {
logger.Debug("Removing condition with empty value: '%s'", cond)
continue
}
// If tableName is provided and the condition HAS a table prefix, check if it's correct
if tableName != "" && hasTablePrefix(condToCheck) {
// Extract the current prefix and column name
@@ -400,18 +446,36 @@ func containsTopLevelOR(clause string) bool {
return false
}
// splitByAND splits a WHERE clause by AND operators (case-insensitive)
// This is parenthesis-aware and won't split on AND operators inside subqueries
// splitByAND splits a WHERE clause by AND operators (case-insensitive).
// It is parenthesis-aware (won't split inside subqueries), quote-aware
// (won't split on AND inside single-quoted strings), and BETWEEN-aware
// (won't split on the AND that separates the two operands of BETWEEN x AND y).
func splitByAND(where string) []string {
conditions := []string{}
currentCondition := strings.Builder{}
depth := 0 // Track parenthesis depth
depth := 0 // parenthesis nesting depth
inSingleQuote := false
afterBetween := false // true after seeing BETWEEN at depth 0; next AND belongs to it
i := 0
for i < len(where) {
ch := where[i]
// Track parenthesis depth
// Track single-quote state so we never split on AND inside string literals.
if ch == '\'' {
inSingleQuote = !inSingleQuote
currentCondition.WriteByte(ch)
i++
continue
}
if inSingleQuote {
currentCondition.WriteByte(ch)
i++
continue
}
// Track parenthesis depth (outside quotes only).
if ch == '(' {
depth++
currentCondition.WriteByte(ch)
@@ -424,32 +488,39 @@ func splitByAND(where string) []string {
continue
}
// Only look for AND operators at depth 0 (not inside parentheses)
// All keyword checks only apply at depth 0 (not inside subqueries).
if depth == 0 {
// Check if we're at an AND operator (case-insensitive)
// We need at least " AND " (5 chars) or " and " (5 chars)
if i+5 <= len(where) {
substring := where[i : i+5]
lowerSubstring := strings.ToLower(substring)
// Detect " BETWEEN " (9 chars, case-insensitive) so the very next
// top-level AND is recognised as part of the BETWEEN syntax.
if i+9 <= len(where) && strings.ToLower(where[i:i+9]) == " between " {
afterBetween = true
currentCondition.WriteString(where[i : i+9])
i += 9
continue
}
if lowerSubstring == " and " {
// Found an AND operator at the top level
// Add the current condition to the list
conditions = append(conditions, currentCondition.String())
currentCondition.Reset()
// Skip past the AND operator
// Detect " AND " (5 chars, case-insensitive).
if i+5 <= len(where) && strings.ToLower(where[i:i+5]) == " and " {
if afterBetween {
// This AND closes a BETWEEN expression — do NOT split.
afterBetween = false
currentCondition.WriteString(where[i : i+5])
i += 5
continue
}
// Regular conjunction — split here.
conditions = append(conditions, currentCondition.String())
currentCondition.Reset()
i += 5
continue
}
}
// Not an AND operator or we're inside parentheses, just add the character
currentCondition.WriteByte(ch)
i++
}
// Add the last condition
// Add the last condition.
if currentCondition.Len() > 0 {
conditions = append(conditions, currentCondition.String())
}
@@ -568,6 +639,15 @@ func extractTableAndColumn(cond string) (table string, column string) {
// Remove any quotes
columnRef = strings.Trim(columnRef, "`\"'")
// If the left side is a parenthesized subquery (starts with '(' and contains SQL keywords),
// don't attempt prefix extraction from inside it.
if len(columnRef) > 0 && columnRef[0] == '(' {
lowerRef := strings.ToLower(columnRef)
if strings.Contains(lowerRef, "select ") || strings.Contains(lowerRef, " from ") || strings.Contains(lowerRef, " where ") {
return "", ""
}
}
// Check if there's a function call (contains opening parenthesis)
openParenIdx := strings.Index(columnRef, "(")
+75
View File
@@ -134,6 +134,30 @@ func TestSanitizeWhereClause(t *testing.T) {
tableName: "apiprovider",
expected: "apiprovider.type in ('softphone') AND (apiprovider.rid_apiprovider in (select l.rid_apiprovider from core.apiproviderlink l where l.rid_hub = 2576))",
},
{
name: "empty RHS stripped mid-clause",
where: "com.tableprefix = 'tcli' and com.rid_parent = \n and com.status = 'Active'",
tableName: "",
expected: "com.tableprefix = 'tcli' AND com.status = 'Active'",
},
{
name: "empty RHS stripped at end of clause",
where: "com.tableprefix = 'tcli' and com.rid_parent =",
tableName: "",
expected: "com.tableprefix = 'tcli'",
},
{
name: "non-empty value not stripped",
where: "com.tableprefix = 'tcli' and com.rid_parent = 123 and com.status = 'Active'",
tableName: "",
expected: "com.tableprefix = 'tcli' AND com.rid_parent = 123 AND com.status = 'Active'",
},
{
name: "empty RHS inside subquery stripped",
where: "a = 1 and b in (select x from t where c.rid = \n and d = 2)",
tableName: "",
expected: "a = 1 AND b in (select x from t where d = 2)",
},
}
for _, tt := range tests {
@@ -496,6 +520,38 @@ func TestSplitByAND(t *testing.T) {
input: "a = 1 AND b = 2 AND c = 3 and (select s from generate_series(1,10) s where s < 10 and s > 0 offset 2 limit 1) = 3",
expected: []string{"a = 1", "b = 2", "c = 3", "(select s from generate_series(1,10) s where s < 10 and s > 0 offset 2 limit 1) = 3"},
},
// BETWEEN-aware cases: the AND inside BETWEEN x AND y must not cause a split.
{
name: "BETWEEN does not split on its AND",
input: "col between '2025-08-31' and '1970-01-01'",
expected: []string{"col between '2025-08-31' and '1970-01-01'"},
},
{
name: "BETWEEN uppercase AND",
input: "col BETWEEN '2025-08-31' AND '1970-01-01'",
expected: []string{"col BETWEEN '2025-08-31' AND '1970-01-01'"},
},
{
name: "BETWEEN followed by a regular AND conjunction",
input: "col between 1 and 5 and other = 'x'",
expected: []string{"col between 1 and 5", "other = 'x'"},
},
{
name: "two BETWEEN conditions joined by AND",
input: "col1 between 1 and 5 and col2 between 10 and 20",
expected: []string{"col1 between 1 and 5", "col2 between 10 and 20"},
},
{
name: "complex OR block with multiple BETWEENs (real-world case)",
input: "tbl.applicationdate between '2025-08-31' and '1970-01-01'\n or tbl.capturedate between '2025-08-31' and '1970-01-01'\n or tbl.startdate between '2025-08-31' AND '1970-01-01'",
expected: []string{"tbl.applicationdate between '2025-08-31' and '1970-01-01'\n or tbl.capturedate between '2025-08-31' and '1970-01-01'\n or tbl.startdate between '2025-08-31' AND '1970-01-01'"},
},
// Quote-aware cases: AND inside a string literal must not split.
{
name: "AND inside single-quoted string is not a split point",
input: "comment = 'this and that' and status = 'active'",
expected: []string{"comment = 'this and that'", "status = 'active'"},
},
}
for _, tt := range tests {
@@ -893,6 +949,25 @@ where: "(true AND status = 'active')",
tableName: "unregistered_table",
expected: "(true AND unregistered_table.status = 'active')",
},
// BETWEEN regression: date literals inside BETWEEN must not be prefixed as columns.
{
name: "BETWEEN date range - second date must not be prefixed",
where: "applicationdate between '2025-08-31' and '1970-01-01'",
tableName: "unregistered_table",
expected: "unregistered_table.applicationdate between '2025-08-31' and '1970-01-01'",
},
{
name: "Already-prefixed BETWEEN column - unchanged",
where: `"v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01'`,
tableName: "v_webui_clients",
expected: `"v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01'`,
},
{
name: "Complex OR block with multiple BETWEENs - date values must not be prefixed",
where: `("v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01' or "v_webui_clients".clientcapturedate between '2025-08-31' and '1970-01-01' or "v_webui_clients".startdate between '2025-08-31' AND '1970-01-01')`,
tableName: "v_webui_clients",
expected: `("v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01' or "v_webui_clients".clientcapturedate between '2025-08-31' and '1970-01-01' or "v_webui_clients".startdate between '2025-08-31' AND '1970-01-01')`,
},
}
for _, tt := range tests {
+19
View File
@@ -1,5 +1,23 @@
package common
// SQLError wraps a database error together with the SQL that caused it,
// so callers can surface the query in API error responses for easier debugging.
type SQLError struct {
Err error
SQL string
}
func (e *SQLError) Error() string { return e.Err.Error() }
func (e *SQLError) Unwrap() error { return e.Err }
// WrapSQLError wraps err with the given SQL. If err is nil it returns nil.
func WrapSQLError(err error, sql string) error {
if err == nil {
return nil
}
return &SQLError{Err: err, SQL: sql}
}
type RequestBody struct {
Operation string `json:"operation"`
Data interface{} `json:"data"`
@@ -104,6 +122,7 @@ type APIError struct {
Message string `json:"message"`
Details interface{} `json:"details,omitempty"`
Detail string `json:"detail,omitempty"`
SQL string `json:"sql,omitempty"`
}
type Column struct {
+43 -7
View File
@@ -3,6 +3,7 @@ package common
import (
"fmt"
"reflect"
"sort"
"strings"
"github.com/bitechdev/ResolveSpec/pkg/logger"
@@ -30,7 +31,7 @@ func (v *ColumnValidator) buildValidColumns() {
modelType := reflect.TypeOf(v.model)
// Unwrap pointers, slices, and arrays to get to the base struct type
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
modelType = modelType.Elem()
}
@@ -43,7 +44,7 @@ func (v *ColumnValidator) buildValidColumns() {
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
if !field.IsExported() {
if !field.IsExported() || field.Anonymous {
continue
}
@@ -125,6 +126,16 @@ func (v *ColumnValidator) IsValidColumn(column string) bool {
return v.ValidateColumn(column) == nil
}
// Columns returns all valid column names known to this validator
func (v *ColumnValidator) Columns() []string {
cols := make([]string, 0, len(v.validColumns))
for col := range v.validColumns {
cols = append(cols, col)
}
sort.Strings(cols)
return cols
}
// FilterValidColumns filters a list of columns, returning only valid ones
// Logs warnings for any invalid columns
func (v *ColumnValidator) FilterValidColumns(columns []string) []string {
@@ -224,7 +235,19 @@ func (v *ColumnValidator) FilterRequestOptions(options RequestOptions) RequestOp
// Filter Filter columns
validFilters := make([]FilterOption, 0, len(options.Filters))
for _, filter := range options.Filters {
if v.IsValidColumn(filter.Column) {
if strings.EqualFold(filter.Column, "all") {
allCols := v.Columns()
if len(filtered.Columns) > 0 {
allCols = filtered.Columns
}
for _, col := range allCols {
expanded := filter
expanded.Column = col
expanded.LogicOperator = "OR"
validFilters = append(validFilters, expanded)
}
} else if v.IsValidColumn(filter.Column) {
validFilters = append(validFilters, filter)
} else {
logger.Warn("Invalid column in filter '%s' removed", filter.Column)
@@ -266,11 +289,24 @@ func (v *ColumnValidator) FilterRequestOptions(options RequestOptions) RequestOp
// Filter Preload columns
validPreloads := make([]PreloadOption, 0, len(options.Preload))
modelType := reflect.TypeOf(v.model)
if modelType != nil && modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
for idx := range options.Preload {
preload := options.Preload[idx]
filteredPreload := preload
filteredPreload.Columns = v.FilterValidColumns(preload.Columns)
filteredPreload.OmitColumns = v.FilterValidColumns(preload.OmitColumns)
// Use the related model's validator for preload columns/filters/sorts
preloadValidator := v
if modelType != nil {
if relInfo := GetRelationshipInfo(modelType, preload.Relation); relInfo != nil && relInfo.RelatedModel != nil {
preloadValidator = NewColumnValidator(relInfo.RelatedModel)
}
}
filteredPreload.Columns = preloadValidator.FilterValidColumns(preload.Columns)
filteredPreload.OmitColumns = preloadValidator.FilterValidColumns(preload.OmitColumns)
// Preserve SqlJoins and JoinAliases for preloads with custom joins
filteredPreload.SqlJoins = preload.SqlJoins
@@ -279,7 +315,7 @@ func (v *ColumnValidator) FilterRequestOptions(options RequestOptions) RequestOp
// Filter preload filters
validPreloadFilters := make([]FilterOption, 0, len(preload.Filters))
for _, filter := range preload.Filters {
if v.IsValidColumn(filter.Column) {
if preloadValidator.IsValidColumn(filter.Column) {
validPreloadFilters = append(validPreloadFilters, filter)
} else {
// Check if the filter column references a joined table alias
@@ -302,7 +338,7 @@ func (v *ColumnValidator) FilterRequestOptions(options RequestOptions) RequestOp
// Filter preload sort columns
validPreloadSorts := make([]SortOption, 0, len(preload.Sort))
for _, sort := range preload.Sort {
if v.IsValidColumn(sort.Column) {
if preloadValidator.IsValidColumn(sort.Column) {
validPreloadSorts = append(validPreloadSorts, sort)
} else if strings.HasPrefix(sort.Column, "(") && strings.HasSuffix(sort.Column, ")") {
// Allow sort by expression/subquery, but validate for security
+81
View File
@@ -464,3 +464,84 @@ func TestFilterRequestOptions_WithSortExpressions(t *testing.T) {
t.Errorf("Expected third sort to be 'name', got '%s'", filtered.Sort[2].Column)
}
}
// RelatedModel is used by PreloadParentModel to test preload column validation.
type RelatedModel struct {
RelatedID int64 `bun:"related_id,pk"`
Functionname string `bun:"functionname"`
}
// PreloadParentModel has a has-one relation to RelatedModel. The json tag on
// the relation field is the name used in x-preload headers.
type PreloadParentModel struct {
ID int64 `bun:"id,pk"`
Name string `bun:"name"`
RELATED *RelatedModel `json:"RELATED" bun:"rel:has-one,join:id=related_id"`
}
// TestFilterRequestOptions_PreloadColumnsValidatedAgainstRelatedModel verifies
// that preload columns are validated against the related model's fields, not the
// parent model's fields. This is the fix for the bug where specifying a column
// that exists only on the relation (e.g. "functionname") was incorrectly filtered
// out because it doesn't exist on the parent model.
func TestFilterRequestOptions_PreloadColumnsValidatedAgainstRelatedModel(t *testing.T) {
validator := NewColumnValidator(PreloadParentModel{})
options := RequestOptions{
Preload: []PreloadOption{
{
Relation: "RELATED",
// "functionname" exists on RelatedModel but NOT on PreloadParentModel.
// "name" exists on PreloadParentModel but NOT on RelatedModel.
// "nonexistent" exists on neither.
Columns: []string{"functionname", "name", "nonexistent"},
},
},
}
filtered := validator.FilterRequestOptions(options)
if len(filtered.Preload) != 1 {
t.Fatalf("Expected 1 preload, got %d", len(filtered.Preload))
}
cols := filtered.Preload[0].Columns
// Only "functionname" should survive: it belongs to RelatedModel.
if len(cols) != 1 {
t.Errorf("Expected 1 preload column, got %d: %v", len(cols), cols)
}
if len(cols) > 0 && cols[0] != "functionname" {
t.Errorf("Expected preload column 'functionname', got '%s'", cols[0])
}
}
// TestFilterRequestOptions_PreloadColumnsParentModelFallback verifies that when
// a preload relation is not found on the parent model, column validation falls
// back to the parent model's validator (no panic, no silent pass-through).
func TestFilterRequestOptions_PreloadColumnsParentModelFallback(t *testing.T) {
validator := NewColumnValidator(PreloadParentModel{})
options := RequestOptions{
Preload: []PreloadOption{
{
Relation: "UNKNOWN_RELATION",
Columns: []string{"id", "functionname"},
},
},
}
filtered := validator.FilterRequestOptions(options)
if len(filtered.Preload) != 1 {
t.Fatalf("Expected 1 preload, got %d", len(filtered.Preload))
}
cols := filtered.Preload[0].Columns
// Falls back to parent model: only "id" is valid on PreloadParentModel.
if len(cols) != 1 {
t.Errorf("Expected 1 preload column (fallback to parent), got %d: %v", len(cols), cols)
}
if len(cols) > 0 && cols[0] != "id" {
t.Errorf("Expected preload column 'id', got '%s'", cols[0])
}
}
+4
View File
@@ -50,6 +50,10 @@ type ServerInstanceConfig struct {
// GZIP enables GZIP compression middleware
GZIP bool `mapstructure:"gzip"`
// HTTP2 enables HTTP/2 with the Extended CONNECT protocol (RFC 8441) for WebSocket support.
// Requires TLS; pair with SSLCert/SSLKey, SelfSignedSSL, or AutoTLS.
HTTP2 bool `mapstructure:"http2"`
// TLS/HTTPS configuration options (mutually exclusive)
// Option 1: Provide certificate and key files directly
SSLCert string `mapstructure:"ssl_cert"`
+9 -6
View File
@@ -66,7 +66,7 @@ func (s *SentryProvider) CaptureError(ctx context.Context, err error, severity S
}
if extra != nil {
event.Extra = extra
event.Contexts["extra"] = sentry.Context(extra)
}
hub.CaptureEvent(event)
@@ -88,7 +88,7 @@ func (s *SentryProvider) CaptureMessage(ctx context.Context, message string, sev
event.Message = message
if extra != nil {
event.Extra = extra
event.Contexts["extra"] = sentry.Context(extra)
}
hub.CaptureEvent(event)
@@ -115,12 +115,15 @@ func (s *SentryProvider) CapturePanic(ctx context.Context, recovered interface{}
},
}
if extra != nil {
event.Extra = extra
extraCtx := sentry.Context{}
for k, v := range extra {
extraCtx[k] = v
}
if stackTrace != nil {
event.Extra["stack_trace"] = string(stackTrace)
extraCtx["stack_trace"] = string(stackTrace)
}
if len(extraCtx) > 0 {
event.Contexts["extra"] = extraCtx
}
hub.CaptureEvent(event)
+142 -16
View File
@@ -3,6 +3,7 @@ package funcspec
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -16,6 +17,7 @@ import (
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
"github.com/bitechdev/ResolveSpec/pkg/restheadspec"
"github.com/bitechdev/ResolveSpec/pkg/security"
)
@@ -168,9 +170,16 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
// Replace meta variables in SQL
sqlquery = h.replaceMetaVariables(sqlquery, r, userCtx, metainfo, variables)
// Remove unused input variables
if options.BlankParams {
for _, kw := range inputvars {
// Replace variables from provided values, then blank any remaining unused ones
for _, kw := range inputvars {
varName := kw[1 : len(kw)-1] // strip [ and ]
if val, ok := variables[varName]; ok {
if strVal := fmt.Sprintf("%v", val); strVal != "" {
sqlquery = strings.ReplaceAll(sqlquery, kw, safeSubstituteVar(sqlquery, kw, strVal))
continue
}
}
if options.BlankParams {
replacement := getReplacementForBlankParam(sqlquery, kw)
sqlquery = strings.ReplaceAll(sqlquery, kw, replacement)
logger.Debug("Replaced unused variable %s with: %s", kw, replacement)
@@ -322,7 +331,10 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
w.Header().Set("Content-Range", fmt.Sprintf("items %d-%d/%d", respOffset, respOffset+len(dbobjlist), total))
logger.Info("Serving: Records %d of %d", len(dbobjlist), total)
// Execute BeforeResponse hook
// Execute BeforeResponse hook. The transaction has already committed by
// this point, so hooks must use the pooled connection rather than the
// now-dead tx.
hookCtx.Tx = h.db
hookCtx.Result = dbobjlist
hookCtx.Total = total
if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil {
@@ -359,13 +371,17 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
}
case "detail":
// Detail format: complex API with metadata
// Detail format: { count, fields, items, tablename, tableprefix, total }
tableName := r.URL.Path
tablePrefix := reflection.ExtractTableNameOnly(tableName)
fields := buildDetailFieldsFromRows(dbobjlist)
metaobj := map[string]interface{}{
"items": dbobjlist,
"count": fmt.Sprintf("%d", len(dbobjlist)),
"fields": fields,
"items": dbobjlist,
"tablename": tableName,
"tableprefix": tablePrefix,
"total": fmt.Sprintf("%d", total),
"tablename": r.URL.Path,
"tableprefix": "gsql",
}
data, err := json.Marshal(metaobj)
if err != nil {
@@ -520,9 +536,16 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
}
}
// Remove unused input variables
if options.BlankParams {
for _, kw := range inputvars {
// Replace variables from provided values, then blank any remaining unused ones
for _, kw := range inputvars {
varName := kw[1 : len(kw)-1] // strip [ and ]
if val, ok := variables[varName]; ok {
if strVal := fmt.Sprintf("%v", val); strVal != "" {
sqlquery = strings.ReplaceAll(sqlquery, kw, safeSubstituteVar(sqlquery, kw, strVal))
continue
}
}
if options.BlankParams {
replacement := getReplacementForBlankParam(sqlquery, kw)
sqlquery = strings.ReplaceAll(sqlquery, kw, replacement)
logger.Debug("Replaced unused variable %s with: %s", kw, replacement)
@@ -611,7 +634,10 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
return
}
// Execute BeforeResponse hook
// Execute BeforeResponse hook. The transaction has already committed by
// this point, so hooks must use the pooled connection rather than the
// now-dead tx.
hookCtx.Tx = h.db
hookCtx.Result = dbobj
if err := h.hooks.Execute(BeforeResponse, hookCtx); err != nil {
logger.Error("BeforeResponse hook failed: %v", err)
@@ -715,8 +741,10 @@ func (h *Handler) mergeQueryParams(r *http.Request, sqlquery string, variables m
propQry[parmk] = val
}
// Apply filters if allowed
if allowFilter && len(parmk) > 1 && strings.Contains(strings.ToLower(sqlquery), strings.ToLower(parmk)) {
// Apply filters if allowed — check only the SELECT list to avoid matching function
// parameters in the FROM clause (e.g. [p_rid_doctype] in a set-returning function call)
// or names inside quoted string arguments.
if allowFilter && len(parmk) > 1 && strings.Contains(sqlSelectList(sqlStripStringLiterals(sqlquery)), strings.ToLower(parmk)) {
if len(parmv) > 1 {
// Sanitize each value in the IN clause with appropriate quoting
sanitizedValues := make([]string, len(parmv))
@@ -824,6 +852,26 @@ func (h *Handler) mergeHeaderParams(r *http.Request, sqlquery string, variables
return sqlquery
}
// sqlStripStringLiterals removes the contents of single-quoted string literals from SQL,
// leaving the structural identifiers (column names, table names) intact.
// Used to check column presence without matching inside string arguments.
func sqlStripStringLiterals(sql string) string {
re := regexp.MustCompile(`'(?:[^']|'')*'`)
return re.ReplaceAllString(sql, "''")
}
// sqlSelectList returns the column list portion of a SELECT query (between SELECT and FROM).
// Returns the full query lowercased if no clear SELECT…FROM boundary is found.
func sqlSelectList(sql string) string {
lower := strings.ToLower(sql)
selectPos := strings.Index(lower, "select ")
fromPos := strings.Index(lower, " from ")
if selectPos < 0 || fromPos <= selectPos {
return lower
}
return lower[selectPos+7 : fromPos]
}
// replaceMetaVariables replaces meta variables like [rid_user], [user], etc. in the SQL query
func (h *Handler) replaceMetaVariables(sqlquery string, r *http.Request, userCtx *security.UserContext, metainfo map[string]interface{}, variables map[string]interface{}) string {
if strings.Contains(sqlquery, "[p_meta_default]") {
@@ -969,6 +1017,37 @@ func IsNumeric(s string) bool {
return err == nil
}
// isInsideDollarQuote reports whether the first occurrence of placeholder in sqlquery
// is immediately surrounded by dollar-sign characters (i.e. inside a $...$-quoted string).
// Dollar-quoted strings pass content through literally — no backslash processing — so
// values placed there must NOT have their backslashes escaped.
func isInsideDollarQuote(sqlquery, placeholder string) bool {
idx := strings.Index(sqlquery, placeholder)
if idx < 0 {
return false
}
endIdx := idx + len(placeholder)
charBefore := byte(0)
charAfter := byte(0)
if idx > 0 {
charBefore = sqlquery[idx-1]
}
if endIdx < len(sqlquery) {
charAfter = sqlquery[endIdx]
}
return charBefore == '$' || charAfter == '$'
}
// safeSubstituteVar returns value sanitised for the quoting context that surrounds
// placeholder in sqlquery: raw (no backslash escaping) for dollar-quoted contexts,
// ValidSQL("colvalue") escaping for everything else.
func safeSubstituteVar(sqlquery, placeholder, value string) string {
if isInsideDollarQuote(sqlquery, placeholder) {
return value
}
return ValidSQL(value, "colvalue")
}
// getReplacementForBlankParam determines the replacement value for an unused parameter
// based on whether it appears within quotes in the SQL query.
// It checks for PostgreSQL quotes: single quotes (”) and dollar quotes ($...$)
@@ -991,8 +1070,8 @@ func getReplacementForBlankParam(sqlquery, param string) string {
charAfter = sqlquery[endIdx]
}
// Check if parameter is surrounded by quotes (single quote or dollar sign for PostgreSQL dollar-quoted strings)
if (charBefore == '\'' || charBefore == '$') && (charAfter == '\'' || charAfter == '$') {
// Check if parameter is surrounded by quotes (single quote, dollar sign for PostgreSQL dollar-quoted strings, or double quote for JSON string values)
if (charBefore == '\'' || charBefore == '$' || charBefore == '"') && (charAfter == '\'' || charAfter == '$' || charAfter == '"') {
// Parameter is in quotes, return empty string
return ""
}
@@ -1011,6 +1090,49 @@ func getReplacementForBlankParam(sqlquery, param string) string {
// return result
// }
// buildDetailFieldsFromRows builds a field metadata list from the column names and value types
// of a raw SQL result set. Used when no model struct is available (funcspec raw queries).
func buildDetailFieldsFromRows(rows []map[string]interface{}) []reflection.ModelFieldDetail {
if len(rows) == 0 {
return []reflection.ModelFieldDetail{}
}
first := rows[0]
fields := make([]reflection.ModelFieldDetail, 0, len(first))
for colName, val := range first {
dataType := inferGoType(val)
fields = append(fields, reflection.ModelFieldDetail{
Name: colName,
DataType: dataType,
SQLName: colName,
SQLDataType: "",
SQLKey: "",
Nullable: val == nil,
})
}
return fields
}
// inferGoType returns a simple type name for a value, used for detail field metadata.
func inferGoType(val interface{}) string {
if val == nil {
return "interface{}"
}
switch val.(type) {
case bool:
return "bool"
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return "int64"
case float32, float64:
return "float64"
case string:
return "string"
case []byte:
return "[]byte"
default:
return "interface{}"
}
}
// getIPAddress extracts the real IP address from the request
func getIPAddress(r *http.Request) string {
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
@@ -1035,6 +1157,10 @@ func sendError(w http.ResponseWriter, status int, code, message string, err erro
}
if err != nil {
errObj.Detail = err.Error()
var sqlErr *common.SQLError
if errors.As(err, &sqlErr) {
errObj.SQL = sqlErr.SQL
}
}
data, _ := json.Marshal(map[string]interface{}{
+365 -1
View File
@@ -617,6 +617,91 @@ func TestSqlQueryList(t *testing.T) {
}
},
},
{
name: "x-detailapi header returns detail format",
sqlQuery: "SELECT * FROM myschema.myentity",
noCount: false,
blankParams: false,
allowFilter: false,
headers: map[string]string{"x-detailapi": "true"},
setupDB: func() *MockDatabase {
return &MockDatabase{
RunInTransactionFunc: func(ctx context.Context, fn func(common.Database) error) error {
db := &MockDatabase{
QueryFunc: func(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
if strings.Contains(query, "COUNT") {
dest.(*struct{ Count int64 }).Count = 3
return nil
}
*dest.(*[]map[string]interface{}) = []map[string]interface{}{
{"id": float64(1), "name": "Alice"},
{"id": float64(2), "name": "Bob"},
{"id": float64(3), "name": "Carol"},
}
return nil
},
}
return fn(db)
},
}
},
expectedStatus: 200,
validateResp: func(t *testing.T, w *httptest.ResponseRecorder) {
var resp map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("expected JSON object, got: %s", w.Body.String())
}
for _, key := range []string{"count", "fields", "items", "tablename", "tableprefix", "total"} {
if _, ok := resp[key]; !ok {
t.Errorf("missing key %q in detail response", key)
}
}
var count, total string
json.Unmarshal(resp["count"], &count)
json.Unmarshal(resp["total"], &total)
if count != "3" {
t.Errorf("expected count %q, got %q", "3", count)
}
if total != "3" {
t.Errorf("expected total %q, got %q", "3", total)
}
var items []map[string]interface{}
if err := json.Unmarshal(resp["items"], &items); err != nil {
t.Fatalf("items is not an array: %v", err)
}
if len(items) != 3 {
t.Errorf("expected 3 items, got %d", len(items))
}
var fields []map[string]interface{}
if err := json.Unmarshal(resp["fields"], &fields); err != nil {
t.Fatalf("fields is not an array: %v", err)
}
if len(fields) == 0 {
t.Error("expected non-empty fields list")
}
for _, f := range fields {
for _, key := range []string{"name", "datatype", "sqlname", "sqldatatype", "sqlkey", "nullable"} {
if _, ok := f[key]; !ok {
t.Errorf("field %v missing key %q", f, key)
}
}
}
var tablename, tableprefix string
json.Unmarshal(resp["tablename"], &tablename)
json.Unmarshal(resp["tableprefix"], &tableprefix)
if tablename == "" {
t.Error("expected non-empty tablename")
}
if tableprefix == "" {
t.Error("expected non-empty tableprefix")
}
},
},
{
name: "List query with noCount",
sqlQuery: "SELECT * FROM users",
@@ -821,7 +906,7 @@ func TestReplaceMetaVariables(t *testing.T) {
name: "Replace [user]",
sqlQuery: "SELECT * FROM audit WHERE username = [user]",
expectedCheck: func(result string) bool {
return strings.Contains(result, "'testuser'")
return strings.Contains(result, "$USR$testuser$USR$")
},
},
{
@@ -851,6 +936,285 @@ func TestReplaceMetaVariables(t *testing.T) {
}
}
// TestSqlStripStringLiterals tests that single-quoted string literals are removed
func TestSqlStripStringLiterals(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "No string literals",
input: "SELECT rid, rid_parent FROM users",
expected: "SELECT rid, rid_parent FROM users",
},
{
name: "Simple string literal",
input: "SELECT * FROM users WHERE mode = 'admin'",
expected: "SELECT * FROM users WHERE mode = ''",
},
{
name: "JSON argument containing column names",
input: `SELECT rid, rid_parent FROM crm_get_menu(1,'mode', '{"rid_parent":"[rid_parent]","CF:STARTDATE":"[cf_startdate]"}')`,
expected: `SELECT rid, rid_parent FROM crm_get_menu(1,'', '')`,
},
{
name: "Escaped single quotes inside literal",
input: "SELECT * FROM t WHERE name = 'O''Brien'",
expected: "SELECT * FROM t WHERE name = ''",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sqlStripStringLiterals(tt.input)
if result != tt.expected {
t.Errorf("sqlStripStringLiterals() =\n %q\nwant\n %q", result, tt.expected)
}
})
}
}
// TestAllowFilterDoesNotMatchInsideJsonArgument verifies that AllowFilter will add WHERE
// clauses for real output columns (rid, rid_parent) but not for names that only appear
// inside a JSON string argument (cf_startdate, cf_rid_branch).
func TestAllowFilterDoesNotMatchInsideJsonArgument(t *testing.T) {
handler := NewHandler(&MockDatabase{})
sqlQuery := `select rid, rid_parent, description
from crm_get_menu([rid_user],'[p_mode]', 0, '', '{"rid_parent":"[rid_parent]", "CF:STARTDATE": "[cf_startdate]", "CF:RID_BRANCH": "[cf_rid_branch]"}')`
tests := []struct {
name string
queryParams map[string]string
checkResult func(t *testing.T, result string)
}{
{
name: "rid_parent=0 is a real column — filter applied",
queryParams: map[string]string{"rid_parent": "0"},
checkResult: func(t *testing.T, result string) {
if !strings.Contains(strings.ToLower(result), "where") {
t.Error("Expected WHERE clause to be added for rid_parent")
}
if !strings.Contains(result, "rid_parent = 0 OR") && !strings.Contains(result, "rid_parent IS NULL") {
t.Errorf("Expected null-safe filter for rid_parent=0, got:\n%s", result)
}
},
},
{
name: "cf_startdate only appears in JSON string — no filter applied",
queryParams: map[string]string{"cf_startdate": "2024-01-01"},
checkResult: func(t *testing.T, result string) {
if strings.Contains(strings.ToLower(result), "where") {
t.Errorf("Expected no WHERE clause for cf_startdate (only in JSON arg), got:\n%s", result)
}
},
},
{
name: "cf_rid_branch only appears in JSON string — no filter applied",
queryParams: map[string]string{"cf_rid_branch": "5"},
checkResult: func(t *testing.T, result string) {
if strings.Contains(strings.ToLower(result), "where") {
t.Errorf("Expected no WHERE clause for cf_rid_branch (only in JSON arg), got:\n%s", result)
}
},
},
{
name: "description is a real column — filter applied",
queryParams: map[string]string{"description": "test"},
checkResult: func(t *testing.T, result string) {
if !strings.Contains(strings.ToLower(result), "where") {
t.Error("Expected WHERE clause for description")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := createTestRequest("GET", "/test", tt.queryParams, nil, nil)
variables := make(map[string]interface{})
propQry := make(map[string]string)
result := handler.mergeQueryParams(req, sqlQuery, variables, true, propQry)
tt.checkResult(t, result)
})
}
}
// TestAllowFilterDoesNotMatchFunctionParams verifies that query params that appear only
// as function call arguments in the FROM clause (e.g. [p_rid_doctype]) are not treated
// as column filters, since they are not in the SELECT list.
func TestAllowFilterDoesNotMatchFunctionParams(t *testing.T) {
handler := NewHandler(&MockDatabase{})
sqlQuery := `select rid, rid_parent, description, row_cnt, filterstring, tableprefix, rid_table, tooltip, additionalfilter, haschildren
from crm_get_doc_menu($JQ$[p_tableprefix]$JQ$,[p_rid_parent],[p_rid_doctype],[p_removedup],[p_showall]) r`
tests := []struct {
name string
queryParams map[string]string
checkResult func(t *testing.T, result string)
}{
{
name: "p_rid_doctype is a function param, not a column — no filter applied",
queryParams: map[string]string{"p_rid_doctype": "0"},
checkResult: func(t *testing.T, result string) {
if strings.Contains(strings.ToLower(result), "where") {
t.Errorf("Expected no WHERE clause for p_rid_doctype (function arg, not SELECT column), got:\n%s", result)
}
},
},
{
name: "p_showall is a function param, not a column — no filter applied",
queryParams: map[string]string{"p_showall": "1"},
checkResult: func(t *testing.T, result string) {
if strings.Contains(strings.ToLower(result), "where") {
t.Errorf("Expected no WHERE clause for p_showall (function arg, not SELECT column), got:\n%s", result)
}
},
},
{
name: "rid is a SELECT column — filter applied",
queryParams: map[string]string{"rid": "42"},
checkResult: func(t *testing.T, result string) {
if !strings.Contains(strings.ToLower(result), "where") {
t.Error("Expected WHERE clause for rid (real SELECT column)")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := createTestRequest("GET", "/test", tt.queryParams, nil, nil)
variables := make(map[string]interface{})
propQry := make(map[string]string)
result := handler.mergeQueryParams(req, sqlQuery, variables, true, propQry)
tt.checkResult(t, result)
})
}
}
// TestGetReplacementForBlankParamDoubleQuote verifies that placeholders surrounded by
// double quotes (as in JSON string values) are blanked to "" not NULL.
func TestGetReplacementForBlankParamDoubleQuote(t *testing.T) {
tests := []struct {
name string
sqlQuery string
param string
expected string
}{
{
name: "Parameter in double quotes (JSON value)",
sqlQuery: `SELECT * FROM f(1, '{"key":"[myparam]"}')`,
param: "[myparam]",
expected: "",
},
{
name: "Parameter not in any quotes",
sqlQuery: `SELECT * FROM f([myparam])`,
param: "[myparam]",
expected: "NULL",
},
{
name: "Parameter in single quotes",
sqlQuery: `SELECT * FROM f('[myparam]')`,
param: "[myparam]",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getReplacementForBlankParam(tt.sqlQuery, tt.param)
if result != tt.expected {
t.Errorf("getReplacementForBlankParam() = %q, want %q\nquery: %s", result, tt.expected, tt.sqlQuery)
}
})
}
}
// TestVariableReplacementFromQueryParams verifies that query params matching [placeholder]
// tokens are substituted even when they don't have the p- prefix.
func TestVariableReplacementFromQueryParams(t *testing.T) {
handler := NewHandler(&MockDatabase{})
sqlQuery := `select rid, rid_parent from crm_get_menu([rid_user],'[p_mode]', 0, '', '{"rid_parent":"[rid_parent]","CF:STARTDATE":"[cf_startdate]"}')`
tests := []struct {
name string
queryParams map[string]string
checkResult func(t *testing.T, result string)
}{
{
name: "rid_parent replaced from query param",
queryParams: map[string]string{"rid_parent": "42"},
checkResult: func(t *testing.T, result string) {
if strings.Contains(result, "[rid_parent]") {
t.Errorf("Expected [rid_parent] to be replaced, still present in:\n%s", result)
}
if !strings.Contains(result, "42") {
t.Errorf("Expected value 42 in query, got:\n%s", result)
}
},
},
{
name: "cf_startdate replaced from query param",
queryParams: map[string]string{"cf_startdate": "2024-01-01"},
checkResult: func(t *testing.T, result string) {
if strings.Contains(result, "[cf_startdate]") {
t.Errorf("Expected [cf_startdate] to be replaced, still present in:\n%s", result)
}
if !strings.Contains(result, "2024-01-01") {
t.Errorf("Expected date value in query, got:\n%s", result)
}
},
},
{
name: "missing param blanked to empty string inside JSON (double-quoted)",
queryParams: map[string]string{},
checkResult: func(t *testing.T, result string) {
// [cf_startdate] is surrounded by " in the JSON — should blank to ""
if strings.Contains(result, "[cf_startdate]") {
t.Errorf("Expected [cf_startdate] to be blanked, still present in:\n%s", result)
}
if strings.Contains(result, "NULL") && strings.Contains(result, "cf_startdate") {
t.Errorf("Expected empty string (not NULL) for double-quoted placeholder, got:\n%s", result)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inputvars := make([]string, 0)
q := handler.extractInputVariables(sqlQuery, &inputvars)
req := createTestRequest("GET", "/test", tt.queryParams, nil, nil)
variables := make(map[string]interface{})
propQry := make(map[string]string)
q = handler.mergeQueryParams(req, q, variables, false, propQry)
// Simulate the variable replacement + blank-param loop (mirrors function_api.go)
for _, kw := range inputvars {
varName := kw[1 : len(kw)-1]
if val, ok := variables[varName]; ok {
if strVal := strings.TrimSpace(val.(string)); strVal != "" {
q = strings.ReplaceAll(q, kw, ValidSQL(strVal, "colvalue"))
continue
}
}
replacement := getReplacementForBlankParam(q, kw)
q = strings.ReplaceAll(q, kw, replacement)
}
tt.checkResult(t, q)
})
}
}
// TestGetReplacementForBlankParam tests the blank parameter replacement logic
func TestGetReplacementForBlankParam(t *testing.T) {
tests := []struct {
+10
View File
@@ -71,6 +71,16 @@ func (f *funcSpecSecurityContext) GetUserID() (int, bool) {
return int(f.ctx.UserContext.UserID), true
}
// GetUserRef returns an opaque user identifier for row security lookups.
// It returns the full *security.UserContext so providers can read JWT claims
// (e.g. a UUID subject) instead of relying on the int user ID.
func (f *funcSpecSecurityContext) GetUserRef() (any, bool) {
if f.ctx.UserContext == nil {
return nil, false
}
return f.ctx.UserContext, true
}
func (f *funcSpecSecurityContext) GetSchema() string {
// funcspec doesn't have a schema concept, extract from SQL query or use default
return "public"
+2 -2
View File
@@ -107,7 +107,7 @@ func (r *DefaultModelRegistry) RegisterModel(name string, model interface{}) err
originalType := modelType
// Unwrap pointers, slices, and arrays to check the underlying type
for modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array {
for modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array {
modelType = modelType.Elem()
}
@@ -124,7 +124,7 @@ func (r *DefaultModelRegistry) RegisterModel(name string, model interface{}) err
// Additional check: ensure model is not a pointer
finalType := reflect.TypeOf(model)
if finalType.Kind() == reflect.Ptr {
if finalType.Kind() == reflect.Pointer {
return fmt.Errorf("model must be a non-pointer struct, got pointer to %s. Use MyModel{} instead of &MyModel{}", finalType.Elem().Name())
}
+6
View File
@@ -781,6 +781,12 @@ func (h *Handler) create(hookCtx *HookContext) (interface{}, error) {
return nil, fmt.Errorf("failed to create record: %w", err)
}
// Re-fetch the created record to capture DB-generated defaults/triggers.
if pkVal := reflection.GetPrimaryKeyValue(hookCtx.ModelPtr); pkVal != nil {
hookCtx.ID = fmt.Sprintf("%v", pkVal)
return h.readByID(hookCtx)
}
return hookCtx.ModelPtr, nil
}
+11
View File
@@ -71,6 +71,17 @@ func (s *securityContext) GetUserID() (int, bool) {
return security.GetUserID(s.ctx.Context)
}
// GetUserRef returns an opaque user identifier for row security lookups.
// It prefers the full *security.UserContext (so providers can read JWT claims,
// e.g. a UUID subject) and falls back to the int user ID.
func (s *securityContext) GetUserRef() (any, bool) {
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
return userCtx, true
}
userID, ok := security.GetUserID(s.ctx.Context)
return userID, ok
}
func (s *securityContext) GetSchema() string {
return s.ctx.Schema
}
+4 -4
View File
@@ -387,7 +387,7 @@ func (g *Generator) generateModelSchema(model interface{}) Schema {
}
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Ptr {
if modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
if modelType.Kind() != reflect.Struct {
@@ -418,7 +418,7 @@ func (g *Generator) generateModelSchema(model interface{}) Schema {
schema.Properties[fieldName] = propSchema
// Check if field is required (not a pointer and no omitempty)
if field.Type.Kind() != reflect.Ptr && !strings.Contains(jsonTag, "omitempty") {
if field.Type.Kind() != reflect.Pointer && !strings.Contains(jsonTag, "omitempty") {
schema.Required = append(schema.Required, fieldName)
}
}
@@ -431,7 +431,7 @@ func (g *Generator) generatePropertySchema(field reflect.StructField) *Schema {
schema := &Schema{}
fieldType := field.Type
if fieldType.Kind() == reflect.Ptr {
if fieldType.Kind() == reflect.Pointer {
fieldType = fieldType.Elem()
}
@@ -453,7 +453,7 @@ func (g *Generator) generatePropertySchema(field reflect.StructField) *Schema {
case reflect.Slice, reflect.Array:
schema.Type = "array"
elemType := fieldType.Elem()
if elemType.Kind() == reflect.Ptr {
if elemType.Kind() == reflect.Pointer {
elemType = elemType.Elem()
}
if elemType.Kind() == reflect.Struct {
+30 -5
View File
@@ -9,7 +9,7 @@ func Len(v any) int {
val := reflect.ValueOf(v)
valKind := val.Kind()
if valKind == reflect.Ptr {
if valKind == reflect.Pointer {
val = val.Elem()
}
@@ -51,16 +51,41 @@ func ExtractTableNameOnly(fullName string) string {
return fullName[startIndex:]
}
// IsEmptyValue reports whether v is nil, an empty string, or a zero number.
func IsEmptyValue(v any) bool {
if v == nil {
return true
}
rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return true
}
rv = rv.Elem()
}
switch rv.Kind() {
case reflect.String:
return rv.String() == ""
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return rv.Int() == 0
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return rv.Uint() == 0
case reflect.Float32, reflect.Float64:
return rv.Float() == 0
}
return false
}
// GetPointerElement returns the element type if the provided reflect.Type is a pointer.
// If the type is a slice of pointers, it returns the element type of the pointer within the slice.
// If neither condition is met, it returns the original type.
func GetPointerElement(v reflect.Type) reflect.Type {
if v.Kind() == reflect.Ptr {
if v.Kind() == reflect.Pointer {
return v.Elem()
}
if v.Kind() == reflect.Slice && v.Elem().Kind() == reflect.Ptr {
if v.Kind() == reflect.Slice && v.Elem().Kind() == reflect.Pointer {
subElem := v.Elem()
if subElem.Elem().Kind() == reflect.Ptr {
if subElem.Elem().Kind() == reflect.Pointer {
return subElem.Elem().Elem()
}
return v.Elem()
@@ -79,7 +104,7 @@ func GetJSONNameForField(modelType reflect.Type, fieldName string) string {
// Unwrap pointer and slice indirections to reach the struct type
for {
switch modelType.Kind() {
case reflect.Ptr, reflect.Slice:
case reflect.Pointer, reflect.Slice:
modelType = modelType.Elem()
continue
}
+117 -15
View File
@@ -226,7 +226,7 @@ func buildJSONToDBMap(modelType reflect.Type, result map[string]string, scanOnly
// Handle embedded structs
if field.Anonymous {
ft := field.Type
if ft.Kind() == reflect.Ptr {
if ft.Kind() == reflect.Pointer {
ft = ft.Elem()
}
isScanOnly := scanOnly
@@ -544,7 +544,7 @@ func IsColumnWritable(model any, columnName string) bool {
// Unwrap pointers and slices to get to the base struct type
for modelType != nil {
switch modelType.Kind() {
case reflect.Ptr, reflect.Slice:
case reflect.Pointer, reflect.Slice:
modelType = modelType.Elem()
continue
}
@@ -709,7 +709,7 @@ func GetColumnTypeFromModel(model interface{}, colName string) reflect.Kind {
modelType := reflect.TypeOf(model)
// Dereference pointer if needed
if modelType.Kind() == reflect.Ptr {
if modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
@@ -886,7 +886,7 @@ func GetRelationType(model interface{}, fieldName string) RelationType {
// Unwrap pointer → slice → pointer chains to reach the underlying struct
for {
switch modelType.Kind() {
case reflect.Ptr, reflect.Slice:
case reflect.Pointer, reflect.Slice:
modelType = modelType.Elem()
continue
}
@@ -947,7 +947,7 @@ func GetRelationType(model interface{}, fieldName string) RelationType {
// Slice indicates has-many or many-to-many
return RelationHasMany
}
if fieldType.Kind() == reflect.Ptr {
if fieldType.Kind() == reflect.Pointer {
// Pointer to single struct usually indicates belongs-to or has-one
// Check if it has foreignKey (belongs-to) or references (has-one)
if strings.Contains(gormTag, "foreignKey:") {
@@ -963,7 +963,7 @@ func GetRelationType(model interface{}, fieldName string) RelationType {
// Slice of structs → has-many
return RelationHasMany
}
if fieldType.Kind() == reflect.Ptr || fieldType.Kind() == reflect.Struct {
if fieldType.Kind() == reflect.Pointer || fieldType.Kind() == reflect.Struct {
// Single struct → belongs-to (default assumption for safety)
// Using belongs-to as default ensures we use JOIN, which is safer
return RelationBelongsTo
@@ -973,6 +973,108 @@ func GetRelationType(model interface{}, fieldName string) RelationType {
return RelationUnknown
}
// GetForeignKeyColumn returns the DB column names of the foreign key(s) that
// relate parentKey to modelType. Composite keys (e.g. bun "join:a=b,join:c=d"
// or GORM "foreignKey:ColA,ColB") yield multiple entries. Returns nil when no
// tag is found (caller should fall back to convention).
//
// Two lookup strategies are tried in order:
//
// 1. Relation-field match: find a field whose name/json equals parentKey, then
// read its bun join: or GORM foreignKey: tag and return the local columns.
// e.g. parentKey="department", field `Department bun:"join:dept_id=id"` → ["dept_id"]
//
// 2. Join left-side scan: scan every bun join tag in the struct for pairs whose
// left side equals parentKey and return the right-side (child FK) columns.
// e.g. parentKey="rid_mastertaskitem", field `Children bun:"join:rid_mastertaskitem=rid_parentmastertaskitem"` → ["rid_parentmastertaskitem"]
// Strategy 1 is skipped if the matched field is a declared relation (rel:) or
// has a GORM tag but carries no explicit FK — callers should use convention.
func GetForeignKeyColumn(modelType reflect.Type, parentKey string) []string {
for modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice {
modelType = modelType.Elem()
}
if modelType.Kind() != reflect.Struct {
return nil
}
// Strategy 1: match parentKey against a field's name/json tag.
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
name := field.Name
jsonName := strings.Split(field.Tag.Get("json"), ",")[0]
if !strings.EqualFold(name, parentKey) && !strings.EqualFold(jsonName, parentKey) {
continue
}
bunTag := field.Tag.Get("bun")
// Bun: join:local_col=foreign_col (one join: part per pair)
var bunCols []string
for _, part := range strings.Split(bunTag, ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "join:") {
pair := strings.TrimPrefix(part, "join:")
if idx := strings.Index(pair, "="); idx > 0 {
bunCols = append(bunCols, pair[:idx])
}
}
}
if len(bunCols) > 0 {
return bunCols
}
// GORM: foreignKey:FieldA,FieldB
for _, part := range strings.Split(field.Tag.Get("gorm"), ";") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "foreignKey:") {
var cols []string
for _, fkFieldName := range strings.Split(strings.TrimPrefix(part, "foreignKey:"), ",") {
fkFieldName = strings.TrimSpace(fkFieldName)
if fkField, ok := modelType.FieldByName(fkFieldName); ok {
cols = append(cols, getColumnNameFromField(fkField))
}
}
if len(cols) > 0 {
return cols
}
}
}
// The field matched by name/json but has no explicit FK tag. If it is a
// declared relation field (rel:) or carries a GORM tag, the caller should
// use naming convention — don't fall through to strategy 2. Otherwise the
// matched field is a plain scalar column; proceed to the join left-side scan.
if strings.Contains(bunTag, "rel:") || field.Tag.Get("gorm") != "" {
return nil
}
break
}
// Strategy 2: scan every field's bun join tag for pairs whose left side (the
// parent's column) matches parentKey; the right side is the child FK column.
// This handles cases where parentKey is a raw column name rather than a
// relation field name (e.g. self-referential or has-many relationships).
seen := map[string]bool{}
var cols []string
for i := 0; i < modelType.NumField(); i++ {
for _, part := range strings.Split(modelType.Field(i).Tag.Get("bun"), ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "join:") {
pair := strings.TrimPrefix(part, "join:")
if idx := strings.Index(pair, "="); idx > 0 {
left, right := pair[:idx], pair[idx+1:]
if strings.EqualFold(left, parentKey) && !seen[right] {
seen[right] = true
cols = append(cols, right)
}
}
}
}
}
return cols // nil if empty
}
// GetRelationModel gets the model type for a relation field
// It searches for the field by name in the following order (case-insensitive):
// 1. Actual field name
@@ -1021,7 +1123,7 @@ func MapToStruct(dataMap map[string]interface{}, target interface{}) error {
}
targetValue := reflect.ValueOf(target)
if targetValue.Kind() != reflect.Ptr {
if targetValue.Kind() != reflect.Pointer {
return fmt.Errorf("target must be a pointer to a struct")
}
@@ -1124,8 +1226,8 @@ func setFieldValue(field reflect.Value, value interface{}) error {
}
// Handle pointer fields
if field.Kind() == reflect.Ptr {
if valueReflect.Kind() != reflect.Ptr {
if field.Kind() == reflect.Pointer {
if valueReflect.Kind() != reflect.Pointer {
// Create a new pointer and set its value
newPtr := reflect.New(field.Type().Elem())
if err := setFieldValue(newPtr.Elem(), value); err != nil {
@@ -1316,14 +1418,14 @@ func convertSlice(targetSlice reflect.Value, sourceSlice reflect.Value) error {
// Handle nil elements
if sourceValue == nil {
// For pointer types, nil is valid
if targetElemType.Kind() == reflect.Ptr {
if targetElemType.Kind() == reflect.Pointer {
targetElem.Set(reflect.Zero(targetElemType))
}
continue
}
// If target element type is a pointer to struct, we need to create new instances
if targetElemType.Kind() == reflect.Ptr {
if targetElemType.Kind() == reflect.Pointer {
// Create a new instance of the pointed-to type
newElemPtr := reflect.New(targetElemType.Elem())
@@ -1486,7 +1588,7 @@ func GetValidJSONFieldNames(modelType reflect.Type) map[string]bool {
// Unwrap pointers and slices to get to the base struct type
for modelType != nil {
switch modelType.Kind() {
case reflect.Ptr, reflect.Slice:
case reflect.Pointer, reflect.Slice:
modelType = modelType.Elem()
continue
}
@@ -1514,7 +1616,7 @@ func collectValidFieldNames(typ reflect.Type, validFields map[string]bool) {
// Check for embedded structs
if field.Anonymous {
fieldType := field.Type
if fieldType.Kind() == reflect.Ptr {
if fieldType.Kind() == reflect.Pointer {
fieldType = fieldType.Elem()
}
if fieldType.Kind() == reflect.Struct {
@@ -1553,7 +1655,7 @@ func getRelationModelSingleLevel(model interface{}, fieldName string) interface{
for {
switch modelType.Kind() {
case reflect.Ptr, reflect.Slice:
case reflect.Pointer, reflect.Slice:
modelType = modelType.Elem()
continue
}
@@ -1622,7 +1724,7 @@ func getRelationModelSingleLevel(model interface{}, fieldName string) interface{
for {
switch targetType.Kind() {
case reflect.Ptr, reflect.Slice:
case reflect.Pointer, reflect.Slice:
targetType = targetType.Elem()
if targetType == nil {
return nil
@@ -0,0 +1,168 @@
package reflection
import (
"reflect"
"testing"
)
// --- local test models ---
type fkDept struct{}
// bunEmployee uses bun join: tag to declare the FK column explicitly.
type bunEmployee struct {
DeptID string `bun:"dept_id" json:"dept_id"`
Department *fkDept `bun:"rel:belongs-to,join:dept_id=id" json:"department"`
}
// bunCompositeEmployee has a composite bun join: (two join: parts).
type bunCompositeEmployee struct {
DeptID string `bun:"dept_id" json:"dept_id"`
TenantID string `bun:"tenant_id" json:"tenant_id"`
Department *fkDept `bun:"rel:belongs-to,join:dept_id=id,join:tenant_id=id" json:"department"`
}
// gormEmployee uses gorm foreignKey: tag (mirrors testmodels.Employee).
type gormEmployee struct {
DepartmentID string `json:"department_id"`
ManagerID string `json:"manager_id"`
Department *fkDept `gorm:"foreignKey:DepartmentID;references:ID" json:"department"`
Manager *fkDept `gorm:"foreignKey:ManagerID;references:ID" json:"manager"`
}
// gormCompositeEmployee has a composite GORM foreignKey.
type gormCompositeEmployee struct {
DeptID string `json:"dept_id"`
TenantID string `json:"tenant_id"`
Department *fkDept `gorm:"foreignKey:DeptID,TenantID" json:"department"`
}
// selfRefItem mimics a self-referential model (like mastertaskitem) where the
// parent PK column appears as the left side of a has-many join tag.
type selfRefItem struct {
RidItem int32 `json:"rid_item" bun:"rid_item,type:integer,pk"`
RidParentItem int32 `json:"rid_parentitem" bun:"rid_parentitem,type:integer"`
// has-one (single parent pointer)
Parent *selfRefItem `json:"Parent,omitempty" bun:"rel:has-one,join:rid_item=rid_parentitem"`
// has-many (child collection) — same join, duplicate right-side must be deduped
Children []*selfRefItem `json:"Children,omitempty" bun:"rel:has-many,join:rid_item=rid_parentitem"`
}
// conventionEmployee has no explicit FK tag — relies on naming convention.
type conventionEmployee struct {
DepartmentID string `json:"department_id"`
Department *fkDept `json:"department"`
}
// noTagEmployee has a relation field with no FK tag and no convention match.
type noTagEmployee struct {
Unrelated *fkDept `json:"unrelated"`
}
func TestGetForeignKeyColumn(t *testing.T) {
tests := []struct {
name string
modelType reflect.Type
parentKey string
want []string
}{
// Bun join: tag
{
name: "bun join tag returns local column",
modelType: reflect.TypeOf(bunEmployee{}),
parentKey: "department",
want: []string{"dept_id"},
},
{
name: "bun join tag matched via json tag (case-insensitive)",
modelType: reflect.TypeOf(bunEmployee{}),
parentKey: "Department",
want: []string{"dept_id"},
},
{
name: "bun composite join returns all local columns",
modelType: reflect.TypeOf(bunCompositeEmployee{}),
parentKey: "department",
want: []string{"dept_id", "tenant_id"},
},
// GORM foreignKey: tag
{
name: "gorm foreignKey resolves to column name",
modelType: reflect.TypeOf(gormEmployee{}),
parentKey: "department",
want: []string{"department_id"},
},
{
name: "gorm foreignKey resolves second relation",
modelType: reflect.TypeOf(gormEmployee{}),
parentKey: "manager",
want: []string{"manager_id"},
},
{
name: "gorm foreignKey matched case-insensitively",
modelType: reflect.TypeOf(gormEmployee{}),
parentKey: "Department",
want: []string{"department_id"},
},
{
name: "gorm composite foreignKey returns all columns",
modelType: reflect.TypeOf(gormCompositeEmployee{}),
parentKey: "department",
want: []string{"dept_id", "tenant_id"},
},
// Join left-side scan (parentKey is a raw column name, not a relation field name)
{
name: "self-referential: parent PK column returns child FK column",
modelType: reflect.TypeOf(selfRefItem{}),
parentKey: "rid_item",
want: []string{"rid_parentitem"},
},
// Pointer and slice unwrapping
{
name: "pointer to struct is unwrapped",
modelType: reflect.TypeOf(&gormEmployee{}),
parentKey: "department",
want: []string{"department_id"},
},
{
name: "slice of struct is unwrapped",
modelType: reflect.TypeOf([]gormEmployee{}),
parentKey: "department",
want: []string{"department_id"},
},
// No tag — returns nil so caller can fall back to convention
{
name: "relation with no FK tag returns nil",
modelType: reflect.TypeOf(conventionEmployee{}),
parentKey: "department",
want: nil,
},
// Unknown parent key
{
name: "unknown parent key returns nil",
modelType: reflect.TypeOf(gormEmployee{}),
parentKey: "nonexistent",
want: nil,
},
{
name: "non-struct type returns nil",
modelType: reflect.TypeOf(""),
parentKey: "department",
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetForeignKeyColumn(tt.modelType, tt.parentKey)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetForeignKeyColumn(%v, %q) = %v, want %v", tt.modelType, tt.parentKey, got, tt.want)
}
})
}
}
+102 -7
View File
@@ -428,14 +428,36 @@ func (h *Handler) executeCreate(ctx context.Context, schema, entity string, data
// Use potentially modified data
data = hookCtx.Data
pkName := reflection.GetPrimaryKeyName(model)
switch v := data.(type) {
case map[string]interface{}:
query := h.db.NewInsert().Table(tableName)
for key, value := range v {
query = query.Value(key, value)
}
if _, err := query.Exec(ctx); err != nil {
return nil, fmt.Errorf("create error: %w", err)
if pkName != "" {
var insertedID interface{}
if err := query.Returning(pkName).Scan(ctx, &insertedID); err != nil {
return nil, fmt.Errorf("create error: %w", err)
}
// Re-fetch after insert to capture DB-generated defaults/triggers.
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
fetchedRecord := reflect.New(modelType).Interface()
if err := h.db.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), insertedID).
ScanModel(ctx); err == nil {
v = mergeWithInput(fetchedRecord, v)
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, insertedID, err)
}
} else {
if _, err := query.Exec(ctx); err != nil {
return nil, fmt.Errorf("create error: %w", err)
}
}
hookCtx.Result = v
if err := h.hooks.Execute(AfterCreate, hookCtx); err != nil {
@@ -444,7 +466,12 @@ func (h *Handler) executeCreate(ctx context.Context, schema, entity string, data
return v, nil
case []interface{}:
results := make([]interface{}, 0, len(v))
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
originals := make([]map[string]interface{}, 0, len(v))
insertedIDs := 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{})
@@ -455,16 +482,43 @@ func (h *Handler) executeCreate(ctx context.Context, schema, entity string, data
for key, value := range itemMap {
q = q.Value(key, value)
}
if _, err := q.Exec(ctx); err != nil {
if pkName == "" {
if _, err := q.Exec(ctx); err != nil {
return err
}
originals = append(originals, itemMap)
insertedIDs = append(insertedIDs, nil)
continue
}
var returnedID interface{}
if err := q.Returning(pkName).Scan(ctx, &returnedID); err != nil {
return err
}
results = append(results, item)
originals = append(originals, itemMap)
insertedIDs = append(insertedIDs, returnedID)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("batch create error: %w", err)
}
// Re-fetch each record after transaction commits; fall back to input on failure.
results := make([]interface{}, 0, len(insertedIDs))
for i, pkVal := range insertedIDs {
if pkVal == nil {
results = append(results, originals[i])
continue
}
fetchedRecord := reflect.New(modelType).Interface()
if err := h.db.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), pkVal).
ScanModel(ctx); err == nil {
results = append(results, mergeWithInput(fetchedRecord, originals[i]))
} else {
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, pkVal, err)
results = append(results, originals[i])
}
}
hookCtx.Result = results
if err := h.hooks.Execute(AfterCreate, hookCtx); err != nil {
return nil, fmt.Errorf("AfterCreate hook failed: %w", err)
@@ -513,7 +567,7 @@ func (h *Handler) executeUpdate(ctx context.Context, schema, entity, id string,
err = h.db.RunInTransaction(ctx, func(tx common.Database) error {
// Read existing record
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Ptr {
if modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
existingRecord := reflect.New(modelType).Interface()
@@ -584,6 +638,25 @@ func (h *Handler) executeUpdate(ctx context.Context, schema, entity, id string,
if err != nil {
return nil, err
}
// Re-fetch the record after transaction commits to capture DB-generated changes.
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
fetchedRecord := reflect.New(modelType).Interface()
if err := h.db.NewSelect().Model(fetchedRecord).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id).
ScanModel(ctx); err == nil {
jsonData, marshalErr := json.Marshal(fetchedRecord)
if marshalErr == nil {
var fetchedMap map[string]interface{}
if json.Unmarshal(jsonData, &fetchedMap) == nil {
updateResult = fetchedMap
}
}
}
return updateResult, nil
}
@@ -628,7 +701,7 @@ func (h *Handler) executeDelete(ctx context.Context, schema, entity, id string)
}
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Ptr {
if modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
@@ -749,6 +822,28 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (condition st
return "", nil
}
// mergeWithInput merges a database record with the original request data.
// DB values take precedence (capturing triggers/defaults), while extra
// input keys that have no DB column are preserved in the response.
func mergeWithInput(dbRecord interface{}, input map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{}, len(input))
for k, v := range input {
result[k] = v
}
jsonData, err := json.Marshal(dbRecord)
if err != nil {
return result
}
var dbMap map[string]interface{}
if err := json.Unmarshal(jsonData, &dbMap); err != nil {
return result
}
for k, v := range dbMap {
result[k] = v
}
return result
}
func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, preloads []common.PreloadOption) (common.SelectQuery, error) {
for i := range preloads {
preload := &preloads[i]
+11
View File
@@ -84,6 +84,17 @@ func (s *securityContext) GetUserID() (int, bool) {
return security.GetUserID(s.ctx.Context)
}
// GetUserRef returns an opaque user identifier for row security lookups.
// It prefers the full *security.UserContext (so providers can read JWT claims,
// e.g. a UUID subject) and falls back to the int user ID.
func (s *securityContext) GetUserRef() (any, bool) {
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
return userCtx, true
}
userID, ok := security.GetUserID(s.ctx.Context)
return userID, ok
}
func (s *securityContext) GetSchema() string {
return s.ctx.Schema
}
+3 -3
View File
@@ -67,7 +67,7 @@ func buildModelInfo(schema, entity string, model interface{}) modelInfo {
// Unwrap to base struct type
modelType := reflect.TypeOf(model)
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice) {
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice) {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
@@ -87,7 +87,7 @@ func buildModelInfo(schema, entity string, model interface{}) modelInfo {
fieldType, found := modelType.FieldByName(d.Name)
if found {
ft := fieldType.Type
if ft.Kind() == reflect.Ptr {
if ft.Kind() == reflect.Pointer {
ft = ft.Elem()
}
isUserStruct := ft.Kind() == reflect.Struct && ft.Name() != "Time" && ft.PkgPath() != ""
@@ -106,7 +106,7 @@ func buildModelInfo(schema, entity string, model interface{}) modelInfo {
goType := d.DataType
if goType == "" && found {
ft := fieldType.Type
for ft.Kind() == reflect.Ptr {
for ft.Kind() == reflect.Pointer {
ft = ft.Elem()
}
goType = ft.Name()
+614 -306
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -78,6 +78,17 @@ func (s *securityContext) GetUserID() (int, bool) {
return security.GetUserID(s.ctx.Context)
}
// GetUserRef returns an opaque user identifier for row security lookups.
// It prefers the full *security.UserContext (so providers can read JWT claims,
// e.g. a UUID subject) and falls back to the int user ID.
func (s *securityContext) GetUserRef() (any, bool) {
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
return userCtx, true
}
userID, ok := security.GetUserID(s.ctx.Context)
return userID, ok
}
func (s *securityContext) GetSchema() string {
return s.ctx.Schema
}
+209
View File
@@ -0,0 +1,209 @@
package restheadspec
import (
"encoding/json"
"testing"
"github.com/bitechdev/ResolveSpec/pkg/common"
)
// detailTestModel is a simple model with gorm column/type tags for detail format tests.
type detailTestModel struct {
ID int64 `bun:"rid,pk" gorm:"column:rid;primaryKey" json:"rid"`
Name string `bun:"name" gorm:"column:name;type:citext" json:"name"`
Description *string `bun:"description" gorm:"column:description;type:text;nullable" json:"description"`
Score float64 `bun:"score" gorm:"column:score;type:numeric" json:"score"`
Active bool `bun:"active" gorm:"column:active;type:boolean;not null" json:"active"`
}
func TestSendFormattedResponse_DetailFormat(t *testing.T) {
handler := &Handler{}
name := "hello"
items := []*detailTestModel{
{ID: 1, Name: "first", Description: &name, Score: 1.5, Active: true},
{ID: 2, Name: "second", Description: nil, Score: 2.0, Active: false},
}
metadata := &common.Metadata{
Total: 36,
Count: 2,
Filtered: 36,
Limit: 10,
Offset: 0,
}
options := ExtendedRequestOptions{
ResponseFormat: "detail",
}
mockWriter := &MockTestResponseWriter{headers: make(map[string]string)}
handler.sendFormattedResponse(mockWriter, items, metadata, "myschema.myentity", detailTestModel{}, options)
if mockWriter.statusCode != 200 {
t.Fatalf("expected status 200, got %d", mockWriter.statusCode)
}
body, err := json.Marshal(mockWriter.body)
if err != nil {
t.Fatalf("failed to marshal body: %v", err)
}
var resp map[string]json.RawMessage
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
t.Run("top-level keys", func(t *testing.T) {
for _, key := range []string{"count", "fields", "items", "tablename", "tableprefix", "total"} {
if _, ok := resp[key]; !ok {
t.Errorf("missing key %q in detail response", key)
}
}
})
t.Run("count and total are string", func(t *testing.T) {
var count, total string
if err := json.Unmarshal(resp["count"], &count); err != nil {
t.Errorf("count is not a string: %v", err)
}
if err := json.Unmarshal(resp["total"], &total); err != nil {
t.Errorf("total is not a string: %v", err)
}
if count != "2" {
t.Errorf("expected count %q, got %q", "2", count)
}
if total != "36" {
t.Errorf("expected total %q, got %q", "36", total)
}
})
t.Run("tablename and tableprefix", func(t *testing.T) {
var tablename, tableprefix string
json.Unmarshal(resp["tablename"], &tablename)
json.Unmarshal(resp["tableprefix"], &tableprefix)
if tablename != "myschema.myentity" {
t.Errorf("expected tablename %q, got %q", "myschema.myentity", tablename)
}
if tableprefix != "myentity" {
t.Errorf("expected tableprefix %q, got %q", "myentity", tableprefix)
}
})
t.Run("items contains data", func(t *testing.T) {
var itemSlice []map[string]interface{}
if err := json.Unmarshal(resp["items"], &itemSlice); err != nil {
t.Fatalf("items is not an array: %v", err)
}
if len(itemSlice) != 2 {
t.Errorf("expected 2 items, got %d", len(itemSlice))
}
})
t.Run("fields contains column metadata", func(t *testing.T) {
var fields []map[string]interface{}
if err := json.Unmarshal(resp["fields"], &fields); err != nil {
t.Fatalf("fields is not an array: %v", err)
}
if len(fields) == 0 {
t.Fatal("expected fields to be non-empty")
}
bySQL := make(map[string]map[string]interface{}, len(fields))
for _, f := range fields {
if sqlname, ok := f["sqlname"].(string); ok {
bySQL[sqlname] = f
}
}
// Check required field keys are present
for _, f := range fields {
for _, key := range []string{"name", "datatype", "sqlname", "sqldatatype", "sqlkey", "nullable"} {
if _, ok := f[key]; !ok {
t.Errorf("field %v missing key %q", f, key)
}
}
}
// Validate specific columns
if col, ok := bySQL["rid"]; ok {
if col["sqlkey"] != "primary_key" {
t.Errorf("rid: expected sqlkey %q, got %v", "primary_key", col["sqlkey"])
}
} else {
t.Error("expected column 'rid' in fields")
}
if col, ok := bySQL["name"]; ok {
if col["sqldatatype"] != "citext" {
t.Errorf("name: expected sqldatatype %q, got %v", "citext", col["sqldatatype"])
}
if col["nullable"] != false {
t.Errorf("name: expected nullable false, got %v", col["nullable"])
}
} else {
t.Error("expected column 'name' in fields")
}
if col, ok := bySQL["description"]; ok {
if col["sqldatatype"] != "text" {
t.Errorf("description: expected sqldatatype %q, got %v", "text", col["sqldatatype"])
}
if col["nullable"] != true {
t.Errorf("description: expected nullable true, got %v", col["nullable"])
}
} else {
t.Error("expected column 'description' in fields")
}
})
}
func TestSendFormattedResponse_DetailFormat_EmptyItems(t *testing.T) {
handler := &Handler{}
metadata := &common.Metadata{Total: 0, Count: 0, Filtered: 0}
options := ExtendedRequestOptions{ResponseFormat: "detail"}
mockWriter := &MockTestResponseWriter{headers: make(map[string]string)}
handler.sendFormattedResponse(mockWriter, []*detailTestModel{}, metadata, "s.t", detailTestModel{}, options)
body, _ := json.Marshal(mockWriter.body)
var resp map[string]json.RawMessage
json.Unmarshal(body, &resp)
var count, total string
json.Unmarshal(resp["count"], &count)
json.Unmarshal(resp["total"], &total)
if count != "0" || total != "0" {
t.Errorf("expected count/total both %q, got count=%q total=%q", "0", count, total)
}
var fields []interface{}
json.Unmarshal(resp["fields"], &fields)
if len(fields) == 0 {
t.Error("fields should still list column metadata even when items is empty")
}
}
func TestBuildDetailFields_SkipsRelations(t *testing.T) {
type child struct {
ID int64 `bun:"id,pk" gorm:"column:id;primaryKey" json:"id"`
}
type parent struct {
ID int64 `bun:"id,pk" gorm:"column:id;primaryKey" json:"id"`
Name string `bun:"name" gorm:"column:name" json:"name"`
Children []child `bun:"rel:has-many" json:"children"`
Child *child `bun:"rel:has-one" json:"child"`
}
handler := &Handler{}
fields := handler.buildDetailFields(parent{})
for _, f := range fields {
if f.SQLName == "children" || f.SQLName == "child" {
t.Errorf("relation field %q should not appear in detail fields", f.SQLName)
}
}
if len(fields) != 2 {
t.Errorf("expected 2 scalar fields (id, name), got %d", len(fields))
}
}
+20 -20
View File
@@ -9,29 +9,29 @@ import (
"github.com/bitechdev/ResolveSpec/pkg/common"
)
// Test that normalizeResultArray returns empty array when no records found without ID
// Test that normalizeResultArray returns empty object when no records found (single-record mode)
func TestNormalizeResultArray_EmptyArrayWhenNoID(t *testing.T) {
handler := &Handler{}
tests := []struct {
name string
input interface{}
shouldBeEmptyArr bool
shouldBeEmptyObj bool
}{
{
name: "nil should return empty array",
name: "nil should return empty object",
input: nil,
shouldBeEmptyArr: true,
shouldBeEmptyObj: true,
},
{
name: "empty slice should return empty array",
name: "empty slice should return empty object",
input: []*EmptyTestModel{},
shouldBeEmptyArr: true,
shouldBeEmptyObj: true,
},
{
name: "single element should return the element",
input: []*EmptyTestModel{{ID: 1, Name: "test"}},
shouldBeEmptyArr: false,
shouldBeEmptyObj: false,
},
{
name: "multiple elements should return the slice",
@@ -39,7 +39,7 @@ func TestNormalizeResultArray_EmptyArrayWhenNoID(t *testing.T) {
{ID: 1, Name: "test1"},
{ID: 2, Name: "test2"},
},
shouldBeEmptyArr: false,
shouldBeEmptyObj: false,
},
}
@@ -47,25 +47,25 @@ func TestNormalizeResultArray_EmptyArrayWhenNoID(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
result := handler.normalizeResultArray(tt.input)
// For cases that should return empty array
if tt.shouldBeEmptyArr {
emptyArr, ok := result.([]interface{})
// For cases that should return empty object
if tt.shouldBeEmptyObj {
emptyObj, ok := result.(map[string]interface{})
if !ok {
t.Errorf("Expected empty array []interface{}{}, got %T: %v", result, result)
t.Errorf("Expected empty object map[string]interface{}{}, got %T: %v", result, result)
return
}
if len(emptyArr) != 0 {
t.Errorf("Expected empty array with length 0, got length %d", len(emptyArr))
if len(emptyObj) != 0 {
t.Errorf("Expected empty object with length 0, got length %d", len(emptyObj))
}
// Verify it serializes to [] and not null
// Verify it serializes to {} and not null
jsonBytes, err := json.Marshal(result)
if err != nil {
t.Errorf("Failed to marshal result: %v", err)
return
}
if string(jsonBytes) != "[]" {
t.Errorf("Expected JSON '[]', got '%s'", string(jsonBytes))
if string(jsonBytes) != "{}" {
t.Errorf("Expected JSON '{}', got '%s'", string(jsonBytes))
}
}
})
@@ -95,7 +95,7 @@ func TestSendFormattedResponse_NoDataFoundHeader(t *testing.T) {
// Test with empty data
emptyData := []interface{}{}
handler.sendFormattedResponse(mockWriter, emptyData, metadata, options)
handler.sendFormattedResponse(mockWriter, emptyData, metadata, "", nil, options)
// Check if X-No-Data-Found header was set
if mockWriter.headers["X-No-Data-Found"] != "true" {
@@ -138,12 +138,12 @@ func TestSendResponseWithOptions_NoDataFoundHeader(t *testing.T) {
t.Errorf("Expected X-No-Data-Found header to be 'true', got '%s'", mockWriter.headers["X-No-Data-Found"])
}
// Check status code is 200
// Check status code is 200 even when no records found
if mockWriter.statusCode != 200 {
t.Errorf("Expected status code 200, got %d", mockWriter.statusCode)
}
// Verify the body is an empty array
// Verify the body is an empty array (list request, SingleRecordAsObject not set)
if mockWriter.body == nil {
t.Error("Expected body to be set, got nil")
} else {
File diff suppressed because it is too large Load Diff
+39
View File
@@ -352,6 +352,45 @@ func (m *mockRegistry) GetAllModels() map[string]interface{} {
return m.models
}
// TestIsValidNestedRequest verifies that only the allowed _request verbs are accepted
// and that items missing the key are rejected.
func TestIsValidNestedRequest(t *testing.T) {
tests := []struct {
name string
item map[string]interface{}
expected bool
}{
// Valid verbs
{name: "insert", item: map[string]interface{}{"_request": "insert"}, expected: true},
{name: "add", item: map[string]interface{}{"_request": "add"}, expected: true},
{name: "update", item: map[string]interface{}{"_request": "update"}, expected: true},
{name: "change", item: map[string]interface{}{"_request": "change"}, expected: true},
{name: "delete", item: map[string]interface{}{"_request": "delete"}, expected: true},
{name: "remove", item: map[string]interface{}{"_request": "remove"}, expected: true},
// Case-insensitive
{name: "INSERT uppercase", item: map[string]interface{}{"_request": "INSERT"}, expected: true},
{name: "Remove mixed case", item: map[string]interface{}{"_request": "Remove"}, expected: true},
// Whitespace trimmed
{name: "insert with spaces", item: map[string]interface{}{"_request": " insert "}, expected: true},
// Invalid / missing
{name: "missing _request", item: map[string]interface{}{"name": "foo"}, expected: false},
{name: "empty string", item: map[string]interface{}{"_request": ""}, expected: false},
{name: "unknown verb", item: map[string]interface{}{"_request": "create"}, expected: false},
{name: "unknown verb modify", item: map[string]interface{}{"_request": "modify"}, expected: false},
{name: "non-string value", item: map[string]interface{}{"_request": 42}, expected: false},
{name: "nil value", item: map[string]interface{}{"_request": nil}, expected: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isValidNestedRequest(tt.item)
if got != tt.expected {
t.Errorf("isValidNestedRequest(%v) = %v, want %v", tt.item, got, tt.expected)
}
})
}
}
// TestMultiLevelRelationExtraction tests extracting deeply nested relations
func TestMultiLevelRelationExtraction(t *testing.T) {
registry := &mockRegistry{
+65 -20
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
@@ -63,7 +65,10 @@ type ExpandOption struct {
// decodeHeaderValue decodes base64 encoded header values
// Supports ZIP_ and __ prefixes for base64 encoding
func decodeHeaderValue(value string) string {
str, _ := DecodeParam(value)
str, err := DecodeParam(value)
if err != nil {
return value
}
return str
}
@@ -136,9 +141,21 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request, model interface{}) E
combinedParams[strings.ToLower(key)] = value
}
sortedKeys := make([]string, 0, len(combinedParams))
for key := range combinedParams {
sortedKeys = append(sortedKeys, key)
}
sort.Slice(sortedKeys, func(i, j int) bool {
if sortedKeys[i] != sortedKeys[j] {
return sortedKeys[i] < sortedKeys[j]
}
return combinedParams[sortedKeys[i]] < combinedParams[sortedKeys[j]]
})
// Process each parameter (from both headers and query params)
// Note: keys are already normalized to lowercase in combinedParams
for key, value := range combinedParams {
for _, key := range sortedKeys {
value := combinedParams[key]
// Decode value if it's base64 encoded
decodedValue := decodeHeaderValue(value)
@@ -208,12 +225,13 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request, model interface{}) E
limitValueParts := strings.Split(limitValue, ",")
if len(limitValueParts) > 1 {
if offset, err := strconv.Atoi(limitValueParts[0]); err == nil {
options.Offset = &offset
}
if limit, err := strconv.Atoi(limitValueParts[1]); err == nil {
if limit, err := strconv.Atoi(limitValueParts[0]); err == nil {
options.Limit = &limit
}
if offset, err := strconv.Atoi(limitValueParts[1]); err == nil {
options.Offset = &offset
}
} else {
if limit, err := strconv.Atoi(limitValueParts[0]); err == nil {
options.Limit = &limit
@@ -501,6 +519,31 @@ func (h *Handler) parseExpand(options *ExtendedRequestOptions, value string) {
}
}
// reMultiJoinBoundary finds the start of each individual JOIN clause within a string that
// may contain multiple consecutive JOIN clauses (e.g., "INNER JOIN ... LEFT OUTER JOIN ...").
var reMultiJoinBoundary = regexp.MustCompile(`(?i)(?:inner|left(?:\s+outer)?|right(?:\s+outer)?|full(?:\s+outer)?|cross)\s+join\b`)
// splitJoinClauses splits a SQL string that may contain multiple JOIN clauses into
// individual clauses. A plain pipe-separated segment may itself contain several JOINs;
// this function splits them so each gets its own alias entry.
func splitJoinClauses(joinStr string) []string {
indices := reMultiJoinBoundary.FindAllStringIndex(joinStr, -1)
if len(indices) <= 1 {
return []string{strings.TrimSpace(joinStr)}
}
parts := make([]string, 0, len(indices))
for i, idx := range indices {
end := len(joinStr)
if i+1 < len(indices) {
end = indices[i+1][0]
}
if part := strings.TrimSpace(joinStr[idx[0]:end]); part != "" {
parts = append(parts, part)
}
}
return parts
}
// parseCustomSQLJoin parses x-custom-sql-join header
// Format: Single JOIN clause or multiple JOIN clauses separated by |
// Example: "LEFT JOIN departments d ON d.id = employees.department_id"
@@ -533,17 +576,19 @@ func (h *Handler) parseCustomSQLJoin(options *ExtendedRequestOptions, value stri
continue
}
// Extract table alias from the JOIN clause
alias := extractJoinAlias(sanitizedJoin)
if alias != "" {
// Split into individual JOIN clauses so each clause gets its own alias entry.
// CustomSQLJoin and JoinAliases are kept parallel (one entry per individual clause).
for _, clause := range splitJoinClauses(sanitizedJoin) {
alias := extractJoinAlias(clause)
// Keep arrays parallel; use empty string when alias cannot be extracted.
options.JoinAliases = append(options.JoinAliases, alias)
// Also add to the embedded RequestOptions for validation
options.RequestOptions.JoinAliases = append(options.RequestOptions.JoinAliases, alias)
logger.Debug("Extracted join alias: %s", alias)
if alias != "" {
logger.Debug("Extracted join alias: %s", alias)
}
logger.Debug("Adding custom SQL join: %s", clause)
options.CustomSQLJoin = append(options.CustomSQLJoin, clause)
}
logger.Debug("Adding custom SQL join: %s", sanitizedJoin)
options.CustomSQLJoin = append(options.CustomSQLJoin, sanitizedJoin)
}
}
@@ -933,7 +978,7 @@ func (h *Handler) resolveRelationName(model interface{}, nameOrTable string) str
}
// Dereference pointer if needed
if modelType.Kind() == reflect.Ptr {
if modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
@@ -968,13 +1013,13 @@ func (h *Handler) resolveRelationName(model interface{}, nameOrTable string) str
var targetType reflect.Type
if fieldType.Kind() == reflect.Slice {
targetType = fieldType.Elem()
} else if fieldType.Kind() == reflect.Ptr {
} else if fieldType.Kind() == reflect.Pointer {
targetType = fieldType.Elem()
}
if targetType != nil {
// Dereference pointer if the slice contains pointers
if targetType.Kind() == reflect.Ptr {
if targetType.Kind() == reflect.Pointer {
targetType = targetType.Elem()
}
@@ -1018,7 +1063,7 @@ func (h *Handler) resolveRelationNameWithJoinKey(model interface{}, nameOrTable
if modelType == nil {
return nameOrTable
}
if modelType.Kind() == reflect.Ptr {
if modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
if modelType == nil || modelType.Kind() != reflect.Struct {
@@ -1045,10 +1090,10 @@ func (h *Handler) resolveRelationNameWithJoinKey(model interface{}, nameOrTable
var targetType reflect.Type
if fieldType.Kind() == reflect.Slice {
targetType = fieldType.Elem()
} else if fieldType.Kind() == reflect.Ptr {
} else if fieldType.Kind() == reflect.Pointer {
targetType = fieldType.Elem()
}
if targetType != nil && targetType.Kind() == reflect.Ptr {
if targetType != nil && targetType.Kind() == reflect.Pointer {
targetType = targetType.Elem()
}
if targetType == nil || targetType.Kind() != reflect.Struct {
+11
View File
@@ -77,6 +77,17 @@ func (s *securityContext) GetUserID() (int, bool) {
return security.GetUserID(s.ctx.Context)
}
// GetUserRef returns an opaque user identifier for row security lookups.
// It prefers the full *security.UserContext (so providers can read JWT claims,
// e.g. a UUID subject) and falls back to the int user ID.
func (s *securityContext) GetUserRef() (any, bool) {
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
return userCtx, true
}
userID, ok := security.GetUserID(s.ctx.Context)
return userID, ok
}
func (s *securityContext) GetSchema() string {
return s.ctx.Schema
}
+90 -1
View File
@@ -11,7 +11,8 @@ Type-safe, composable security system for ResolveSpec with support for authentic
- ✅ **No Global State** - Each handler has its own security configuration
- ✅ **Testable** - Easy to mock and test
- ✅ **Extensible** - Implement custom providers for your needs
- ✅ **Stored Procedures** - All database operations use PostgreSQL stored procedures for security and maintainability
- ✅ **Stored Procedures** - Database operations use PostgreSQL stored procedures where available, for security and maintainability
- ✅ **Direct Mode** - Portable Go/SQL fallback for SQLite, MySQL, or Postgres without the stored procedures installed — no code changes required
- ✅ **OAuth2 Authorization Server** - Built-in OAuth 2.1 + PKCE server (RFC 8414, 7591, 7009, 7662) with login form and external provider federation
- ✅ **Password Reset** - Self-service password reset with secure token generation and session invalidation
@@ -51,6 +52,94 @@ Type-safe, composable security system for ResolveSpec with support for authentic
See `database_schema.sql` for complete stored procedure definitions and examples.
**Not on Postgres, or don't have the procedures installed?** See [Direct Mode](#direct-mode-portable-sql-without-stored-procedures) below — every provider that calls a `resolvespec_*` procedure also has a portable Go/SQL implementation that works on SQLite, MySQL, or plain Postgres.
## Direct Mode (portable SQL without stored procedures)
Every database-backed provider (`DatabaseAuthenticator`, `JWTAuthenticator`, `DatabaseTwoFactorProvider`, `DatabasePasskeyProvider`, the OAuth2 methods/server, `DatabaseKeyStore`) has two code paths:
- **Procedure mode** — calls the configured `resolvespec_*` stored procedure (original behavior, Postgres-only).
- **Direct mode** — reimplements the same logic in Go using plain parameterized SQL against configurable table names. Works on SQLite, MySQL, or a Postgres database where the procedures were never deployed.
### QueryMode
Selection is controlled per-provider by a `QueryMode`:
```go
type QueryMode int
const (
ModeAuto QueryMode = iota // default
ModeProcedure
ModeDirect
)
```
- **`ModeAuto`** (default, zero value) — auto-detects per connection:
- SQLite/MySQL drivers → Direct mode, no probing.
- Postgres drivers (`lib/pq`, `pgx`) → probes `pg_proc` for the configured procedure name and uses it **only if it actually exists**; otherwise falls back to Direct mode. The result is cached per procedure name and reset on reconnect.
- Any other/unrecognized driver (including `sqlmock` test doubles) → defaults to Procedure mode, preserving existing behavior for callers that don't expose an identifiable driver type.
- **`ModeProcedure`** — always calls the stored procedure, regardless of dialect.
- **`ModeDirect`** — always uses the portable Go/SQL path, never the stored procedure.
Set it via the provider's `Options` struct or `With...` chain method:
```go
auth := security.NewDatabaseAuthenticatorWithOptions(db, security.DatabaseAuthenticatorOptions{
QueryMode: security.ModeDirect, // force Direct mode, e.g. for SQLite
})
tfaProvider := security.NewDatabaseTwoFactorProvider(sqliteDB, nil).
WithQueryMode(security.ModeDirect)
```
On a real SQLite/MySQL connection you can usually leave `QueryMode` unset — `ModeAuto` detects the dialect and uses Direct mode automatically.
### TableNames / KeyStoreTableNames
Direct mode reads/writes plain tables instead of calling procedures, so table names are configurable the same way procedure names are (`SQLNames`):
```go
type TableNames struct {
Users string // default: "users"
UserSessions string // default: "user_sessions"
TokenBlacklist string // default: "token_blacklist"
UserTOTPBackupCodes string // default: "user_totp_backup_codes"
UserPasskeyCredentials string // default: "user_passkey_credentials"
UserPasswordResets string // default: "user_password_resets"
OAuthClients string // default: "oauth_clients"
OAuthCodes string // default: "oauth_codes"
}
type KeyStoreTableNames struct {
UserKeys string // default: "user_keys" — used by DatabaseKeyStore
}
```
`DefaultTableNames()` / `MergeTableNames()` / `ValidateTableNames()` mirror `DefaultSQLNames()` / `MergeSQLNames()` / `ValidateSQLNames()`. Set custom names via the same `Options`/`With...` surface as `QueryMode`:
```go
auth := security.NewDatabaseAuthenticatorWithOptions(db, security.DatabaseAuthenticatorOptions{
TableNames: &security.TableNames{Users: "app_users"}, // only override what differs
})
```
`oauth2_methods.go` and `oauth_server_db.go` are methods on `*DatabaseAuthenticator` and reuse its `TableNames`/`QueryMode`; there's no separate config for them.
### Schema
`database_schema_sqlite.sql` is the portable companion to `database_schema.sql` — plain `CREATE TABLE` statements only (no functions, no triggers, no `jsonb`/`bytea`/array types), covering every table Direct mode reads or writes. Use it to stand up a SQLite (or adapt for MySQL) database for Direct mode.
### What's NOT covered
`ColumnSecurityProvider`/`RowSecurityProvider` (`resolvespec_column_security` / `resolvespec_row_security`) query an external `core.secaccess`/`core.hub_link` schema this package doesn't own. Direct mode has no portable equivalent to fabricate for these and returns `security.ErrDirectModeUnsupported` — use `ConfigColumnSecurityProvider`/`ConfigRowSecurityProvider` instead when not running against Postgres with those procedures installed.
### Behavioral notes
- Direct mode matches Procedure mode's current behavior exactly, including its TODOs — e.g. passwords are compared as-is (the stored procedures don't verify bcrypt hashes yet either; see the TODO in `resolvespec_login`/`resolvespec_password_reset`).
- Session tokens generated by Direct mode use the same `sess_<hex>_<unix-timestamp>` shape as the plpgsql procedures.
- `bytea`/array/`jsonb` Postgres-only columns (passkey credentials, OAuth2 client scopes, keystore `meta`) are stored as base64/JSON-encoded `TEXT` in Direct mode — transparent to callers, since the Go-level API already deals in those same encodings.
## Quick Start
```go
+57
View File
@@ -0,0 +1,57 @@
package security
import (
"context"
"fmt"
"net/http"
)
// ChainAuthenticator tries each authenticator in order, returning the first success.
// Login and Logout are delegated to the primary authenticator.
type ChainAuthenticator struct {
authenticators []Authenticator
authenticateCallback func(r *http.Request) (*UserContext, error)
}
// NewChainAuthenticator creates a ChainAuthenticator from the given authenticators.
// At least one authenticator is required; the first is treated as primary for Login/Logout.
func NewChainAuthenticator(primary Authenticator, rest ...Authenticator) *ChainAuthenticator {
return &ChainAuthenticator{
authenticators: append([]Authenticator{primary}, rest...),
}
}
func (c *ChainAuthenticator) Authenticate(r *http.Request) (*UserContext, error) {
var lastErr error
for _, a := range c.authenticators {
if uc, err := a.Authenticate(r); err == nil {
return uc, nil
} else {
lastErr = err
}
}
if c.authenticateCallback != nil {
return c.authenticateCallback(r)
}
return nil, fmt.Errorf("all authenticators failed; last error: %w", lastErr)
}
func (c *ChainAuthenticator) SetAuthenticateCallback(fn func(r *http.Request) (*UserContext, error)) {
c.authenticateCallback = fn
}
func (c *ChainAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
return c.authenticators[0].Login(ctx, req)
}
func (c *ChainAuthenticator) LoginWithCookie(ctx context.Context, req LoginRequest, w http.ResponseWriter) (*LoginResponse, error) {
return c.authenticators[0].LoginWithCookie(ctx, req, w)
}
func (c *ChainAuthenticator) Logout(ctx context.Context, req LogoutRequest) error {
return c.authenticators[0].Logout(ctx, req)
}
func (c *ChainAuthenticator) LogoutWithCookie(ctx context.Context, req LogoutRequest, w http.ResponseWriter) error {
return c.authenticators[0].LogoutWithCookie(ctx, req, w)
}
+127
View File
@@ -0,0 +1,127 @@
package security
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
// stubAuthenticator is a configurable Authenticator for testing.
type stubAuthenticator struct {
userCtx *UserContext
err error
}
func (s *stubAuthenticator) Authenticate(_ *http.Request) (*UserContext, error) {
return s.userCtx, s.err
}
func (s *stubAuthenticator) Login(_ context.Context, _ LoginRequest) (*LoginResponse, error) {
if s.err != nil {
return nil, s.err
}
return &LoginResponse{Token: "tok"}, nil
}
func (s *stubAuthenticator) LoginWithCookie(ctx context.Context, req LoginRequest, _ http.ResponseWriter) (*LoginResponse, error) {
return s.Login(ctx, req)
}
func (s *stubAuthenticator) Logout(_ context.Context, _ LogoutRequest) error {
return s.err
}
func (s *stubAuthenticator) LogoutWithCookie(ctx context.Context, req LogoutRequest, _ http.ResponseWriter) error {
return s.Logout(ctx, req)
}
func (s *stubAuthenticator) SetAuthenticateCallback(_ func(r *http.Request) (*UserContext, error)) {}
func TestChainAuthenticator_Authenticate(t *testing.T) {
successCtx := &UserContext{UserID: 42, UserName: "alice"}
failStub := &stubAuthenticator{err: fmt.Errorf("no token")}
okStub := &stubAuthenticator{userCtx: successCtx}
t.Run("primary succeeds", func(t *testing.T) {
chain := NewChainAuthenticator(okStub, failStub)
req := httptest.NewRequest("GET", "/", nil)
uc, err := chain.Authenticate(req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if uc.UserID != 42 {
t.Errorf("expected UserID 42, got %d", uc.UserID)
}
})
t.Run("primary fails, secondary succeeds", func(t *testing.T) {
chain := NewChainAuthenticator(failStub, okStub)
req := httptest.NewRequest("GET", "/", nil)
uc, err := chain.Authenticate(req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if uc.UserID != 42 {
t.Errorf("expected UserID 42, got %d", uc.UserID)
}
})
t.Run("all fail", func(t *testing.T) {
chain := NewChainAuthenticator(failStub, failStub)
req := httptest.NewRequest("GET", "/", nil)
_, err := chain.Authenticate(req)
if err == nil {
t.Fatal("expected error when all authenticators fail")
}
})
t.Run("three in chain, first two fail", func(t *testing.T) {
chain := NewChainAuthenticator(failStub, failStub, okStub)
req := httptest.NewRequest("GET", "/", nil)
uc, err := chain.Authenticate(req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if uc.UserName != "alice" {
t.Errorf("expected UserName alice, got %s", uc.UserName)
}
})
}
func TestChainAuthenticator_LoginLogout(t *testing.T) {
primary := &stubAuthenticator{userCtx: &UserContext{UserID: 1}}
secondary := &stubAuthenticator{userCtx: &UserContext{UserID: 2}}
chain := NewChainAuthenticator(primary, secondary)
ctx := context.Background()
t.Run("login delegates to primary", func(t *testing.T) {
resp, err := chain.Login(ctx, LoginRequest{Username: "u", Password: "p"})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if resp.Token != "tok" {
t.Errorf("expected token from primary, got %s", resp.Token)
}
})
t.Run("logout delegates to primary", func(t *testing.T) {
if err := chain.Logout(ctx, LogoutRequest{}); err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
t.Run("login error from primary is returned", func(t *testing.T) {
failPrimary := &stubAuthenticator{err: fmt.Errorf("db down")}
chain2 := NewChainAuthenticator(failPrimary, secondary)
_, err := chain2.Login(ctx, LoginRequest{})
if err == nil {
t.Fatal("expected error from primary login failure")
}
})
}
+17 -2
View File
@@ -43,24 +43,39 @@ func (c *CompositeSecurityProvider) Login(ctx context.Context, req LoginRequest)
return c.auth.Login(ctx, req)
}
// LoginWithCookie delegates to the authenticator
func (c *CompositeSecurityProvider) LoginWithCookie(ctx context.Context, req LoginRequest, w http.ResponseWriter) (*LoginResponse, error) {
return c.auth.LoginWithCookie(ctx, req, w)
}
// Logout delegates to the authenticator
func (c *CompositeSecurityProvider) Logout(ctx context.Context, req LogoutRequest) error {
return c.auth.Logout(ctx, req)
}
// LogoutWithCookie delegates to the authenticator
func (c *CompositeSecurityProvider) LogoutWithCookie(ctx context.Context, req LogoutRequest, w http.ResponseWriter) error {
return c.auth.LogoutWithCookie(ctx, req, w)
}
// Authenticate delegates to the authenticator
func (c *CompositeSecurityProvider) Authenticate(r *http.Request) (*UserContext, error) {
return c.auth.Authenticate(r)
}
// SetAuthenticateCallback delegates to the authenticator
func (c *CompositeSecurityProvider) SetAuthenticateCallback(fn func(r *http.Request) (*UserContext, error)) {
c.auth.SetAuthenticateCallback(fn)
}
// GetColumnSecurity delegates to the column security provider
func (c *CompositeSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]ColumnSecurity, error) {
return c.colSec.GetColumnSecurity(ctx, userID, schema, table)
}
// GetRowSecurity delegates to the row security provider
func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
return c.rowSec.GetRowSecurity(ctx, userID, schema, table)
func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
return c.rowSec.GetRowSecurity(ctx, userRef, schema, table)
}
// Optional interface implementations (if wrapped providers support them)
+11 -1
View File
@@ -23,14 +23,24 @@ func (m *mockAuth) Login(ctx context.Context, req LoginRequest) (*LoginResponse,
return m.loginResp, m.loginErr
}
func (m *mockAuth) LoginWithCookie(ctx context.Context, req LoginRequest, _ http.ResponseWriter) (*LoginResponse, error) {
return m.Login(ctx, req)
}
func (m *mockAuth) Logout(ctx context.Context, req LogoutRequest) error {
return m.logoutErr
}
func (m *mockAuth) LogoutWithCookie(ctx context.Context, req LogoutRequest, _ http.ResponseWriter) error {
return m.Logout(ctx, req)
}
func (m *mockAuth) Authenticate(r *http.Request) (*UserContext, error) {
return m.authUser, m.authErr
}
func (m *mockAuth) SetAuthenticateCallback(_ func(r *http.Request) (*UserContext, error)) {}
// Optional interface implementations
func (m *mockAuth) RefreshToken(ctx context.Context, refreshToken string) (*LoginResponse, error) {
if !m.supportsRefresh {
@@ -69,7 +79,7 @@ type mockRowSec struct {
supportsCache bool
}
func (m *mockRowSec) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
func (m *mockRowSec) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
return m.rowSec, m.err
}
+53 -18
View File
@@ -13,6 +13,9 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP,
-- Program-level user mapping
program_user_id INTEGER DEFAULT 0,
program_user_table VARCHAR(255) DEFAULT '',
-- OAuth2 fields
remote_id VARCHAR(255), -- Provider's user ID (e.g., Google sub, GitHub id)
auth_provider VARCHAR(50), -- 'local', 'google', 'github', 'microsoft', 'facebook', etc.
@@ -99,6 +102,8 @@ DECLARE
v_expires_at TIMESTAMP;
v_ip_address TEXT;
v_user_agent TEXT;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN
-- Extract login request fields
v_username := p_request->>'username';
@@ -106,8 +111,8 @@ BEGIN
v_user_agent := p_request->'claims'->>'user_agent';
-- Validate user credentials
SELECT id, username, email, password, user_level, roles
INTO v_user_id, v_username, v_email, v_password_hash, v_user_level, v_roles
SELECT id, username, email, password, user_level, roles, program_user_id, program_user_table
INTO v_user_id, v_username, v_email, v_password_hash, v_user_level, v_roles, v_program_user_id, v_program_user_table
FROM users
WHERE username = v_username AND is_active = true;
@@ -146,7 +151,9 @@ BEGIN
'email', v_email,
'user_level', v_user_level,
'roles', string_to_array(COALESCE(v_roles, ''), ','),
'session_id', v_session_token
'session_id', v_session_token,
'program_user_id', COALESCE(v_program_user_id, 0),
'program_user_table', COALESCE(v_program_user_table, '')
),
'expires_in', 86400 -- 24 hours in seconds
);
@@ -195,12 +202,16 @@ DECLARE
v_user_level INTEGER;
v_roles TEXT;
v_session_id TEXT;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN
-- Query session and user data
SELECT
s.user_id, u.username, u.email, u.user_level, u.roles, s.session_token
s.user_id, u.username, u.email, u.user_level, u.roles, s.session_token,
u.program_user_id, u.program_user_table
INTO
v_user_id, v_username, v_email, v_user_level, v_roles, v_session_id
v_user_id, v_username, v_email, v_user_level, v_roles, v_session_id,
v_program_user_id, v_program_user_table
FROM user_sessions s
JOIN users u ON s.user_id = u.id
WHERE s.session_token = p_session_token
@@ -222,7 +233,9 @@ BEGIN
'email', v_email,
'user_level', v_user_level,
'session_id', v_session_id,
'roles', string_to_array(COALESCE(v_roles, ''), ',')
'roles', string_to_array(COALESCE(v_roles, ''), ','),
'program_user_id', COALESCE(v_program_user_id, 0),
'program_user_table', COALESCE(v_program_user_table, '')
);
END;
$$ LANGUAGE plpgsql;
@@ -266,10 +279,14 @@ DECLARE
v_expires_at TIMESTAMP;
v_ip_address TEXT;
v_user_agent TEXT;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN
-- Verify old session exists and is valid
SELECT s.user_id, u.username, u.email, u.user_level, u.roles, s.ip_address, s.user_agent
INTO v_user_id, v_username, v_email, v_user_level, v_roles, v_ip_address, v_user_agent
SELECT s.user_id, u.username, u.email, u.user_level, u.roles, s.ip_address, s.user_agent,
u.program_user_id, u.program_user_table
INTO v_user_id, v_username, v_email, v_user_level, v_roles, v_ip_address, v_user_agent,
v_program_user_id, v_program_user_table
FROM user_sessions s
JOIN users u ON s.user_id = u.id
WHERE s.session_token = p_old_session_token
@@ -302,7 +319,9 @@ BEGIN
'email', v_email,
'user_level', v_user_level,
'session_id', v_new_session_token,
'roles', string_to_array(COALESCE(v_roles, ''), ',')
'roles', string_to_array(COALESCE(v_roles, ''), ','),
'program_user_id', COALESCE(v_program_user_id, 0),
'program_user_table', COALESCE(v_program_user_table, '')
);
END;
$$ LANGUAGE plpgsql;
@@ -439,6 +458,8 @@ DECLARE
v_ip_address TEXT;
v_user_agent TEXT;
v_roles_array TEXT[];
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN
-- Extract registration request fields
v_username := p_request->>'username';
@@ -447,6 +468,8 @@ BEGIN
v_user_level := COALESCE((p_request->>'user_level')::integer, 0);
v_ip_address := p_request->'claims'->>'ip_address';
v_user_agent := p_request->'claims'->>'user_agent';
v_program_user_id := COALESCE((p_request->>'program_user_id')::integer, 0);
v_program_user_table := COALESCE(p_request->>'program_user_table', '');
-- Convert roles array from JSON to comma-separated string
SELECT array_to_string(ARRAY(SELECT jsonb_array_elements_text(p_request->'roles')), ',')
@@ -485,8 +508,8 @@ BEGIN
-- v_password := crypt(v_password, gen_salt('bf'));
-- Create new user
INSERT INTO users (username, email, password, user_level, roles, is_active, created_at, updated_at)
VALUES (v_username, v_email, v_password, v_user_level, v_roles, true, now(), now())
INSERT INTO users (username, email, password, user_level, roles, is_active, created_at, updated_at, program_user_id, program_user_table)
VALUES (v_username, v_email, v_password, v_user_level, v_roles, true, now(), now(), v_program_user_id, v_program_user_table)
RETURNING id INTO v_user_id;
-- Generate session token
@@ -512,7 +535,9 @@ BEGIN
'email', v_email,
'user_level', v_user_level,
'roles', string_to_array(COALESCE(v_roles, ''), ','),
'session_id', v_session_token
'session_id', v_session_token,
'program_user_id', v_program_user_id,
'program_user_table', v_program_user_table
),
'expires_in', 86400 -- 24 hours in seconds
);
@@ -671,12 +696,16 @@ DECLARE
v_user_level INTEGER;
v_roles TEXT;
v_expires_at TIMESTAMP;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN
-- Query session and user data from user_sessions table
SELECT
s.user_id, u.username, u.email, u.user_level, u.roles, s.expires_at
s.user_id, u.username, u.email, u.user_level, u.roles, s.expires_at,
u.program_user_id, u.program_user_table
INTO
v_user_id, v_username, v_email, v_user_level, v_roles, v_expires_at
v_user_id, v_username, v_email, v_user_level, v_roles, v_expires_at,
v_program_user_id, v_program_user_table
FROM user_sessions s
JOIN users u ON s.user_id = u.id
WHERE s.session_token = p_session_token
@@ -698,7 +727,9 @@ BEGIN
'email', v_email,
'user_level', v_user_level,
'session_id', p_session_token,
'roles', string_to_array(COALESCE(v_roles, ''), ',')
'roles', string_to_array(COALESCE(v_roles, ''), ','),
'program_user_id', COALESCE(v_program_user_id, 0),
'program_user_table', COALESCE(v_program_user_table, '')
);
END;
$$ LANGUAGE plpgsql;
@@ -815,10 +846,12 @@ DECLARE
v_email TEXT;
v_user_level INTEGER;
v_roles TEXT;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN
-- Query user data
SELECT username, email, user_level, roles
INTO v_username, v_email, v_user_level, v_roles
SELECT username, email, user_level, roles, program_user_id, program_user_table
INTO v_username, v_email, v_user_level, v_roles, v_program_user_id, v_program_user_table
FROM users
WHERE id = p_user_id
AND is_active = true;
@@ -837,7 +870,9 @@ BEGIN
'user_name', v_username,
'email', v_email,
'user_level', v_user_level,
'roles', string_to_array(COALESCE(v_roles, ''), ',')
'roles', string_to_array(COALESCE(v_roles, ''), ','),
'program_user_id', COALESCE(v_program_user_id, 0),
'program_user_table', COALESCE(v_program_user_table, '')
);
END;
$$ LANGUAGE plpgsql;
+141
View File
@@ -0,0 +1,141 @@
-- Portable schema for Direct-mode (non-stored-procedure) operation.
-- Plain CREATE TABLE statements only, no functions/triggers, using types
-- understood by SQLite (and portable to MySQL). Used by Direct-mode tests
-- and as a reference for deployments without Postgres.
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255),
user_level INTEGER DEFAULT 0,
roles VARCHAR(500),
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP,
updated_at TIMESTAMP,
last_login_at TIMESTAMP,
program_user_id INTEGER DEFAULT 0,
program_user_table VARCHAR(255) DEFAULT '',
remote_id VARCHAR(255),
auth_provider VARCHAR(50),
totp_secret VARCHAR(255),
totp_enabled BOOLEAN DEFAULT 0,
totp_enabled_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_token VARCHAR(500) NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP,
last_activity_at TIMESTAMP,
ip_address VARCHAR(45),
user_agent TEXT,
access_token TEXT,
refresh_token TEXT,
token_type VARCHAR(50) DEFAULT 'Bearer',
auth_provider VARCHAR(50)
);
CREATE INDEX IF NOT EXISTS idx_session_token ON user_sessions(session_token);
CREATE INDEX IF NOT EXISTS idx_user_id ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_expires_at ON user_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_refresh_token ON user_sessions(refresh_token);
CREATE TABLE IF NOT EXISTS token_blacklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token VARCHAR(500) NOT NULL,
user_id INTEGER,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_totp_backup_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
code_hash VARCHAR(64) NOT NULL,
used BOOLEAN DEFAULT 0,
used_at TIMESTAMP,
created_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_totp_user_id ON user_totp_backup_codes(user_id);
CREATE INDEX IF NOT EXISTS idx_totp_code_hash ON user_totp_backup_codes(code_hash);
CREATE TABLE IF NOT EXISTS user_passkey_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
credential_id TEXT NOT NULL UNIQUE, -- base64 text (Direct mode), not native bytea
public_key TEXT NOT NULL, -- base64 text
attestation_type VARCHAR(50) DEFAULT 'none',
aaguid TEXT, -- base64 text
sign_count INTEGER DEFAULT 0,
clone_warning BOOLEAN DEFAULT 0,
transports TEXT, -- JSON-encoded []string
backup_eligible BOOLEAN DEFAULT 0,
backup_state BOOLEAN DEFAULT 0,
name VARCHAR(255),
created_at TIMESTAMP,
last_used_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_passkey_user_id ON user_passkey_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_passkey_credential_id ON user_passkey_credentials(credential_id);
CREATE TABLE IF NOT EXISTS user_password_resets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP,
used BOOLEAN DEFAULT 0,
used_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS oauth_clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id VARCHAR(255) NOT NULL UNIQUE,
redirect_uris TEXT NOT NULL, -- JSON-encoded []string
client_name VARCHAR(255),
grant_types TEXT, -- JSON-encoded []string
allowed_scopes TEXT, -- JSON-encoded []string
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS oauth_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code VARCHAR(255) NOT NULL UNIQUE,
client_id VARCHAR(255) NOT NULL,
redirect_uri TEXT NOT NULL,
client_state TEXT,
code_challenge VARCHAR(255) NOT NULL,
code_challenge_method VARCHAR(10) DEFAULT 'S256',
session_token TEXT NOT NULL,
refresh_token TEXT,
scopes TEXT, -- JSON-encoded []string
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_codes_code ON oauth_codes(code);
CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_at);
CREATE TABLE IF NOT EXISTS user_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
key_type VARCHAR(50) NOT NULL,
key_hash VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL DEFAULT '',
scopes TEXT, -- JSON-encoded []string
meta TEXT, -- JSON-encoded map
expires_at TIMESTAMP,
created_at TIMESTAMP,
last_used_at TIMESTAMP,
is_active BOOLEAN DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_user_keys_user_id ON user_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_user_keys_key_hash ON user_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_user_keys_key_type ON user_keys(key_type);
+467
View File
@@ -0,0 +1,467 @@
package security
import (
"context"
"database/sql"
"encoding/base64"
"net/http"
"os"
"path/filepath"
"testing"
"time"
_ "github.com/mattn/go-sqlite3"
)
func futureTime() time.Time {
return time.Now().Add(1 * time.Hour)
}
// newDirectTestDB opens a fresh in-memory SQLite database and applies the
// portable Direct-mode schema (database_schema_sqlite.sql), giving every
// Direct-mode test a real, isolated database to exercise end-to-end.
func newDirectTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", "file::memory:?cache=shared")
if err != nil {
t.Fatalf("failed to open sqlite db: %v", err)
}
db.SetMaxOpenConns(1) // keep the shared in-memory db single-connection so state isn't lost
t.Cleanup(func() { _ = db.Close() })
schemaPath := filepath.Join("database_schema_sqlite.sql")
schema, err := os.ReadFile(schemaPath)
if err != nil {
t.Fatalf("failed to read schema: %v", err)
}
if _, err := db.Exec(string(schema)); err != nil {
t.Fatalf("failed to apply schema: %v", err)
}
return db
}
func authenticatedRequest(token string) *http.Request {
req, _ := http.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+token)
return req
}
func TestDirectMode_RegisterThenLogin(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
ctx := context.Background()
regResp, err := auth.Register(ctx, RegisterRequest{
Username: "alice",
Password: "hunter2",
Email: "alice@example.com",
Roles: []string{"user", "admin"},
Claims: map[string]any{"ip_address": "127.0.0.1", "user_agent": "test-agent"},
})
if err != nil {
t.Fatalf("Register() error = %v", err)
}
if regResp.Token == "" || regResp.User == nil {
t.Fatalf("Register() returned incomplete response: %+v", regResp)
}
if len(regResp.User.Roles) != 2 {
t.Errorf("expected 2 roles, got %v", regResp.User.Roles)
}
loginResp, err := auth.Login(ctx, LoginRequest{Username: "alice", Password: "hunter2"})
if err != nil {
t.Fatalf("Login() error = %v", err)
}
if loginResp.User.UserName != "alice" {
t.Errorf("Login() user = %q, want alice", loginResp.User.UserName)
}
// Duplicate registration should fail.
if _, err := auth.Register(ctx, RegisterRequest{Username: "alice", Password: "x", Email: "other@example.com"}); err == nil {
t.Error("expected duplicate username registration to fail")
}
}
func TestDirectMode_SessionLifecycle(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
ctx := context.Background()
loginResp, err := auth.Register(ctx, RegisterRequest{Username: "bob", Password: "p", Email: "bob@example.com"})
if err != nil {
t.Fatalf("Register() error = %v", err)
}
userCtx, err := auth.Authenticate(authenticatedRequest(loginResp.Token))
if err != nil {
t.Fatalf("Authenticate() error = %v", err)
}
if userCtx.UserName != "bob" {
t.Errorf("Authenticate() user = %q, want bob", userCtx.UserName)
}
refreshResp, err := auth.RefreshToken(ctx, loginResp.Token)
if err != nil {
t.Fatalf("RefreshToken() error = %v", err)
}
if refreshResp.Token == "" || refreshResp.Token == loginResp.Token {
t.Errorf("RefreshToken() should return a new token, got %q", refreshResp.Token)
}
if err := auth.Logout(ctx, LogoutRequest{Token: refreshResp.Token, UserID: refreshResp.User.UserID}); err != nil {
t.Fatalf("Logout() error = %v", err)
}
if _, err := auth.RefreshToken(ctx, refreshResp.Token); err == nil {
t.Error("expected RefreshToken() after logout to fail")
}
}
func TestDirectMode_PasswordReset(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
ctx := context.Background()
if _, err := auth.Register(ctx, RegisterRequest{Username: "carol", Password: "old", Email: "carol@example.com"}); err != nil {
t.Fatalf("Register() error = %v", err)
}
resetResp, err := auth.RequestPasswordReset(ctx, PasswordResetRequest{Email: "carol@example.com"})
if err != nil {
t.Fatalf("RequestPasswordReset() error = %v", err)
}
if resetResp.Token == "" {
t.Fatal("expected a non-empty reset token")
}
if err := auth.CompletePasswordReset(ctx, PasswordResetCompleteRequest{Token: resetResp.Token, NewPassword: "new"}); err != nil {
t.Fatalf("CompletePasswordReset() error = %v", err)
}
// Reusing the same token should now fail.
if err := auth.CompletePasswordReset(ctx, PasswordResetCompleteRequest{Token: resetResp.Token, NewPassword: "again"}); err == nil {
t.Error("expected reusing a consumed reset token to fail")
}
}
func TestDirectMode_JWTLoginAndLogout(t *testing.T) {
db := newDirectTestDB(t)
jwtAuth := NewJWTAuthenticator("secret", db).WithQueryMode(ModeDirect)
directAuth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
ctx := context.Background()
if _, err := directAuth.Register(ctx, RegisterRequest{Username: "dave", Password: "p", Email: "dave@example.com"}); err != nil {
t.Fatalf("Register() error = %v", err)
}
resp, err := jwtAuth.Login(ctx, LoginRequest{Username: "dave", Password: "p"})
if err != nil {
t.Fatalf("JWTAuthenticator.Login() error = %v", err)
}
if resp.User.UserName != "dave" {
t.Errorf("JWT login user = %q, want dave", resp.User.UserName)
}
if err := jwtAuth.Logout(ctx, LogoutRequest{Token: resp.Token, UserID: resp.User.UserID}); err != nil {
t.Fatalf("JWTAuthenticator.Logout() error = %v", err)
}
}
func TestDirectMode_TOTPEnableAndValidateBackupCode(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
ctx := context.Background()
regResp, err := auth.Register(ctx, RegisterRequest{Username: "erin", Password: "p", Email: "erin@example.com"})
if err != nil {
t.Fatalf("Register() error = %v", err)
}
userID := regResp.User.UserID
totp := NewDatabaseTwoFactorProvider(db, nil).WithQueryMode(ModeDirect)
if err := totp.Enable2FA(userID, "SECRET123", []string{"code1", "code2"}); err != nil {
t.Fatalf("Enable2FA() error = %v", err)
}
enabled, err := totp.Get2FAStatus(userID)
if err != nil {
t.Fatalf("Get2FAStatus() error = %v", err)
}
if !enabled {
t.Error("expected 2FA to be enabled")
}
secret, err := totp.Get2FASecret(userID)
if err != nil {
t.Fatalf("Get2FASecret() error = %v", err)
}
if secret != "SECRET123" {
t.Errorf("Get2FASecret() = %q, want SECRET123", secret)
}
valid, err := totp.ValidateBackupCode(userID, "code1")
if err != nil {
t.Fatalf("ValidateBackupCode() error = %v", err)
}
if !valid {
t.Error("expected backup code to be valid")
}
// Reusing the same backup code should fail.
if _, err := totp.ValidateBackupCode(userID, "code1"); err == nil {
t.Error("expected reusing a consumed backup code to fail")
}
if err := totp.Disable2FA(userID); err != nil {
t.Fatalf("Disable2FA() error = %v", err)
}
enabled, err = totp.Get2FAStatus(userID)
if err != nil {
t.Fatalf("Get2FAStatus() error = %v", err)
}
if enabled {
t.Error("expected 2FA to be disabled")
}
}
func TestDirectMode_PasskeyStoreAndFetch(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
ctx := context.Background()
regResp, err := auth.Register(ctx, RegisterRequest{Username: "frank", Password: "p", Email: "frank@example.com"})
if err != nil {
t.Fatalf("Register() error = %v", err)
}
userID := regResp.User.UserID
passkeys := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{
RPID: "example.com", RPName: "Example", RPOrigin: "https://example.com", QueryMode: ModeDirect,
})
cred, err := passkeys.CompleteRegistration(ctx, userID, PasskeyRegistrationResponse{
RawID: []byte("credential-1"),
Response: PasskeyAuthenticatorAttestationResponse{
AttestationObject: []byte("public-key-bytes"),
},
Transports: []string{"internal", "usb"},
}, nil)
if err != nil {
t.Fatalf("CompleteRegistration() error = %v", err)
}
if cred.UserID != userID {
t.Errorf("CompleteRegistration() UserID = %d, want %d", cred.UserID, userID)
}
creds, err := passkeys.GetCredentials(ctx, userID)
if err != nil {
t.Fatalf("GetCredentials() error = %v", err)
}
if len(creds) != 1 {
t.Fatalf("expected 1 credential, got %d", len(creds))
}
if string(creds[0].CredentialID) != "credential-1" {
t.Errorf("GetCredentials() CredentialID = %q, want credential-1", creds[0].CredentialID)
}
if len(creds[0].Transports) != 2 {
t.Errorf("expected 2 transports, got %v", creds[0].Transports)
}
credentialIDB64 := base64.StdEncoding.EncodeToString(creds[0].CredentialID)
if err := passkeys.UpdateCredentialName(ctx, userID, credentialIDB64, "My Phone"); err != nil {
t.Fatalf("UpdateCredentialName() error = %v", err)
}
updated, err := passkeys.GetCredentials(ctx, userID)
if err != nil {
t.Fatalf("GetCredentials() error = %v", err)
}
if updated[0].Name != "My Phone" {
t.Errorf("expected updated name 'My Phone', got %q", updated[0].Name)
}
if err := passkeys.DeleteCredential(ctx, userID, credentialIDB64); err != nil {
t.Fatalf("DeleteCredential() error = %v", err)
}
remaining, err := passkeys.GetCredentials(ctx, userID)
if err != nil {
t.Fatalf("GetCredentials() error = %v", err)
}
if len(remaining) != 0 {
t.Errorf("expected 0 credentials after delete, got %d", len(remaining))
}
}
func TestDirectMode_OAuthGetOrCreateUserAndSession(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
ctx := context.Background()
userCtx := &UserContext{UserName: "gina", Email: "gina@example.com", Roles: []string{"user"}}
userID, err := auth.oauth2GetOrCreateUser(ctx, userCtx, "google")
if err != nil {
t.Fatalf("oauth2GetOrCreateUser() error = %v", err)
}
if userID == 0 {
t.Fatal("expected non-zero user ID")
}
// Calling again with the same email should return the same user, not create a duplicate.
userID2, err := auth.oauth2GetOrCreateUser(ctx, userCtx, "google")
if err != nil {
t.Fatalf("oauth2GetOrCreateUser() second call error = %v", err)
}
if userID2 != userID {
t.Errorf("expected same user ID on repeat call, got %d and %d", userID, userID2)
}
}
func TestDirectMode_KeyStoreCreateAndValidate(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
ctx := context.Background()
regResp, err := auth.Register(ctx, RegisterRequest{Username: "henry", Password: "p", Email: "henry@example.com"})
if err != nil {
t.Fatalf("Register() error = %v", err)
}
ks := NewDatabaseKeyStore(db, DatabaseKeyStoreOptions{QueryMode: ModeDirect})
createResp, err := ks.CreateKey(ctx, CreateKeyRequest{
UserID: regResp.User.UserID,
KeyType: KeyTypeGenericAPI,
Name: "test key",
Scopes: []string{"read", "write"},
Meta: map[string]any{"note": "test"},
})
if err != nil {
t.Fatalf("CreateKey() error = %v", err)
}
if createResp.RawKey == "" {
t.Fatal("expected a non-empty raw key")
}
validated, err := ks.ValidateKey(ctx, createResp.RawKey, KeyTypeGenericAPI)
if err != nil {
t.Fatalf("ValidateKey() error = %v", err)
}
if validated.UserID != regResp.User.UserID {
t.Errorf("ValidateKey() UserID = %d, want %d", validated.UserID, regResp.User.UserID)
}
if len(validated.Scopes) != 2 {
t.Errorf("expected 2 scopes, got %v", validated.Scopes)
}
keys, err := ks.GetUserKeys(ctx, regResp.User.UserID, "")
if err != nil {
t.Fatalf("GetUserKeys() error = %v", err)
}
if len(keys) != 1 {
t.Fatalf("expected 1 key, got %d", len(keys))
}
if err := ks.DeleteKey(ctx, regResp.User.UserID, keys[0].ID); err != nil {
t.Fatalf("DeleteKey() error = %v", err)
}
remaining, err := ks.GetUserKeys(ctx, regResp.User.UserID, "")
if err != nil {
t.Fatalf("GetUserKeys() error = %v", err)
}
if len(remaining) != 0 {
t.Errorf("expected 0 keys after delete, got %d", len(remaining))
}
}
func TestDirectMode_OAuthServerClientAndCode(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
ctx := context.Background()
client := &OAuthServerClient{
ClientID: "client-1",
RedirectURIs: []string{"https://app.example.com/callback"},
ClientName: "Example App",
}
registered, err := auth.OAuthRegisterClient(ctx, client)
if err != nil {
t.Fatalf("OAuthRegisterClient() error = %v", err)
}
if len(registered.GrantTypes) != 1 || registered.GrantTypes[0] != "authorization_code" {
t.Errorf("expected default grant types, got %v", registered.GrantTypes)
}
if len(registered.AllowedScopes) != 3 {
t.Errorf("expected default allowed scopes, got %v", registered.AllowedScopes)
}
fetched, err := auth.OAuthGetClient(ctx, "client-1")
if err != nil {
t.Fatalf("OAuthGetClient() error = %v", err)
}
if fetched.ClientName != "Example App" {
t.Errorf("OAuthGetClient() ClientName = %q, want %q", fetched.ClientName, "Example App")
}
if len(fetched.RedirectURIs) != 1 || fetched.RedirectURIs[0] != "https://app.example.com/callback" {
t.Errorf("OAuthGetClient() RedirectURIs = %v", fetched.RedirectURIs)
}
regResp, err := auth.Register(ctx, RegisterRequest{Username: "ivan", Password: "p", Email: "ivan@example.com"})
if err != nil {
t.Fatalf("Register() error = %v", err)
}
if err := auth.OAuthSaveCode(ctx, &OAuthCode{
Code: "auth-code-2",
ClientID: "client-1",
RedirectURI: "https://app.example.com/callback",
CodeChallenge: "challenge",
SessionToken: regResp.Token,
Scopes: []string{"openid", "profile"},
ExpiresAt: futureTime(),
}); err != nil {
t.Fatalf("OAuthSaveCode() error = %v", err)
}
exchanged, err := auth.OAuthExchangeCode(ctx, "auth-code-2")
if err != nil {
t.Fatalf("OAuthExchangeCode() error = %v", err)
}
if exchanged.ClientID != "client-1" {
t.Errorf("OAuthExchangeCode() ClientID = %q, want client-1", exchanged.ClientID)
}
if len(exchanged.Scopes) != 2 {
t.Errorf("expected 2 scopes, got %v", exchanged.Scopes)
}
// Code should be single-use.
if _, err := auth.OAuthExchangeCode(ctx, "auth-code-2"); err == nil {
t.Error("expected re-exchanging a used code to fail")
}
info, err := auth.OAuthIntrospectToken(ctx, regResp.Token)
if err != nil {
t.Fatalf("OAuthIntrospectToken() error = %v", err)
}
if !info.Active {
t.Error("expected token to be active")
}
if info.Username != "ivan" {
t.Errorf("OAuthIntrospectToken() Username = %q, want ivan", info.Username)
}
if err := auth.OAuthRevokeToken(ctx, regResp.Token); err != nil {
t.Fatalf("OAuthRevokeToken() error = %v", err)
}
info, err = auth.OAuthIntrospectToken(ctx, regResp.Token)
if err != nil {
t.Fatalf("OAuthIntrospectToken() after revoke error = %v", err)
}
if info.Active {
t.Error("expected token to be inactive after revoke")
}
}
+26 -12
View File
@@ -14,6 +14,11 @@ import (
type SecurityContext interface {
GetContext() context.Context
GetUserID() (int, bool)
// GetUserRef returns an opaque user identifier for row security lookups.
// Unlike GetUserID, it is not required to be an integer: implementations backed by
// non-integer identifiers (e.g. UUIDs) can return a string, or the full
// *security.UserContext so a RowSecurityProvider can read JWT claims directly.
GetUserRef() (any, bool)
GetSchema() string
GetEntity() string
GetModel() interface{}
@@ -45,8 +50,13 @@ func loadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error
// return err
}
// Load row security rules using the provider
_, err = securityList.LoadRowSecurity(secCtx.GetContext(), userID, schema, tablename, false)
// Load row security rules using the provider. Row security uses the opaque
// user ref (not the int-only user ID) so non-integer user identifiers work.
userRef, refOK := secCtx.GetUserRef()
if !refOK {
userRef = userID
}
_, err = securityList.LoadRowSecurity(secCtx.GetContext(), userRef, schema, tablename, false)
if err != nil {
logger.Warn("Failed to load row security: %v", err)
// Don't fail the request if no security rules exist
@@ -58,25 +68,29 @@ func loadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error
// applyRowSecurity applies row-level security filters to the query (generic version)
func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error {
userID, ok := secCtx.GetUserID()
userRef, ok := secCtx.GetUserRef()
if !ok {
return nil // No user context, skip
userID, idOK := secCtx.GetUserID()
if !idOK {
return nil // No user context, skip
}
userRef = userID
}
schema := secCtx.GetSchema()
tablename := secCtx.GetEntity()
// Get row security template
rowSec, err := securityList.GetRowSecurityTemplate(userID, schema, tablename)
rowSec, err := securityList.GetRowSecurityTemplate(userRef, schema, tablename)
if err != nil {
// No row security defined, allow query to proceed
logger.Debug("No row security for %s.%s@%d: %v", schema, tablename, userID, err)
logger.Debug("No row security for %s.%s@%v: %v", schema, tablename, userRef, err)
return nil
}
// Check if user has a blocking rule
if rowSec.HasBlock {
logger.Warn("User %d blocked from accessing %s.%s", userID, schema, tablename)
logger.Warn("User %v blocked from accessing %s.%s", userRef, schema, tablename)
return fmt.Errorf("access denied to %s", tablename)
}
@@ -90,7 +104,7 @@ func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error
// Get primary key name from model
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Ptr {
if modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
@@ -112,8 +126,8 @@ func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error
// Generate the WHERE clause from template
whereClause := rowSec.GetTemplate(pkName, modelType)
logger.Info("Applying row security filter for user %d on %s.%s: %s",
userID, schema, tablename, whereClause)
logger.Info("Applying row security filter for user %v on %s.%s: %s",
userRef, schema, tablename, whereClause)
// Apply the WHERE clause to the query
query := secCtx.GetQuery()
@@ -155,13 +169,13 @@ func applyColumnSecurity(secCtx SecurityContext, securityList *SecurityList) err
// Get model type
modelType := reflect.TypeOf(model)
if modelType.Kind() == reflect.Ptr {
if modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
// Apply column security masking
resultValue := reflect.ValueOf(result)
if resultValue.Kind() == reflect.Ptr {
if resultValue.Kind() == reflect.Pointer {
resultValue = resultValue.Elem()
}
+4
View File
@@ -26,6 +26,10 @@ func (m *mockSecurityContext) GetUserID() (int, bool) {
return m.userID, m.hasUser
}
func (m *mockSecurityContext) GetUserRef() (any, bool) {
return m.userID, m.hasUser
}
func (m *mockSecurityContext) GetSchema() string {
return m.schema
}
+22 -2
View File
@@ -18,6 +18,8 @@ type UserContext struct {
Claims map[string]any `json:"claims"`
Meta map[string]any `json:"meta"` // Additional metadata that can hold any JSON-serializable values
TwoFactorEnabled bool `json:"two_factor_enabled"` // Indicates if 2FA is enabled for this user
ProgramUserID int `json:"program_user_id"`
ProgramUserTable string `json:"program_user_table"`
}
// LoginRequest contains credentials for login
@@ -83,12 +85,26 @@ type Authenticator interface {
// Login authenticates credentials and returns a token
Login(ctx context.Context, req LoginRequest) (*LoginResponse, error)
// LoginWithCookie authenticates credentials and, when cookie sessions are enabled,
// writes the session cookie to w. Implementations that do not support cookies
// should delegate to Login and ignore w.
LoginWithCookie(ctx context.Context, req LoginRequest, w http.ResponseWriter) (*LoginResponse, error)
// Logout invalidates a user's session/token
Logout(ctx context.Context, req LogoutRequest) error
// LogoutWithCookie invalidates a user's session/token and, when cookie sessions are
// enabled, clears the session cookie on w. Implementations that do not support cookies
// should delegate to Logout and ignore w.
LogoutWithCookie(ctx context.Context, req LogoutRequest, w http.ResponseWriter) error
// Authenticate extracts and validates user from HTTP request
// Returns UserContext or error if authentication fails
Authenticate(r *http.Request) (*UserContext, error)
// SetAuthenticateCallback registers a fallback called when primary authentication fails.
// If the callback returns a non-nil UserContext, that result is used instead of the error.
SetAuthenticateCallback(fn func(r *http.Request) (*UserContext, error))
}
// Registrable allows providers to support user registration
@@ -105,8 +121,12 @@ type ColumnSecurityProvider interface {
// RowSecurityProvider handles row-level security (filtering)
type RowSecurityProvider interface {
// GetRowSecurity loads row security rules for a user and entity
GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error)
// GetRowSecurity loads row security rules for a user and entity.
// userRef identifies the user and is opaque to the caller: it may be an int ID,
// a string/UUID, or the full *security.UserContext (see SecurityContext.GetUserRef),
// so providers backed by non-integer user identifiers (e.g. UUIDs) or that need
// access to JWT claims can implement row security without relying on a numeric ID.
GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error)
}
// SecurityProvider is the main interface combining all security concerns
+24 -2
View File
@@ -17,8 +17,9 @@ import (
// 2. Authorization: ApiKey <key>
// 3. X-API-Key header
type KeyStoreAuthenticator struct {
keyStore KeyStore
keyType KeyType // empty = accept any type
keyStore KeyStore
keyType KeyType // empty = accept any type
authenticateCallback func(r *http.Request) (*UserContext, error)
}
// NewKeyStoreAuthenticator creates a KeyStoreAuthenticator.
@@ -32,21 +33,42 @@ func (a *KeyStoreAuthenticator) Login(_ context.Context, _ LoginRequest) (*Login
return nil, fmt.Errorf("keystore authenticator does not support login")
}
// LoginWithCookie is not supported for keystore authentication.
func (a *KeyStoreAuthenticator) LoginWithCookie(_ context.Context, _ LoginRequest, _ http.ResponseWriter) (*LoginResponse, error) {
return nil, fmt.Errorf("keystore authenticator does not support login")
}
// Logout is not supported for keystore authentication.
func (a *KeyStoreAuthenticator) Logout(_ context.Context, _ LogoutRequest) error {
return nil
}
// LogoutWithCookie is not supported for keystore authentication.
func (a *KeyStoreAuthenticator) LogoutWithCookie(_ context.Context, _ LogoutRequest, _ http.ResponseWriter) error {
return nil
}
// SetAuthenticateCallback registers a fallback called when key authentication fails.
func (a *KeyStoreAuthenticator) SetAuthenticateCallback(fn func(r *http.Request) (*UserContext, error)) {
a.authenticateCallback = fn
}
// Authenticate extracts an API key from the request and validates it against the KeyStore.
// Returns a UserContext built from the matching UserKey on success.
func (a *KeyStoreAuthenticator) Authenticate(r *http.Request) (*UserContext, error) {
rawKey := extractAPIKey(r)
if rawKey == "" {
if a.authenticateCallback != nil {
return a.authenticateCallback(r)
}
return nil, fmt.Errorf("API key required (Authorization: Bearer/ApiKey <key> or X-API-Key header)")
}
userKey, err := a.keyStore.ValidateKey(r.Context(), rawKey, a.keyType)
if err != nil {
if a.authenticateCallback != nil {
return a.authenticateCallback(r)
}
return nil, fmt.Errorf("invalid API key: %w", err)
}
+52 -11
View File
@@ -23,6 +23,10 @@ type DatabaseKeyStoreOptions struct {
CacheTTL time.Duration
// SQLNames provides custom procedure names. If nil, uses DefaultKeyStoreSQLNames().
SQLNames *KeyStoreSQLNames
// TableNames provides custom table names for Direct mode. If nil, uses DefaultKeyStoreTableNames().
TableNames *KeyStoreTableNames
// QueryMode selects stored-procedure vs Direct-mode SQL. Default (zero value) is ModeAuto.
QueryMode QueryMode
// DBFactory is called to obtain a fresh *sql.DB when the existing connection is closed.
// If nil, reconnection is disabled.
DBFactory func() (*sql.DB, error)
@@ -38,12 +42,15 @@ type DatabaseKeyStoreOptions struct {
// cache TTL, a deleted key may continue to authenticate for up to CacheTTL
// (default 2 minutes) if the cache entry cannot be invalidated.
type DatabaseKeyStore struct {
db *sql.DB
dbMu sync.RWMutex
dbFactory func() (*sql.DB, error)
sqlNames *KeyStoreSQLNames
cache *cache.Cache
cacheTTL time.Duration
db *sql.DB
dbMu sync.RWMutex
dbFactory func() (*sql.DB, error)
sqlNames *KeyStoreSQLNames
tableNames *KeyStoreTableNames
queryMode QueryMode
capability *dbCapability
cache *cache.Cache
cacheTTL time.Duration
}
// NewDatabaseKeyStore creates a DatabaseKeyStore with optional configuration.
@@ -60,12 +67,16 @@ func NewDatabaseKeyStore(db *sql.DB, opts ...DatabaseKeyStoreOptions) *DatabaseK
c = cache.GetDefaultCache()
}
names := MergeKeyStoreSQLNames(DefaultKeyStoreSQLNames(), o.SQLNames)
tableNames := resolveKeyStoreTableNames(o.TableNames)
return &DatabaseKeyStore{
db: db,
dbFactory: o.DBFactory,
sqlNames: names,
cache: c,
cacheTTL: o.CacheTTL,
db: db,
dbFactory: o.DBFactory,
sqlNames: names,
tableNames: tableNames,
queryMode: o.QueryMode,
capability: newDBCapability(),
cache: c,
cacheTTL: o.CacheTTL,
}
}
@@ -86,6 +97,9 @@ func (ks *DatabaseKeyStore) reconnectDB() error {
ks.dbMu.Lock()
ks.db = newDB
ks.dbMu.Unlock()
if ks.capability != nil {
ks.capability.reset()
}
return nil
}
@@ -99,6 +113,14 @@ func (ks *DatabaseKeyStore) CreateKey(ctx context.Context, req CreateKeyRequest)
rawKey := base64.RawURLEncoding.EncodeToString(rawBytes)
hash := hashSHA256Hex(rawKey)
if !ks.capability.ShouldUseProcedure(ctx, ks.queryMode, ks.getDB(), ks.sqlNames.CreateKey) {
key, err := ks.createKeyDirect(ctx, req, hash)
if err != nil {
return nil, err
}
return &CreateKeyResponse{Key: *key, RawKey: rawKey}, nil
}
type createRequest struct {
UserID int `json:"user_id"`
KeyType KeyType `json:"key_type"`
@@ -145,6 +167,10 @@ func (ks *DatabaseKeyStore) CreateKey(ctx context.Context, req CreateKeyRequest)
// GetUserKeys returns all active, non-expired keys for the given user.
// Pass an empty KeyType to return all types.
func (ks *DatabaseKeyStore) GetUserKeys(ctx context.Context, userID int, keyType KeyType) ([]UserKey, error) {
if !ks.capability.ShouldUseProcedure(ctx, ks.queryMode, ks.getDB(), ks.sqlNames.GetUserKeys) {
return ks.getUserKeysDirect(ctx, userID, keyType)
}
var success bool
var errorMsg sql.NullString
var keysJSON sql.NullString
@@ -173,6 +199,10 @@ func (ks *DatabaseKeyStore) GetUserKeys(ctx context.Context, userID int, keyType
// The delete procedure returns the key_hash so no separate lookup is needed.
// Note: cache invalidation is best-effort; a cached entry may persist for up to CacheTTL.
func (ks *DatabaseKeyStore) DeleteKey(ctx context.Context, userID int, keyID int64) error {
if !ks.capability.ShouldUseProcedure(ctx, ks.queryMode, ks.getDB(), ks.sqlNames.DeleteKey) {
return ks.deleteKeyDirect(ctx, userID, keyID)
}
var success bool
var errorMsg sql.NullString
var keyHash sql.NullString
@@ -207,6 +237,17 @@ func (ks *DatabaseKeyStore) ValidateKey(ctx context.Context, rawKey string, keyT
}
}
if !ks.capability.ShouldUseProcedure(ctx, ks.queryMode, ks.getDB(), ks.sqlNames.ValidateKey) {
key, err := ks.validateKeyDirect(ctx, hash, keyType)
if err != nil {
return nil, err
}
if ks.cache != nil {
_ = ks.cache.Set(ctx, cacheKey, *key, ks.cacheTTL)
}
return key, nil
}
var success bool
var errorMsg sql.NullString
var keyJSON sql.NullString
+216
View File
@@ -0,0 +1,216 @@
package security
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
)
// Direct-mode implementations mirroring the resolvespec_keystore_* stored
// procedures in keystore_schema.sql using plain SQL against
// TableNames.UserKeys. meta/scopes are stored as JSON-encoded TEXT instead
// of Postgres JSONB.
func (ks *DatabaseKeyStore) createKeyDirect(ctx context.Context, req CreateKeyRequest, keyHash string) (*UserKey, error) {
scopesJSON, err := json.Marshal(req.Scopes)
if err != nil {
return nil, fmt.Errorf("failed to marshal scopes: %w", err)
}
var metaJSON []byte
if req.Meta != nil {
metaJSON, err = json.Marshal(req.Meta)
if err != nil {
return nil, fmt.Errorf("failed to marshal meta: %w", err)
}
}
now := time.Now()
var id int64
err = ks.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(
`INSERT INTO %s (user_id, key_type, key_hash, name, scopes, meta, expires_at, created_at, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
ks.tableNames.UserKeys))
res, err := db.ExecContext(ctx, query, req.UserID, string(req.KeyType), keyHash, req.Name, string(scopesJSON), nullableString(metaJSON), req.ExpiresAt, now, true)
if err != nil {
return err
}
id, err = res.LastInsertId()
return err
})
if err != nil {
return nil, fmt.Errorf("create key query failed: %w", err)
}
return &UserKey{
ID: id,
UserID: req.UserID,
KeyType: req.KeyType,
KeyHash: keyHash,
Name: req.Name,
Scopes: req.Scopes,
Meta: req.Meta,
ExpiresAt: req.ExpiresAt,
CreatedAt: now,
IsActive: true,
}, nil
}
func (ks *DatabaseKeyStore) runDBOpWithReconnect(run func(*sql.DB) error) error {
db := ks.getDB()
if db == nil {
return fmt.Errorf("database connection is nil")
}
err := run(db)
if isDBClosed(err) {
if reconnErr := ks.reconnectDB(); reconnErr == nil {
err = run(ks.getDB())
}
}
return err
}
func (ks *DatabaseKeyStore) getUserKeysDirect(ctx context.Context, userID int, keyType KeyType) ([]UserKey, error) {
keys := []UserKey{}
err := ks.runDBOpWithReconnect(func(db *sql.DB) error {
var query string
var args []any
if keyType == "" {
query = rewritePlaceholders(db, fmt.Sprintf(
`SELECT id, user_id, key_type, name, scopes, meta, expires_at, created_at, last_used_at, is_active
FROM %s WHERE user_id = ? AND is_active = ? AND (expires_at IS NULL OR expires_at > ?)`, ks.tableNames.UserKeys))
args = []any{userID, true, time.Now()}
} else {
query = rewritePlaceholders(db, fmt.Sprintf(
`SELECT id, user_id, key_type, name, scopes, meta, expires_at, created_at, last_used_at, is_active
FROM %s WHERE user_id = ? AND is_active = ? AND (expires_at IS NULL OR expires_at > ?) AND key_type = ?`, ks.tableNames.UserKeys))
args = []any{userID, true, time.Now(), string(keyType)}
}
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var k UserKey
var kt string
var scopesJSON, metaJSON sql.NullString
var expiresAt, lastUsedAt sql.NullTime
if err := rows.Scan(&k.ID, &k.UserID, &kt, &k.Name, &scopesJSON, &metaJSON, &expiresAt, &k.CreatedAt, &lastUsedAt, &k.IsActive); err != nil {
return err
}
k.KeyType = KeyType(kt)
if scopesJSON.Valid && scopesJSON.String != "" {
_ = json.Unmarshal([]byte(scopesJSON.String), &k.Scopes)
}
if metaJSON.Valid && metaJSON.String != "" {
_ = json.Unmarshal([]byte(metaJSON.String), &k.Meta)
}
if expiresAt.Valid {
t := expiresAt.Time
k.ExpiresAt = &t
}
if lastUsedAt.Valid {
t := lastUsedAt.Time
k.LastUsedAt = &t
}
keys = append(keys, k)
}
return rows.Err()
})
if err != nil {
return nil, fmt.Errorf("get user keys query failed: %w", err)
}
return keys, nil
}
func (ks *DatabaseKeyStore) deleteKeyDirect(ctx context.Context, userID int, keyID int64) error {
var keyHash string
err := ks.runDBOpWithReconnect(func(db *sql.DB) error {
selQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT key_hash FROM %s WHERE id = ? AND user_id = ? AND is_active = ?`, ks.tableNames.UserKeys))
if err := db.QueryRowContext(ctx, selQuery, keyID, userID, true).Scan(&keyHash); err != nil {
return err
}
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET is_active = ? WHERE id = ? AND user_id = ? AND is_active = ?`, ks.tableNames.UserKeys))
_, err := db.ExecContext(ctx, updQuery, false, keyID, userID, true)
return err
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errors.New("key not found or already deleted")
}
return fmt.Errorf("delete key query failed: %w", err)
}
if keyHash != "" && ks.cache != nil {
_ = ks.cache.Delete(ctx, keystoreCacheKey(keyHash))
}
return nil
}
func (ks *DatabaseKeyStore) validateKeyDirect(ctx context.Context, keyHash string, keyType KeyType) (*UserKey, error) {
var k UserKey
var kt string
var scopesJSON, metaJSON sql.NullString
var expiresAt, lastUsedAt sql.NullTime
err := ks.runDBOpWithReconnect(func(db *sql.DB) error {
var query string
var args []any
if keyType == "" {
query = rewritePlaceholders(db, fmt.Sprintf(
`SELECT id, user_id, key_type, name, scopes, meta, expires_at, created_at, is_active
FROM %s WHERE key_hash = ? AND is_active = ? AND (expires_at IS NULL OR expires_at > ?)`, ks.tableNames.UserKeys))
args = []any{keyHash, true, time.Now()}
} else {
query = rewritePlaceholders(db, fmt.Sprintf(
`SELECT id, user_id, key_type, name, scopes, meta, expires_at, created_at, is_active
FROM %s WHERE key_hash = ? AND is_active = ? AND (expires_at IS NULL OR expires_at > ?) AND key_type = ?`, ks.tableNames.UserKeys))
args = []any{keyHash, true, time.Now(), string(keyType)}
}
if err := db.QueryRowContext(ctx, query, args...).Scan(&k.ID, &k.UserID, &kt, &k.Name, &scopesJSON, &metaJSON, &expiresAt, &k.CreatedAt, &k.IsActive); err != nil {
return err
}
now := time.Now()
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_used_at = ? WHERE id = ?`, ks.tableNames.UserKeys))
_, err := db.ExecContext(ctx, updQuery, now, k.ID)
return err
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("invalid or expired key")
}
return nil, fmt.Errorf("validate key query failed: %w", err)
}
k.KeyType = KeyType(kt)
k.KeyHash = keyHash
if scopesJSON.Valid && scopesJSON.String != "" {
_ = json.Unmarshal([]byte(scopesJSON.String), &k.Scopes)
}
if metaJSON.Valid && metaJSON.String != "" {
_ = json.Unmarshal([]byte(metaJSON.String), &k.Meta)
}
if expiresAt.Valid {
t := expiresAt.Time
k.ExpiresAt = &t
}
_ = lastUsedAt
now := time.Now()
k.LastUsedAt = &now
return &k, nil
}
func nullableString(b []byte) any {
if b == nil {
return nil
}
return string(b)
}
+44
View File
@@ -0,0 +1,44 @@
package security
import "fmt"
// KeyStoreTableNames holds the configurable table name used by DatabaseKeyStore
// in Direct mode. Use DefaultKeyStoreTableNames() for defaults and
// MergeKeyStoreTableNames() for partial overrides.
type KeyStoreTableNames struct {
UserKeys string // default: "user_keys"
}
// DefaultKeyStoreTableNames returns a KeyStoreTableNames with default table names.
func DefaultKeyStoreTableNames() *KeyStoreTableNames {
return &KeyStoreTableNames{
UserKeys: "user_keys",
}
}
// MergeKeyStoreTableNames returns a copy of base with any non-empty fields from override applied.
// If override is nil, a copy of base is returned.
func MergeKeyStoreTableNames(base, override *KeyStoreTableNames) *KeyStoreTableNames {
if override == nil {
copied := *base
return &copied
}
merged := *base
if override.UserKeys != "" {
merged.UserKeys = override.UserKeys
}
return &merged
}
// ValidateKeyStoreTableNames checks that all non-empty table names are valid SQL identifiers.
func ValidateKeyStoreTableNames(names *KeyStoreTableNames) error {
if names.UserKeys != "" && !validSQLIdentifier.MatchString(names.UserKeys) {
return fmt.Errorf("KeyStoreTableNames.UserKeys contains invalid characters: %q", names.UserKeys)
}
return nil
}
// resolveKeyStoreTableNames merges an optional override with defaults.
func resolveKeyStoreTableNames(override *KeyStoreTableNames) *KeyStoreTableNames {
return MergeKeyStoreTableNames(DefaultKeyStoreTableNames(), override)
}
+15 -83
View File
@@ -226,6 +226,10 @@ func (a *DatabaseAuthenticator) getOAuth2Provider(providerName string) (*OAuth2P
// oauth2GetOrCreateUser finds or creates a user based on OAuth2 info using stored procedure
func (a *DatabaseAuthenticator) oauth2GetOrCreateUser(ctx context.Context, userCtx *UserContext, providerName string) (int, error) {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthGetOrCreateUser) {
return a.oauth2GetOrCreateUserDirect(ctx, userCtx, providerName)
}
userData := map[string]interface{}{
"username": userCtx.UserName,
"email": userCtx.Email,
@@ -269,6 +273,10 @@ func (a *DatabaseAuthenticator) oauth2GetOrCreateUser(ctx context.Context, userC
// oauth2CreateSession creates a new OAuth2 session using stored procedure
func (a *DatabaseAuthenticator) oauth2CreateSession(ctx context.Context, sessionToken string, userID int, token *oauth2.Token, expiresAt time.Time, providerName string) error {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthCreateSession) {
return a.oauth2CreateSessionDirect(ctx, sessionToken, userID, token, expiresAt, providerName)
}
sessionData := map[string]interface{}{
"session_token": sessionToken,
"user_id": userID,
@@ -381,35 +389,9 @@ func (a *DatabaseAuthenticator) OAuth2RefreshToken(ctx context.Context, refreshT
}
// Get session by refresh token from database
var success bool
var errMsg *string
var sessionData []byte
err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
SELECT p_success, p_error, p_data::text
FROM %s($1)
`, a.sqlNames.OAuthGetRefreshToken), refreshToken).Scan(&success, &errMsg, &sessionData)
session, err := a.oauthGetByRefreshToken(ctx, refreshToken)
if err != nil {
return nil, fmt.Errorf("failed to get session by refresh token: %w", err)
}
if !success {
if errMsg != nil {
return nil, fmt.Errorf("%s", *errMsg)
}
return nil, fmt.Errorf("invalid or expired refresh token")
}
// Parse session data
var session struct {
UserID int `json:"user_id"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Expiry time.Time `json:"expiry"`
}
if err := json.Unmarshal(sessionData, &session); err != nil {
return nil, fmt.Errorf("failed to parse session data: %w", err)
return nil, err
}
// Create oauth2.Token from stored data
@@ -434,64 +416,14 @@ func (a *DatabaseAuthenticator) OAuth2RefreshToken(ctx context.Context, refreshT
}
// Update session in database with new tokens
updateData := map[string]interface{}{
"user_id": session.UserID,
"old_refresh_token": refreshToken,
"new_session_token": newSessionToken,
"new_access_token": newToken.AccessToken,
"new_refresh_token": newToken.RefreshToken,
"expires_at": newToken.Expiry,
}
updateJSON, err := json.Marshal(updateData)
if err != nil {
return nil, fmt.Errorf("failed to marshal update data: %w", err)
}
var updateSuccess bool
var updateErrMsg *string
err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
SELECT p_success, p_error
FROM %s($1::jsonb)
`, a.sqlNames.OAuthUpdateRefreshToken), updateJSON).Scan(&updateSuccess, &updateErrMsg)
if err != nil {
return nil, fmt.Errorf("failed to update session: %w", err)
}
if !updateSuccess {
if updateErrMsg != nil {
return nil, fmt.Errorf("%s", *updateErrMsg)
}
return nil, fmt.Errorf("failed to update session")
if err := a.oauthUpdateRefreshTokenRecord(ctx, session.UserID, refreshToken, newSessionToken, newToken.AccessToken, newToken.RefreshToken, newToken.Expiry); err != nil {
return nil, err
}
// Get user data
var userSuccess bool
var userErrMsg *string
var userData []byte
err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
SELECT p_success, p_error, p_data::text
FROM %s($1)
`, a.sqlNames.OAuthGetUser), session.UserID).Scan(&userSuccess, &userErrMsg, &userData)
userCtx, err := a.oauthGetUserByID(ctx, session.UserID)
if err != nil {
return nil, fmt.Errorf("failed to get user data: %w", err)
}
if !userSuccess {
if userErrMsg != nil {
return nil, fmt.Errorf("%s", *userErrMsg)
}
return nil, fmt.Errorf("failed to get user data")
}
// Parse user context
var userCtx UserContext
if err := json.Unmarshal(userData, &userCtx); err != nil {
return nil, fmt.Errorf("failed to parse user context: %w", err)
return nil, err
}
userCtx.SessionID = newSessionToken
@@ -499,7 +431,7 @@ func (a *DatabaseAuthenticator) OAuth2RefreshToken(ctx context.Context, refreshT
return &LoginResponse{
Token: newSessionToken,
RefreshToken: newToken.RefreshToken,
User: &userCtx,
User: userCtx,
ExpiresIn: int64(time.Until(newToken.Expiry).Seconds()),
}, nil
}
+242
View File
@@ -0,0 +1,242 @@
package security
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/oauth2"
)
// oauthRefreshSession is the session data needed to refresh an OAuth2 token,
// shared by both the stored-procedure and Direct-mode code paths.
type oauthRefreshSession struct {
UserID int `json:"user_id"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Expiry time.Time `json:"expiry"`
}
// oauth2GetOrCreateUserDirect mirrors resolvespec_oauth_getorcreateuser.
func (a *DatabaseAuthenticator) oauth2GetOrCreateUserDirect(ctx context.Context, userCtx *UserContext, providerName string) (int, error) {
rolesStr := strings.Join(userCtx.Roles, ",")
var userID int
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE email = ?`, a.tableNames.Users))
err := db.QueryRowContext(ctx, query, userCtx.Email).Scan(&userID)
if err == nil {
now := time.Now()
updQuery := rewritePlaceholders(db, fmt.Sprintf(
`UPDATE %s SET last_login_at = ?, updated_at = ?, remote_id = COALESCE(remote_id, ?), auth_provider = COALESCE(auth_provider, ?) WHERE id = ?`,
a.tableNames.Users))
_, err := db.ExecContext(ctx, updQuery, now, now, userCtx.RemoteID, providerName, userID)
return err
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
now := time.Now()
insQuery := rewritePlaceholders(db, fmt.Sprintf(
`INSERT INTO %s (username, email, password, user_level, roles, is_active, created_at, updated_at, last_login_at, remote_id, auth_provider) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.tableNames.Users))
res, err := db.ExecContext(ctx, insQuery, userCtx.UserName, userCtx.Email, userCtx.UserLevel, rolesStr, true, now, now, now, userCtx.RemoteID, providerName)
if err != nil {
return err
}
id, err := res.LastInsertId()
if err != nil {
return err
}
userID = int(id)
return nil
})
if err != nil {
return 0, fmt.Errorf("failed to get or create user: %w", err)
}
return userID, nil
}
// oauth2CreateSessionDirect mirrors resolvespec_oauth_createsession (insert-or-update by session_token).
func (a *DatabaseAuthenticator) oauth2CreateSessionDirect(ctx context.Context, sessionToken string, userID int, token *oauth2.Token, expiresAt time.Time, providerName string) error {
return a.runDBOpWithReconnect(func(db *sql.DB) error {
var exists int
checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE session_token = ?`, a.tableNames.UserSessions))
err := db.QueryRowContext(ctx, checkQuery, sessionToken).Scan(&exists)
now := time.Now()
if err == nil {
updQuery := rewritePlaceholders(db, fmt.Sprintf(
`UPDATE %s SET access_token = ?, refresh_token = ?, token_type = ?, expires_at = ?, last_activity_at = ? WHERE session_token = ?`,
a.tableNames.UserSessions))
_, err := db.ExecContext(ctx, updQuery, token.AccessToken, token.RefreshToken, token.TokenType, expiresAt, now, sessionToken)
return err
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
insQuery := rewritePlaceholders(db, fmt.Sprintf(
`INSERT INTO %s (session_token, user_id, expires_at, created_at, last_activity_at, access_token, refresh_token, token_type, auth_provider) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.tableNames.UserSessions))
_, err = db.ExecContext(ctx, insQuery, sessionToken, userID, expiresAt, now, now, token.AccessToken, token.RefreshToken, token.TokenType, providerName)
return err
})
}
// oauthGetByRefreshToken retrieves the session for a refresh token, dispatching between
// the resolvespec_oauth_getrefreshtoken stored procedure and Direct-mode SQL.
func (a *DatabaseAuthenticator) oauthGetByRefreshToken(ctx context.Context, refreshToken string) (*oauthRefreshSession, error) {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthGetRefreshToken) {
var session oauthRefreshSession
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(
`SELECT user_id, access_token, token_type, expires_at FROM %s WHERE refresh_token = ? AND expires_at > ?`,
a.tableNames.UserSessions))
return db.QueryRowContext(ctx, query, refreshToken, time.Now()).Scan(&session.UserID, &session.AccessToken, &session.TokenType, &session.Expiry)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("refresh token not found or expired")
}
return nil, fmt.Errorf("failed to get session by refresh token: %w", err)
}
return &session, nil
}
var success bool
var errMsg *string
var sessionData []byte
err := a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
SELECT p_success, p_error, p_data::text
FROM %s($1)
`, a.sqlNames.OAuthGetRefreshToken), refreshToken).Scan(&success, &errMsg, &sessionData)
if err != nil {
return nil, fmt.Errorf("failed to get session by refresh token: %w", err)
}
if !success {
if errMsg != nil {
return nil, fmt.Errorf("%s", *errMsg)
}
return nil, fmt.Errorf("invalid or expired refresh token")
}
var session oauthRefreshSession
if err := json.Unmarshal(sessionData, &session); err != nil {
return nil, fmt.Errorf("failed to parse session data: %w", err)
}
return &session, nil
}
// oauthUpdateRefreshTokenRecord updates a session with new tokens, dispatching between
// the resolvespec_oauth_updaterefreshtoken stored procedure and Direct-mode SQL.
func (a *DatabaseAuthenticator) oauthUpdateRefreshTokenRecord(ctx context.Context, userID int, oldRefreshToken, newSessionToken, newAccessToken, newRefreshToken string, expiresAt time.Time) error {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthUpdateRefreshToken) {
var rows int64
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(
`UPDATE %s SET session_token = ?, access_token = ?, refresh_token = ?, expires_at = ?, last_activity_at = ? WHERE user_id = ? AND refresh_token = ?`,
a.tableNames.UserSessions))
res, err := db.ExecContext(ctx, query, newSessionToken, newAccessToken, newRefreshToken, expiresAt, time.Now(), userID, oldRefreshToken)
if err != nil {
return err
}
rows, err = res.RowsAffected()
return err
})
if err != nil {
return fmt.Errorf("failed to update session: %w", err)
}
if rows == 0 {
return fmt.Errorf("session not found")
}
return nil
}
updateData := map[string]interface{}{
"user_id": userID,
"old_refresh_token": oldRefreshToken,
"new_session_token": newSessionToken,
"new_access_token": newAccessToken,
"new_refresh_token": newRefreshToken,
"expires_at": expiresAt,
}
updateJSON, err := json.Marshal(updateData)
if err != nil {
return fmt.Errorf("failed to marshal update data: %w", err)
}
var updateSuccess bool
var updateErrMsg *string
err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
SELECT p_success, p_error
FROM %s($1::jsonb)
`, a.sqlNames.OAuthUpdateRefreshToken), updateJSON).Scan(&updateSuccess, &updateErrMsg)
if err != nil {
return fmt.Errorf("failed to update session: %w", err)
}
if !updateSuccess {
if updateErrMsg != nil {
return fmt.Errorf("%s", *updateErrMsg)
}
return fmt.Errorf("failed to update session")
}
return nil
}
// oauthGetUserByID retrieves user data by ID, dispatching between the
// resolvespec_oauth_getuser stored procedure and Direct-mode SQL.
func (a *DatabaseAuthenticator) oauthGetUserByID(ctx context.Context, userID int) (*UserContext, error) {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthGetUser) {
var username, email, roles, programUserTable sql.NullString
var userLevel, programUserID sql.NullInt64
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(
`SELECT username, email, user_level, roles, program_user_id, program_user_table FROM %s WHERE id = ? AND is_active = ?`,
a.tableNames.Users))
return db.QueryRowContext(ctx, query, userID, true).Scan(&username, &email, &userLevel, &roles, &programUserID, &programUserTable)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user not found")
}
return nil, fmt.Errorf("failed to get user data: %w", err)
}
return &UserContext{
UserID: userID,
UserName: username.String,
Email: email.String,
UserLevel: int(userLevel.Int64),
Roles: parseRoles(roles.String),
ProgramUserID: int(programUserID.Int64),
ProgramUserTable: programUserTable.String,
}, nil
}
var userSuccess bool
var userErrMsg *string
var userData []byte
err := a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
SELECT p_success, p_error, p_data::text
FROM %s($1)
`, a.sqlNames.OAuthGetUser), userID).Scan(&userSuccess, &userErrMsg, &userData)
if err != nil {
return nil, fmt.Errorf("failed to get user data: %w", err)
}
if !userSuccess {
if userErrMsg != nil {
return nil, fmt.Errorf("%s", *userErrMsg)
}
return nil, fmt.Errorf("failed to get user data")
}
var userCtx UserContext
if err := json.Unmarshal(userData, &userCtx); err != nil {
return nil, fmt.Errorf("failed to parse user context: %w", err)
}
return &userCtx, nil
}
+24
View File
@@ -44,6 +44,10 @@ type OAuthTokenInfo struct {
// OAuthRegisterClient persists an OAuth2 client registration.
func (a *DatabaseAuthenticator) OAuthRegisterClient(ctx context.Context, client *OAuthServerClient) (*OAuthServerClient, error) {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthRegisterClient) {
return a.oauthRegisterClientDirect(ctx, client)
}
input, err := json.Marshal(client)
if err != nil {
return nil, fmt.Errorf("failed to marshal client: %w", err)
@@ -76,6 +80,10 @@ func (a *DatabaseAuthenticator) OAuthRegisterClient(ctx context.Context, client
// OAuthGetClient retrieves a registered client by ID.
func (a *DatabaseAuthenticator) OAuthGetClient(ctx context.Context, clientID string) (*OAuthServerClient, error) {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthGetClient) {
return a.oauthGetClientDirect(ctx, clientID)
}
var success bool
var errMsg *string
var data []byte
@@ -103,6 +111,10 @@ func (a *DatabaseAuthenticator) OAuthGetClient(ctx context.Context, clientID str
// OAuthSaveCode persists an authorization code.
func (a *DatabaseAuthenticator) OAuthSaveCode(ctx context.Context, code *OAuthCode) error {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthSaveCode) {
return a.oauthSaveCodeDirect(ctx, code)
}
input, err := json.Marshal(code)
if err != nil {
return fmt.Errorf("failed to marshal code: %w", err)
@@ -129,6 +141,10 @@ func (a *DatabaseAuthenticator) OAuthSaveCode(ctx context.Context, code *OAuthCo
// OAuthExchangeCode retrieves and deletes an authorization code (single use).
func (a *DatabaseAuthenticator) OAuthExchangeCode(ctx context.Context, code string) (*OAuthCode, error) {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthExchangeCode) {
return a.oauthExchangeCodeDirect(ctx, code)
}
var success bool
var errMsg *string
var data []byte
@@ -157,6 +173,10 @@ func (a *DatabaseAuthenticator) OAuthExchangeCode(ctx context.Context, code stri
// OAuthIntrospectToken validates a token and returns its metadata (RFC 7662).
func (a *DatabaseAuthenticator) OAuthIntrospectToken(ctx context.Context, token string) (*OAuthTokenInfo, error) {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthIntrospect) {
return a.oauthIntrospectTokenDirect(ctx, token)
}
var success bool
var errMsg *string
var data []byte
@@ -184,6 +204,10 @@ func (a *DatabaseAuthenticator) OAuthIntrospectToken(ctx context.Context, token
// OAuthRevokeToken revokes a token by deleting the session (RFC 7009).
func (a *DatabaseAuthenticator) OAuthRevokeToken(ctx context.Context, token string) error {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthRevoke) {
return a.oauthRevokeTokenDirect(ctx, token)
}
var success bool
var errMsg *string
+188
View File
@@ -0,0 +1,188 @@
package security
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
)
// Direct-mode implementations mirroring the OAuth2 server stored procedures
// (resolvespec_oauth_register_client, etc.) in database_schema.sql, using
// plain SQL against TableNames.OAuthClients / TableNames.OAuthCodes.
// Array columns (redirect_uris, grant_types, allowed_scopes, scopes) are
// JSON-encoded TEXT instead of native Postgres arrays.
func (a *DatabaseAuthenticator) oauthRegisterClientDirect(ctx context.Context, client *OAuthServerClient) (*OAuthServerClient, error) {
grantTypes := client.GrantTypes
if len(grantTypes) == 0 {
grantTypes = []string{"authorization_code"}
}
allowedScopes := client.AllowedScopes
if len(allowedScopes) == 0 {
allowedScopes = []string{"openid", "profile", "email"}
}
redirectURIsJSON, err := json.Marshal(client.RedirectURIs)
if err != nil {
return nil, fmt.Errorf("failed to marshal redirect_uris: %w", err)
}
grantTypesJSON, err := json.Marshal(grantTypes)
if err != nil {
return nil, fmt.Errorf("failed to marshal grant_types: %w", err)
}
allowedScopesJSON, err := json.Marshal(allowedScopes)
if err != nil {
return nil, fmt.Errorf("failed to marshal allowed_scopes: %w", err)
}
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 (?, ?, ?, ?, ?, ?, ?)`,
a.tableNames.OAuthClients))
_, err := db.ExecContext(ctx, query, client.ClientID, string(redirectURIsJSON), client.ClientName, string(grantTypesJSON), string(allowedScopesJSON), true, time.Now())
return err
})
if err != nil {
return nil, fmt.Errorf("failed to register client: %w", err)
}
return &OAuthServerClient{
ClientID: client.ClientID,
RedirectURIs: client.RedirectURIs,
ClientName: client.ClientName,
GrantTypes: grantTypes,
AllowedScopes: allowedScopes,
}, nil
}
func (a *DatabaseAuthenticator) oauthGetClientDirect(ctx context.Context, clientID string) (*OAuthServerClient, error) {
var redirectURIsJSON, grantTypesJSON, allowedScopesJSON sql.NullString
var clientName 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 = ?`,
a.tableNames.OAuthClients))
return db.QueryRowContext(ctx, query, clientID, true).Scan(&redirectURIsJSON, &clientName, &grantTypesJSON, &allowedScopesJSON)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("client not found")
}
return nil, fmt.Errorf("failed to get client: %w", err)
}
result := &OAuthServerClient{ClientID: clientID, ClientName: clientName.String}
if redirectURIsJSON.Valid {
_ = json.Unmarshal([]byte(redirectURIsJSON.String), &result.RedirectURIs)
}
if grantTypesJSON.Valid {
_ = json.Unmarshal([]byte(grantTypesJSON.String), &result.GrantTypes)
}
if allowedScopesJSON.Valid {
_ = json.Unmarshal([]byte(allowedScopesJSON.String), &result.AllowedScopes)
}
return result, nil
}
func (a *DatabaseAuthenticator) oauthSaveCodeDirect(ctx context.Context, code *OAuthCode) error {
scopesJSON, err := json.Marshal(code.Scopes)
if err != nil {
return fmt.Errorf("failed to marshal scopes: %w", err)
}
method := code.CodeChallengeMethod
if method == "" {
method = "S256"
}
return a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(
`INSERT INTO %s (code, client_id, redirect_uri, client_state, code_challenge, code_challenge_method, session_token, refresh_token, scopes, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, a.tableNames.OAuthCodes))
_, err := db.ExecContext(ctx, query, code.Code, code.ClientID, code.RedirectURI, code.ClientState, code.CodeChallenge,
method, code.SessionToken, code.RefreshToken, string(scopesJSON), code.ExpiresAt, time.Now())
return err
})
}
func (a *DatabaseAuthenticator) oauthExchangeCodeDirect(ctx context.Context, code string) (*OAuthCode, error) {
var result OAuthCode
var clientState, refreshToken sql.NullString
var scopesJSON sql.NullString
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(
`SELECT client_id, redirect_uri, client_state, code_challenge, code_challenge_method, session_token, refresh_token, scopes
FROM %s WHERE code = ? AND expires_at > ?`, a.tableNames.OAuthCodes))
err := db.QueryRowContext(ctx, query, code, time.Now()).Scan(
&result.ClientID, &result.RedirectURI, &clientState, &result.CodeChallenge, &result.CodeChallengeMethod, &result.SessionToken, &refreshToken, &scopesJSON)
if err != nil {
return err
}
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE code = ?`, a.tableNames.OAuthCodes))
_, err = db.ExecContext(ctx, delQuery, code)
return err
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("invalid or expired code")
}
return nil, fmt.Errorf("failed to exchange code: %w", err)
}
result.Code = code
result.ClientState = clientState.String
result.RefreshToken = refreshToken.String
if scopesJSON.Valid {
_ = json.Unmarshal([]byte(scopesJSON.String), &result.Scopes)
}
return &result, nil
}
func (a *DatabaseAuthenticator) oauthIntrospectTokenDirect(ctx context.Context, token string) (*OAuthTokenInfo, error) {
var info OAuthTokenInfo
var roles sql.NullString
var exp, iat sql.NullTime
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(
`SELECT u.id, u.username, u.email, u.user_level, u.roles, s.expires_at, s.created_at
FROM %s s JOIN %s u ON u.id = s.user_id
WHERE s.session_token = ? AND s.expires_at > ? AND u.is_active = ?`,
a.tableNames.UserSessions, a.tableNames.Users))
var userID int
err := db.QueryRowContext(ctx, query, token, time.Now(), true).Scan(&userID, &info.Username, &info.Email, &info.UserLevel, &roles, &exp, &iat)
if err != nil {
return err
}
info.Sub = fmt.Sprintf("%d", userID)
return nil
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return &OAuthTokenInfo{Active: false}, nil
}
return nil, fmt.Errorf("failed to introspect token: %w", err)
}
info.Active = true
info.Roles = parseRoles(roles.String)
if exp.Valid {
info.Exp = exp.Time.Unix()
}
if iat.Valid {
info.Iat = iat.Time.Unix()
}
return &info, nil
}
func (a *DatabaseAuthenticator) oauthRevokeTokenDirect(ctx context.Context, token string) error {
return a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE session_token = ?`, a.tableNames.UserSessions))
_, err := db.ExecContext(ctx, query, token)
return err
})
}
+132 -39
View File
@@ -14,14 +14,17 @@ import (
// DatabasePasskeyProvider implements PasskeyProvider using database storage
// Procedure names are configurable via SQLNames (see DefaultSQLNames for defaults)
type DatabasePasskeyProvider struct {
db *sql.DB
dbMu sync.RWMutex
dbFactory func() (*sql.DB, error)
rpID string // Relying Party ID (domain)
rpName string // Relying Party display name
rpOrigin string // Expected origin for WebAuthn
timeout int64 // Timeout in milliseconds (default: 60000)
sqlNames *SQLNames
db *sql.DB
dbMu sync.RWMutex
dbFactory func() (*sql.DB, error)
rpID string // Relying Party ID (domain)
rpName string // Relying Party display name
rpOrigin string // Expected origin for WebAuthn
timeout int64 // Timeout in milliseconds (default: 60000)
sqlNames *SQLNames
tableNames *TableNames
queryMode QueryMode
capability *dbCapability
}
// DatabasePasskeyProviderOptions configures the passkey provider
@@ -36,6 +39,10 @@ type DatabasePasskeyProviderOptions struct {
Timeout int64
// SQLNames provides custom SQL procedure/function names. If nil, uses DefaultSQLNames().
SQLNames *SQLNames
// TableNames provides custom table names for Direct mode. If nil, uses DefaultTableNames().
TableNames *TableNames
// QueryMode selects stored-procedure vs Direct-mode SQL. Default (zero value) is ModeAuto.
QueryMode QueryMode
// DBFactory is called to obtain a fresh *sql.DB when the existing connection is closed.
// If nil, reconnection is disabled.
DBFactory func() (*sql.DB, error)
@@ -48,15 +55,19 @@ func NewDatabasePasskeyProvider(db *sql.DB, opts DatabasePasskeyProviderOptions)
}
sqlNames := MergeSQLNames(DefaultSQLNames(), opts.SQLNames)
tableNames := resolveTableNames(opts.TableNames)
return &DatabasePasskeyProvider{
db: db,
dbFactory: opts.DBFactory,
rpID: opts.RPID,
rpName: opts.RPName,
rpOrigin: opts.RPOrigin,
timeout: opts.Timeout,
sqlNames: sqlNames,
db: db,
dbFactory: opts.DBFactory,
rpID: opts.RPID,
rpName: opts.RPName,
rpOrigin: opts.RPOrigin,
timeout: opts.Timeout,
sqlNames: sqlNames,
tableNames: tableNames,
queryMode: opts.QueryMode,
capability: newDBCapability(),
}
}
@@ -77,9 +88,26 @@ func (p *DatabasePasskeyProvider) reconnectDB() error {
p.dbMu.Lock()
p.db = newDB
p.dbMu.Unlock()
if p.capability != nil {
p.capability.reset()
}
return nil
}
func (p *DatabasePasskeyProvider) runDBOpWithReconnect(run func(*sql.DB) error) error {
db := p.getDB()
if db == nil {
return fmt.Errorf("database connection is nil")
}
err := run(db)
if isDBClosed(err) {
if reconnErr := p.reconnectDB(); reconnErr == nil {
err = run(p.getDB())
}
}
return err
}
// BeginRegistration creates registration options for a new passkey
func (p *DatabasePasskeyProvider) BeginRegistration(ctx context.Context, userID int, username, displayName string) (*PasskeyRegistrationOptions, error) {
// Generate challenge
@@ -145,10 +173,40 @@ func (p *DatabasePasskeyProvider) CompleteRegistration(ctx context.Context, user
// For now, this is a placeholder that stores the credential data
// In production, you MUST use a proper WebAuthn library
credIDB64 := base64.StdEncoding.EncodeToString(response.RawID)
pubKeyB64 := base64.StdEncoding.EncodeToString(response.Response.AttestationObject)
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyStoreCredential) {
credentialID, err := p.storeCredentialDirect(ctx, storeCredentialParams{
UserID: userID,
CredentialID: credIDB64,
PublicKey: pubKeyB64,
AttestationType: "none",
SignCount: 0,
Transports: response.Transports,
BackupEligible: false,
BackupState: false,
Name: "Passkey",
})
if err != nil {
return nil, err
}
return &PasskeyCredential{
ID: fmt.Sprintf("%d", credentialID),
UserID: userID,
CredentialID: response.RawID,
PublicKey: response.Response.AttestationObject,
AttestationType: "none",
Transports: response.Transports,
CreatedAt: time.Now(),
LastUsedAt: time.Now(),
}, nil
}
credData := map[string]any{
"user_id": userID,
"credential_id": base64.StdEncoding.EncodeToString(response.RawID),
"public_key": base64.StdEncoding.EncodeToString(response.Response.AttestationObject),
"credential_id": credIDB64,
"public_key": pubKeyB64,
"attestation_type": "none",
"sign_count": 0,
"transports": response.Transports,
@@ -202,31 +260,36 @@ func (p *DatabasePasskeyProvider) BeginAuthentication(ctx context.Context, usern
// If username is provided, get user's credentials
var allowCredentials []PasskeyCredentialDescriptor
if username != "" {
var success bool
var errorMsg sql.NullString
var userID sql.NullInt64
var credentialsJSON sql.NullString
var creds []passkeyCredential
query := fmt.Sprintf(`SELECT p_success, p_error, p_user_id, p_credentials::text FROM %s($1)`, p.sqlNames.PasskeyGetCredsByUsername)
err := p.getDB().QueryRowContext(ctx, query, username).Scan(&success, &errorMsg, &userID, &credentialsJSON)
if err != nil {
return nil, fmt.Errorf("failed to get credentials: %w", err)
}
if !success {
if errorMsg.Valid {
return nil, fmt.Errorf("%s", errorMsg.String)
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetCredsByUsername) {
_, directCreds, err := p.getCredsByUsernameDirect(ctx, username)
if err != nil {
return nil, err
}
return nil, fmt.Errorf("failed to get credentials")
}
creds = directCreds
} else {
var success bool
var errorMsg sql.NullString
var userID sql.NullInt64
var credentialsJSON sql.NullString
// Parse credentials
var creds []struct {
ID string `json:"credential_id"`
Transports []string `json:"transports"`
}
if err := json.Unmarshal([]byte(credentialsJSON.String), &creds); err != nil {
return nil, fmt.Errorf("failed to parse credentials: %w", err)
query := fmt.Sprintf(`SELECT p_success, p_error, p_user_id, p_credentials::text FROM %s($1)`, p.sqlNames.PasskeyGetCredsByUsername)
err := p.getDB().QueryRowContext(ctx, query, username).Scan(&success, &errorMsg, &userID, &credentialsJSON)
if err != nil {
return nil, fmt.Errorf("failed to get credentials: %w", err)
}
if !success {
if errorMsg.Valid {
return nil, fmt.Errorf("%s", errorMsg.String)
}
return nil, fmt.Errorf("failed to get credentials")
}
if err := json.Unmarshal([]byte(credentialsJSON.String), &creds); err != nil {
return nil, fmt.Errorf("failed to parse credentials: %w", err)
}
}
allowCredentials = make([]PasskeyCredentialDescriptor, 0, len(creds))
@@ -262,6 +325,24 @@ func (p *DatabasePasskeyProvider) CompleteAuthentication(ctx context.Context, re
// 3. Verify signature using stored public key
// 4. Update sign counter and check for cloning
credIDB64 := base64.StdEncoding.EncodeToString(response.RawID)
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetCredential) {
userID, signCount, err := p.getCredentialDirect(ctx, credIDB64)
if err != nil {
return 0, err
}
newCounter := signCount + 1
cloneWarning, err := p.updateCounterDirect(ctx, credIDB64, newCounter)
if err != nil {
return 0, fmt.Errorf("failed to update counter: %w", err)
}
if cloneWarning {
return 0, fmt.Errorf("credential cloning detected")
}
return userID, nil
}
// Get credential from database
var success bool
var errorMsg sql.NullString
@@ -321,6 +402,10 @@ func (p *DatabasePasskeyProvider) CompleteAuthentication(ctx context.Context, re
// GetCredentials returns all passkey credentials for a user
func (p *DatabasePasskeyProvider) GetCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error) {
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetUserCredentials) {
return p.getUserCredentialsDirect(ctx, userID)
}
var success bool
var errorMsg sql.NullString
var credentialsJSON sql.NullString
@@ -401,6 +486,10 @@ func (p *DatabasePasskeyProvider) DeleteCredential(ctx context.Context, userID i
return fmt.Errorf("invalid credential ID: %w", err)
}
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyDeleteCredential) {
return p.deleteCredentialDirect(ctx, userID, credentialID)
}
var success bool
var errorMsg sql.NullString
@@ -427,6 +516,10 @@ func (p *DatabasePasskeyProvider) UpdateCredentialName(ctx context.Context, user
return fmt.Errorf("invalid credential ID: %w", err)
}
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyUpdateName) {
return p.updateNameDirect(ctx, userID, credentialID, name)
}
var success bool
var errorMsg sql.NullString
+256
View File
@@ -0,0 +1,256 @@
package security
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
)
// Direct-mode implementations mirroring the resolvespec_passkey_* stored
// procedures in database_schema.sql using plain SQL against TableNames.
// credential_id/public_key/aaguid are stored as base64 TEXT (not native
// bytea) and transports as JSON-encoded TEXT, so the same schema works on
// SQLite, MySQL, and Postgres.
type storeCredentialParams struct {
UserID int
CredentialID string // base64
PublicKey string // base64
AttestationType string
SignCount int
Transports []string
BackupEligible bool
BackupState bool
Name string
}
func (p *DatabasePasskeyProvider) storeCredentialDirect(ctx context.Context, params storeCredentialParams) (int64, error) {
transportsJSON, err := json.Marshal(params.Transports)
if err != nil {
return 0, fmt.Errorf("failed to marshal transports: %w", err)
}
var credentialID int64
err = p.runDBOpWithReconnect(func(db *sql.DB) error {
var exists int
checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials))
if err := db.QueryRowContext(ctx, checkQuery, params.CredentialID).Scan(&exists); err == nil {
return fmt.Errorf("credential already exists")
} else if !errors.Is(err, sql.ErrNoRows) {
return err
}
var userExists int
userCheckQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE id = ?`, p.tableNames.Users))
if err := db.QueryRowContext(ctx, userCheckQuery, params.UserID).Scan(&userExists); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("user not found")
}
return err
}
now := time.Now()
insertQuery := rewritePlaceholders(db, fmt.Sprintf(
`INSERT INTO %s (user_id, credential_id, public_key, attestation_type, aaguid, sign_count, transports, backup_eligible, backup_state, name, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.tableNames.UserPasskeyCredentials))
res, err := db.ExecContext(ctx, insertQuery, params.UserID, params.CredentialID, params.PublicKey, params.AttestationType,
"", params.SignCount, string(transportsJSON), params.BackupEligible, params.BackupState, params.Name, now, now)
if err != nil {
return err
}
credentialID, err = res.LastInsertId()
return err
})
if err != nil {
return 0, err
}
return credentialID, nil
}
func (p *DatabasePasskeyProvider) getCredentialDirect(ctx context.Context, credentialIDB64 string) (userID int, signCount uint32, err error) {
err = p.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT user_id, sign_count FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials))
return db.QueryRowContext(ctx, query, credentialIDB64).Scan(&userID, &signCount)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, 0, fmt.Errorf("credential not found")
}
return 0, 0, fmt.Errorf("failed to get credential: %w", err)
}
return userID, signCount, nil
}
func (p *DatabasePasskeyProvider) updateCounterDirect(ctx context.Context, credentialIDB64 string, newCounter uint32) (cloneWarning bool, err error) {
err = p.runDBOpWithReconnect(func(db *sql.DB) error {
var oldCounter int
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT sign_count FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials))
if err := db.QueryRowContext(ctx, query, credentialIDB64).Scan(&oldCounter); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("credential not found")
}
return err
}
if int(newCounter) <= oldCounter {
cloneWarning = true
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET clone_warning = ? WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials))
_, err := db.ExecContext(ctx, updQuery, true, credentialIDB64)
return err
}
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET sign_count = ?, last_used_at = ? WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials))
_, err := db.ExecContext(ctx, updQuery, newCounter, time.Now(), credentialIDB64)
return err
})
return cloneWarning, err
}
func (p *DatabasePasskeyProvider) getUserCredentialsDirect(ctx context.Context, userID int) ([]PasskeyCredential, error) {
var credentials []PasskeyCredential
err := p.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(
`SELECT id, user_id, credential_id, public_key, attestation_type, aaguid, sign_count, clone_warning, transports, backup_eligible, backup_state, name, created_at, last_used_at
FROM %s WHERE user_id = ? ORDER BY created_at DESC`, p.tableNames.UserPasskeyCredentials))
rows, err := db.QueryContext(ctx, query, userID)
if err != nil {
return err
}
defer rows.Close()
credentials = make([]PasskeyCredential, 0)
for rows.Next() {
var id, uid int
var credIDB64, pubKeyB64, attestationType, aaguidB64, name string
var signCount uint32
var cloneWarning, backupEligible, backupState bool
var transportsJSON sql.NullString
var createdAt, lastUsedAt time.Time
if err := rows.Scan(&id, &uid, &credIDB64, &pubKeyB64, &attestationType, &aaguidB64, &signCount,
&cloneWarning, &transportsJSON, &backupEligible, &backupState, &name, &createdAt, &lastUsedAt); err != nil {
return err
}
credID, err := base64.StdEncoding.DecodeString(credIDB64)
if err != nil {
continue
}
pubKey, err := base64.StdEncoding.DecodeString(pubKeyB64)
if err != nil {
continue
}
aaguid, _ := base64.StdEncoding.DecodeString(aaguidB64)
var transports []string
if transportsJSON.Valid && transportsJSON.String != "" {
_ = json.Unmarshal([]byte(transportsJSON.String), &transports)
}
credentials = append(credentials, PasskeyCredential{
ID: fmt.Sprintf("%d", id),
UserID: uid,
CredentialID: credID,
PublicKey: pubKey,
AttestationType: attestationType,
AAGUID: aaguid,
SignCount: signCount,
CloneWarning: cloneWarning,
Transports: transports,
BackupEligible: backupEligible,
BackupState: backupState,
Name: name,
CreatedAt: createdAt,
LastUsedAt: lastUsedAt,
})
}
return rows.Err()
})
if err != nil {
return nil, fmt.Errorf("failed to get credentials: %w", err)
}
return credentials, nil
}
func (p *DatabasePasskeyProvider) deleteCredentialDirect(ctx context.Context, userID int, credentialIDB64 string) error {
return p.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ? AND credential_id = ?`, p.tableNames.UserPasskeyCredentials))
res, err := db.ExecContext(ctx, query, userID, credentialIDB64)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("credential not found")
}
return nil
})
}
func (p *DatabasePasskeyProvider) updateNameDirect(ctx context.Context, userID int, credentialIDB64, name string) error {
return p.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET name = ? WHERE user_id = ? AND credential_id = ?`, p.tableNames.UserPasskeyCredentials))
res, err := db.ExecContext(ctx, query, name, userID, credentialIDB64)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("credential not found")
}
return nil
})
}
type passkeyCredential struct {
ID string `json:"credential_id"`
Transports []string `json:"transports"`
}
func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context, username string) (userID int, creds []passkeyCredential, err error) {
err = p.runDBOpWithReconnect(func(db *sql.DB) error {
userQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE username = ? AND is_active = ?`, p.tableNames.Users))
if err := db.QueryRowContext(ctx, userQuery, username, true).Scan(&userID); err != nil {
return err
}
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT credential_id, transports FROM %s WHERE user_id = ?`, p.tableNames.UserPasskeyCredentials))
rows, err := db.QueryContext(ctx, query, userID)
if err != nil {
return err
}
defer rows.Close()
creds = make([]passkeyCredential, 0)
for rows.Next() {
var credID string
var transportsJSON sql.NullString
if err := rows.Scan(&credID, &transportsJSON); err != nil {
return err
}
var transports []string
if transportsJSON.Valid && transportsJSON.String != "" {
_ = json.Unmarshal([]byte(transportsJSON.String), &transports)
}
creds = append(creds, passkeyCredential{ID: credID, Transports: transports})
}
return rows.Err()
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, nil, fmt.Errorf("user not found")
}
return 0, nil, fmt.Errorf("failed to get credentials: %w", err)
}
return userID, creds, nil
}
+10 -7
View File
@@ -34,7 +34,10 @@ type RowSecurity struct {
Tablename string `json:"tablename"`
Template string `json:"template"`
HasBlock bool `json:"has_block"`
UserID int `json:"user_id"`
// UserID is the opaque user reference the security rules were loaded for.
// It may be an int, a string/UUID, or a *UserContext, depending on what the
// RowSecurityProvider/SecurityContext.GetUserRef implementation returns.
UserID any `json:"user_id"`
}
func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Type) string {
@@ -42,7 +45,7 @@ func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Typ
str = strings.ReplaceAll(str, "{PrimaryKeyName}", pPrimaryKeyName)
str = strings.ReplaceAll(str, "{TableName}", m.Tablename)
str = strings.ReplaceAll(str, "{SchemaName}", m.Schema)
str = strings.ReplaceAll(str, "{UserID}", fmt.Sprintf("%d", m.UserID))
str = strings.ReplaceAll(str, "{UserID}", fmt.Sprintf("%v", m.UserID))
return str
}
@@ -413,7 +416,7 @@ func (m *SecurityList) ClearSecurity(pUserID int, pSchema, pTablename string) er
return nil
}
func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema, pTablename string, pOverwrite bool) (RowSecurity, error) {
func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserRef any, pSchema, pTablename string, pOverwrite bool) (RowSecurity, error) {
if m.provider == nil {
return RowSecurity{}, fmt.Errorf("security provider not set")
}
@@ -424,10 +427,10 @@ func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema
if m.RowSecurity == nil {
m.RowSecurity = make(map[string]RowSecurity, 0)
}
secKey := fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)
secKey := fmt.Sprintf("%s.%s@%v", pSchema, pTablename, pUserRef)
// Call the provider to load security rules
record, err := m.provider.GetRowSecurity(ctx, pUserID, pSchema, pTablename)
record, err := m.provider.GetRowSecurity(ctx, pUserRef, pSchema, pTablename)
if err != nil {
return RowSecurity{}, fmt.Errorf("GetRowSecurity failed: %v", err)
}
@@ -436,7 +439,7 @@ func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema
return record, nil
}
func (m *SecurityList) GetRowSecurityTemplate(pUserID int, pSchema, pTablename string) (RowSecurity, error) {
func (m *SecurityList) GetRowSecurityTemplate(pUserRef any, pSchema, pTablename string) (RowSecurity, error) {
defer logger.CatchPanic("GetRowSecurityTemplate")()
if m.RowSecurity == nil {
@@ -446,7 +449,7 @@ func (m *SecurityList) GetRowSecurityTemplate(pUserID int, pSchema, pTablename s
m.RowSecurityMutex.RLock()
defer m.RowSecurityMutex.RUnlock()
rowSec, ok := m.RowSecurity[fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)]
rowSec, ok := m.RowSecurity[fmt.Sprintf("%s.%s@%v", pSchema, pTablename, pUserRef)]
if !ok {
return RowSecurity{}, fmt.Errorf("no row security data")
}
+11 -1
View File
@@ -22,19 +22,29 @@ func (m *mockSecurityProvider) Login(ctx context.Context, req LoginRequest) (*Lo
return m.loginResponse, m.loginError
}
func (m *mockSecurityProvider) LoginWithCookie(ctx context.Context, req LoginRequest, _ http.ResponseWriter) (*LoginResponse, error) {
return m.Login(ctx, req)
}
func (m *mockSecurityProvider) Logout(ctx context.Context, req LogoutRequest) error {
return m.logoutError
}
func (m *mockSecurityProvider) LogoutWithCookie(ctx context.Context, req LogoutRequest, _ http.ResponseWriter) error {
return m.Logout(ctx, req)
}
func (m *mockSecurityProvider) Authenticate(r *http.Request) (*UserContext, error) {
return m.authUser, m.authError
}
func (m *mockSecurityProvider) SetAuthenticateCallback(_ func(r *http.Request) (*UserContext, error)) {}
func (m *mockSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]ColumnSecurity, error) {
return m.columnSecurity, nil
}
func (m *mockSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
func (m *mockSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
return m.rowSecurity, nil
}
+218 -41
View File
@@ -30,10 +30,18 @@ func (a *HeaderAuthenticator) Login(ctx context.Context, req LoginRequest) (*Log
return nil, fmt.Errorf("header authentication does not support login")
}
func (a *HeaderAuthenticator) LoginWithCookie(ctx context.Context, req LoginRequest, w http.ResponseWriter) (*LoginResponse, error) {
return a.Login(ctx, req)
}
func (a *HeaderAuthenticator) Logout(ctx context.Context, req LogoutRequest) error {
return nil
}
func (a *HeaderAuthenticator) LogoutWithCookie(ctx context.Context, req LogoutRequest, w http.ResponseWriter) error {
return a.Logout(ctx, req)
}
func (a *HeaderAuthenticator) Authenticate(r *http.Request) (*UserContext, error) {
userIDStr := r.Header.Get("X-User-ID")
if userIDStr == "" {
@@ -63,12 +71,19 @@ func (a *HeaderAuthenticator) Authenticate(r *http.Request) (*UserContext, error
// Also supports multiple OAuth2 providers configured with WithOAuth2()
// Also supports passkey authentication configured with WithPasskey()
type DatabaseAuthenticator struct {
db *sql.DB
dbMu sync.RWMutex
dbFactory func() (*sql.DB, error)
cache *cache.Cache
cacheTTL time.Duration
sqlNames *SQLNames
db *sql.DB
dbMu sync.RWMutex
dbFactory func() (*sql.DB, error)
cache *cache.Cache
cacheTTL time.Duration
sqlNames *SQLNames
tableNames *TableNames
queryMode QueryMode
capability *dbCapability
// Cookie session support (optional, gated by enableCookieSession)
enableCookieSession bool
cookieOptions SessionCookieOptions
// OAuth2 providers registry (multiple providers supported)
oauth2Providers map[string]*OAuth2Provider
@@ -76,6 +91,9 @@ type DatabaseAuthenticator struct {
// Passkey provider (optional)
passkeyProvider PasskeyProvider
// Optional fallback called when primary authentication fails
authenticateCallback func(r *http.Request) (*UserContext, error)
}
// DatabaseAuthenticatorOptions configures the database authenticator
@@ -90,9 +108,25 @@ type DatabaseAuthenticatorOptions struct {
// SQLNames provides custom SQL procedure/function names. If nil, uses DefaultSQLNames().
// Partial overrides are supported: only set the fields you want to change.
SQLNames *SQLNames
// TableNames provides custom table names for Direct mode. If nil, uses DefaultTableNames().
TableNames *TableNames
// QueryMode selects stored-procedure vs Direct-mode SQL. Default (zero value) is ModeAuto.
QueryMode QueryMode
// DBFactory is called to obtain a fresh *sql.DB when the existing connection is closed.
// If nil, reconnection is disabled.
DBFactory func() (*sql.DB, error)
// EnableCookieSession enables cookie-based session management.
// When true, Authenticate reads the session token from the cookie named by
// CookieOptions.Name (default "session_token") in addition to the Authorization header,
// and LoginWithCookie / LogoutWithCookie automatically set / clear the cookie.
EnableCookieSession bool
// CookieOptions configures the session cookie written by LoginWithCookie.
// Only used when EnableCookieSession is true.
CookieOptions SessionCookieOptions
// AuthenticateCallback is a fallback called when the primary authentication (database
// session lookup) fails. If non-nil and the callback returns a non-nil UserContext,
// that result is used in place of the failure.
AuthenticateCallback func(r *http.Request) (*UserContext, error)
}
func NewDatabaseAuthenticator(db *sql.DB) *DatabaseAuthenticator {
@@ -112,14 +146,21 @@ func NewDatabaseAuthenticatorWithOptions(db *sql.DB, opts DatabaseAuthenticatorO
}
sqlNames := MergeSQLNames(DefaultSQLNames(), opts.SQLNames)
tableNames := resolveTableNames(opts.TableNames)
return &DatabaseAuthenticator{
db: db,
dbFactory: opts.DBFactory,
cache: cacheInstance,
cacheTTL: opts.CacheTTL,
sqlNames: sqlNames,
passkeyProvider: opts.PasskeyProvider,
db: db,
dbFactory: opts.DBFactory,
cache: cacheInstance,
cacheTTL: opts.CacheTTL,
sqlNames: sqlNames,
tableNames: tableNames,
queryMode: opts.QueryMode,
capability: newDBCapability(),
passkeyProvider: opts.PasskeyProvider,
enableCookieSession: opts.EnableCookieSession,
cookieOptions: opts.CookieOptions,
authenticateCallback: opts.AuthenticateCallback,
}
}
@@ -140,6 +181,9 @@ func (a *DatabaseAuthenticator) reconnectDB() error {
a.dbMu.Lock()
a.db = newDB
a.dbMu.Unlock()
if a.capability != nil {
a.capability.reset()
}
return nil
}
@@ -159,7 +203,14 @@ func (a *DatabaseAuthenticator) runDBOpWithReconnect(run func(*sql.DB) error) er
return err
}
func (a *DatabaseAuthenticator) SetAuthenticateCallback(fn func(r *http.Request) (*UserContext, error)) {
a.authenticateCallback = fn
}
func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.Login) {
return a.loginDirect(ctx, req)
}
// Convert LoginRequest to JSON
reqJSON, err := json.Marshal(req)
if err != nil {
@@ -196,6 +247,9 @@ func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*L
// Register implements Registrable interface
func (a *DatabaseAuthenticator) Register(ctx context.Context, req RegisterRequest) (*LoginResponse, error) {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.Register) {
return a.registerDirect(ctx, req)
}
// Convert RegisterRequest to JSON
reqJSON, err := json.Marshal(req)
if err != nil {
@@ -231,6 +285,9 @@ func (a *DatabaseAuthenticator) Register(ctx context.Context, req RegisterReques
}
func (a *DatabaseAuthenticator) Logout(ctx context.Context, req LogoutRequest) error {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.Logout) {
return a.logoutDirect(ctx, req)
}
// Convert LogoutRequest to JSON
reqJSON, err := json.Marshal(req)
if err != nil {
@@ -265,6 +322,33 @@ func (a *DatabaseAuthenticator) Logout(ctx context.Context, req LogoutRequest) e
return nil
}
// LoginWithCookie performs a login and, when EnableCookieSession is true, writes the
// session cookie to w using the configured CookieOptions. The LoginResponse is returned
// regardless of whether cookie sessions are enabled.
func (a *DatabaseAuthenticator) LoginWithCookie(ctx context.Context, req LoginRequest, w http.ResponseWriter) (*LoginResponse, error) {
resp, err := a.Login(ctx, req)
if err != nil {
return nil, err
}
if a.enableCookieSession {
SetSessionCookie(w, resp, a.cookieOptions)
}
return resp, nil
}
// LogoutWithCookie performs a logout and, when EnableCookieSession is true, clears the
// session cookie on w. The logout itself is performed regardless of the cookie flag.
func (a *DatabaseAuthenticator) LogoutWithCookie(ctx context.Context, req LogoutRequest, w http.ResponseWriter) error {
err := a.Logout(ctx, req)
if err != nil {
return err
}
if a.enableCookieSession {
ClearSessionCookie(w, a.cookieOptions)
}
return nil
}
func (a *DatabaseAuthenticator) Authenticate(r *http.Request) (*UserContext, error) {
// Extract session token from header or cookie
sessionToken := r.Header.Get("Authorization")
@@ -272,10 +356,11 @@ func (a *DatabaseAuthenticator) Authenticate(r *http.Request) (*UserContext, err
var tokens []string
if sessionToken == "" {
// Try cookie
if token := GetSessionCookie(r); token != "" {
tokens = []string{token}
reference = "cookie"
if a.enableCookieSession {
if token := GetSessionCookie(r, a.cookieOptions); token != "" {
tokens = []string{token}
reference = "cookie"
}
}
} else {
// Parse Authorization header which may contain multiple comma-separated tokens
@@ -295,6 +380,9 @@ func (a *DatabaseAuthenticator) Authenticate(r *http.Request) (*UserContext, err
}
if len(tokens) == 0 {
if a.authenticateCallback != nil {
return a.authenticateCallback(r)
}
return nil, fmt.Errorf("session token required")
}
@@ -313,6 +401,10 @@ func (a *DatabaseAuthenticator) Authenticate(r *http.Request) (*UserContext, err
var userCtx UserContext
err := a.cache.GetOrSet(r.Context(), cacheKey, &userCtx, a.cacheTTL, func() (any, error) {
// This function is called only if cache miss
if !a.capability.ShouldUseProcedure(r.Context(), a.queryMode, a.getDB(), a.sqlNames.Session) {
return a.sessionDirect(r.Context(), token)
}
var success bool
var errorMsg sql.NullString
var userJSON sql.NullString
@@ -357,7 +449,10 @@ func (a *DatabaseAuthenticator) Authenticate(r *http.Request) (*UserContext, err
return &userCtx, nil
}
// All tokens failed
// All tokens failed — try callback before returning error
if a.authenticateCallback != nil {
return a.authenticateCallback(r)
}
if lastErr != nil {
return nil, lastErr
}
@@ -385,6 +480,11 @@ func (a *DatabaseAuthenticator) ClearUserCache(userID int) error {
// updateSessionActivity updates the last activity timestamp for the session
func (a *DatabaseAuthenticator) updateSessionActivity(ctx context.Context, sessionToken string, userCtx *UserContext) {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.SessionUpdate) {
_ = a.updateSessionActivityDirect(ctx, sessionToken)
return
}
// Convert UserContext to JSON
userJSON, err := json.Marshal(userCtx)
if err != nil {
@@ -403,6 +503,9 @@ func (a *DatabaseAuthenticator) updateSessionActivity(ctx context.Context, sessi
// RefreshToken implements Refreshable interface
func (a *DatabaseAuthenticator) RefreshToken(ctx context.Context, refreshToken string) (*LoginResponse, error) {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.RefreshToken) {
return a.refreshTokenDirect(ctx, refreshToken)
}
// First, we need to get the current user context for the refresh token
var success bool
var errorMsg sql.NullString
@@ -460,18 +563,23 @@ func (a *DatabaseAuthenticator) RefreshToken(ctx context.Context, refreshToken s
// Procedure names are configurable via SQLNames (see DefaultSQLNames for defaults)
// NOTE: JWT signing/verification requires github.com/golang-jwt/jwt/v5 to be installed and imported
type JWTAuthenticator struct {
secretKey []byte
db *sql.DB
dbMu sync.RWMutex
dbFactory func() (*sql.DB, error)
sqlNames *SQLNames
secretKey []byte
db *sql.DB
dbMu sync.RWMutex
dbFactory func() (*sql.DB, error)
sqlNames *SQLNames
tableNames *TableNames
queryMode QueryMode
capability *dbCapability
}
func NewJWTAuthenticator(secretKey string, db *sql.DB, names ...*SQLNames) *JWTAuthenticator {
return &JWTAuthenticator{
secretKey: []byte(secretKey),
db: db,
sqlNames: resolveSQLNames(names...),
secretKey: []byte(secretKey),
db: db,
sqlNames: resolveSQLNames(names...),
tableNames: DefaultTableNames(),
capability: newDBCapability(),
}
}
@@ -481,6 +589,18 @@ func (a *JWTAuthenticator) WithDBFactory(factory func() (*sql.DB, error)) *JWTAu
return a
}
// WithTableNames configures Direct-mode table names. If names is nil, defaults are used.
func (a *JWTAuthenticator) WithTableNames(names *TableNames) *JWTAuthenticator {
a.tableNames = resolveTableNames(names)
return a
}
// WithQueryMode selects stored-procedure vs Direct-mode SQL (default ModeAuto).
func (a *JWTAuthenticator) WithQueryMode(mode QueryMode) *JWTAuthenticator {
a.queryMode = mode
return a
}
func (a *JWTAuthenticator) getDB() *sql.DB {
a.dbMu.RLock()
defer a.dbMu.RUnlock()
@@ -498,10 +618,17 @@ func (a *JWTAuthenticator) reconnectDB() error {
a.dbMu.Lock()
a.db = newDB
a.dbMu.Unlock()
if a.capability != nil {
a.capability.reset()
}
return nil
}
func (a *JWTAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.JWTLogin) {
return a.jwtLoginDirect(ctx, req)
}
var success bool
var errorMsg sql.NullString
var userJSON []byte
@@ -564,6 +691,10 @@ func (a *JWTAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginR
}
func (a *JWTAuthenticator) Logout(ctx context.Context, req LogoutRequest) error {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.JWTLogout) {
return a.jwtLogoutDirect(ctx, req)
}
var success bool
var errorMsg sql.NullString
@@ -583,6 +714,14 @@ func (a *JWTAuthenticator) Logout(ctx context.Context, req LogoutRequest) error
return nil
}
func (a *JWTAuthenticator) LoginWithCookie(ctx context.Context, req LoginRequest, w http.ResponseWriter) (*LoginResponse, error) {
return a.Login(ctx, req)
}
func (a *JWTAuthenticator) LogoutWithCookie(ctx context.Context, req LogoutRequest, w http.ResponseWriter) error {
return a.Logout(ctx, req)
}
func (a *JWTAuthenticator) Authenticate(r *http.Request) (*UserContext, error) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
@@ -605,14 +744,23 @@ func (a *JWTAuthenticator) Authenticate(r *http.Request) (*UserContext, error) {
// All database operations go through stored procedures
// Procedure names are configurable via SQLNames (see DefaultSQLNames for defaults)
type DatabaseColumnSecurityProvider struct {
db *sql.DB
dbMu sync.RWMutex
dbFactory func() (*sql.DB, error)
sqlNames *SQLNames
db *sql.DB
dbMu sync.RWMutex
dbFactory func() (*sql.DB, error)
sqlNames *SQLNames
queryMode QueryMode
capability *dbCapability
}
func NewDatabaseColumnSecurityProvider(db *sql.DB, names ...*SQLNames) *DatabaseColumnSecurityProvider {
return &DatabaseColumnSecurityProvider{db: db, sqlNames: resolveSQLNames(names...)}
return &DatabaseColumnSecurityProvider{db: db, sqlNames: resolveSQLNames(names...), capability: newDBCapability()}
}
// WithQueryMode selects stored-procedure vs Direct-mode SQL (default ModeAuto).
// Direct mode is unsupported for column security (see ErrDirectModeUnsupported).
func (p *DatabaseColumnSecurityProvider) WithQueryMode(mode QueryMode) *DatabaseColumnSecurityProvider {
p.queryMode = mode
return p
}
func (p *DatabaseColumnSecurityProvider) WithDBFactory(factory func() (*sql.DB, error)) *DatabaseColumnSecurityProvider {
@@ -637,10 +785,17 @@ func (p *DatabaseColumnSecurityProvider) reconnectDB() error {
p.dbMu.Lock()
p.db = newDB
p.dbMu.Unlock()
if p.capability != nil {
p.capability.reset()
}
return nil
}
func (p *DatabaseColumnSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]ColumnSecurity, error) {
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.ColumnSecurity) {
return nil, ErrDirectModeUnsupported
}
var rules []ColumnSecurity
var success bool
@@ -705,14 +860,23 @@ func (p *DatabaseColumnSecurityProvider) GetColumnSecurity(ctx context.Context,
// All database operations go through stored procedures
// Procedure names are configurable via SQLNames (see DefaultSQLNames for defaults)
type DatabaseRowSecurityProvider struct {
db *sql.DB
dbMu sync.RWMutex
dbFactory func() (*sql.DB, error)
sqlNames *SQLNames
db *sql.DB
dbMu sync.RWMutex
dbFactory func() (*sql.DB, error)
sqlNames *SQLNames
queryMode QueryMode
capability *dbCapability
}
func NewDatabaseRowSecurityProvider(db *sql.DB, names ...*SQLNames) *DatabaseRowSecurityProvider {
return &DatabaseRowSecurityProvider{db: db, sqlNames: resolveSQLNames(names...)}
return &DatabaseRowSecurityProvider{db: db, sqlNames: resolveSQLNames(names...), capability: newDBCapability()}
}
// WithQueryMode selects stored-procedure vs Direct-mode SQL (default ModeAuto).
// Direct mode is unsupported for row security (see ErrDirectModeUnsupported).
func (p *DatabaseRowSecurityProvider) WithQueryMode(mode QueryMode) *DatabaseRowSecurityProvider {
p.queryMode = mode
return p
}
func (p *DatabaseRowSecurityProvider) WithDBFactory(factory func() (*sql.DB, error)) *DatabaseRowSecurityProvider {
@@ -737,16 +901,23 @@ func (p *DatabaseRowSecurityProvider) reconnectDB() error {
p.dbMu.Lock()
p.db = newDB
p.dbMu.Unlock()
if p.capability != nil {
p.capability.reset()
}
return nil
}
func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.RowSecurity) {
return RowSecurity{}, ErrDirectModeUnsupported
}
var template string
var hasBlock bool
runQuery := func() error {
query := fmt.Sprintf(`SELECT p_template, p_block FROM %s($1, $2, $3)`, p.sqlNames.RowSecurity)
return p.getDB().QueryRowContext(ctx, query, schema, table, userID).Scan(&template, &hasBlock)
return p.getDB().QueryRowContext(ctx, query, schema, table, userRef).Scan(&template, &hasBlock)
}
err := runQuery()
if isDBClosed(err) {
@@ -761,7 +932,7 @@ func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userID
return RowSecurity{
Schema: schema,
Tablename: table,
UserID: userID,
UserID: userRef,
Template: template,
HasBlock: hasBlock,
}, nil
@@ -798,14 +969,14 @@ func NewConfigRowSecurityProvider(templates map[string]string, blocked map[strin
}
}
func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
key := fmt.Sprintf("%s.%s", schema, table)
if p.blocked[key] {
return RowSecurity{
Schema: schema,
Tablename: table,
UserID: userID,
UserID: userRef,
HasBlock: true,
}, nil
}
@@ -814,7 +985,7 @@ func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userID i
return RowSecurity{
Schema: schema,
Tablename: table,
UserID: userID,
UserID: userRef,
Template: template,
HasBlock: false,
}, nil
@@ -874,6 +1045,9 @@ func generateRandomString(length int) string {
// RequestPasswordReset implements PasswordResettable. It calls the stored procedure
// resolvespec_password_reset_request and returns the reset token and expiry.
func (a *DatabaseAuthenticator) RequestPasswordReset(ctx context.Context, req PasswordResetRequest) (*PasswordResetResponse, error) {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.PasswordResetRequest) {
return a.requestPasswordResetDirect(ctx, req)
}
reqJSON, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal password reset request: %w", err)
@@ -911,6 +1085,9 @@ func (a *DatabaseAuthenticator) RequestPasswordReset(ctx context.Context, req Pa
// CompletePasswordReset implements PasswordResettable. It validates the token and
// updates the user's password via resolvespec_password_reset.
func (a *DatabaseAuthenticator) CompletePasswordReset(ctx context.Context, req PasswordResetCompleteRequest) error {
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.PasswordResetComplete) {
return a.completePasswordResetDirect(ctx, req)
}
reqJSON, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to marshal password reset complete request: %w", err)
+473
View File
@@ -0,0 +1,473 @@
package security
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
)
// Direct-mode implementations for DatabaseAuthenticator and JWTAuthenticator.
// These mirror the plpgsql bodies in database_schema.sql using plain
// parameterized SQL against the configured TableNames, so they work on
// SQLite, MySQL, or Postgres without the resolvespec_* functions installed.
//
// Password verification is intentionally not implemented here: the stored
// procedures never verify the password hash either (see the TODOs in
// database_schema.sql), so Direct mode matches that behavior exactly rather
// than introducing a mismatch between modes.
var (
errUsernameExists = errors.New("username already exists")
errEmailExists = errors.New("email already exists")
)
func (a *DatabaseAuthenticator) loginDirect(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
var userID int
var email, roles, programUserTable sql.NullString
var userLevel, programUserID sql.NullInt64
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(
`SELECT id, email, user_level, roles, program_user_id, program_user_table FROM %s WHERE username = ? AND is_active = ?`,
a.tableNames.Users))
return db.QueryRowContext(ctx, query, req.Username, true).Scan(&userID, &email, &userLevel, &roles, &programUserID, &programUserTable)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("invalid credentials")
}
return nil, fmt.Errorf("login query failed: %w", err)
}
sessionToken, err := generateSessionToken()
if err != nil {
return nil, fmt.Errorf("failed to generate session token: %w", err)
}
now := time.Now()
expiresAt := now.Add(24 * time.Hour)
ipAddress, userAgent := claimStrings(req.Claims)
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
insertQuery := rewritePlaceholders(db, fmt.Sprintf(
`INSERT INTO %s (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
a.tableNames.UserSessions))
if _, err := db.ExecContext(ctx, insertQuery, sessionToken, userID, expiresAt, ipAddress, userAgent, now, now); err != nil {
return err
}
updateQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_login_at = ? WHERE id = ?`, a.tableNames.Users))
_, err := db.ExecContext(ctx, updateQuery, now, userID)
return err
})
if err != nil {
return nil, fmt.Errorf("login query failed: %w", err)
}
userCtx := &UserContext{
UserID: userID,
UserName: req.Username,
Email: email.String,
UserLevel: int(userLevel.Int64),
Roles: parseRoles(roles.String),
SessionID: sessionToken,
ProgramUserID: int(programUserID.Int64),
ProgramUserTable: programUserTable.String,
}
return &LoginResponse{
Token: sessionToken,
User: userCtx,
ExpiresIn: 86400,
}, nil
}
func (a *DatabaseAuthenticator) registerDirect(ctx context.Context, req RegisterRequest) (*LoginResponse, error) {
if req.Username == "" {
return nil, fmt.Errorf("username is required")
}
if req.Email == "" {
return nil, fmt.Errorf("email is required")
}
if req.Password == "" {
return nil, fmt.Errorf("password is required")
}
rolesStr := strings.Join(req.Roles, ",")
now := time.Now()
ipAddress, userAgent := claimStrings(req.Claims)
var userID int64
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
var count int
checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE username = ?`, a.tableNames.Users))
if err := db.QueryRowContext(ctx, checkQuery, req.Username).Scan(&count); err != nil {
return err
}
if count > 0 {
return errUsernameExists
}
checkQuery2 := rewritePlaceholders(db, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE email = ?`, a.tableNames.Users))
if err := db.QueryRowContext(ctx, checkQuery2, req.Email).Scan(&count); err != nil {
return err
}
if count > 0 {
return errEmailExists
}
insertQuery := rewritePlaceholders(db, fmt.Sprintf(
`INSERT INTO %s (username, email, password, user_level, roles, is_active, created_at, updated_at, program_user_id, program_user_table) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.tableNames.Users))
res, err := db.ExecContext(ctx, insertQuery, req.Username, req.Email, req.Password, req.UserLevel, rolesStr, true, now, now, 0, "")
if err != nil {
return err
}
userID, err = res.LastInsertId()
return err
})
if err != nil {
if errors.Is(err, errUsernameExists) {
return nil, errUsernameExists
}
if errors.Is(err, errEmailExists) {
return nil, errEmailExists
}
return nil, fmt.Errorf("register query failed: %w", err)
}
sessionToken, err := generateSessionToken()
if err != nil {
return nil, fmt.Errorf("failed to generate session token: %w", err)
}
expiresAt := now.Add(24 * time.Hour)
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
insertSession := rewritePlaceholders(db, fmt.Sprintf(
`INSERT INTO %s (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
a.tableNames.UserSessions))
if _, err := db.ExecContext(ctx, insertSession, sessionToken, userID, expiresAt, ipAddress, userAgent, now, now); err != nil {
return err
}
updUser := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_login_at = ? WHERE id = ?`, a.tableNames.Users))
_, err := db.ExecContext(ctx, updUser, now, userID)
return err
})
if err != nil {
return nil, fmt.Errorf("register query failed: %w", err)
}
userCtx := &UserContext{
UserID: int(userID),
UserName: req.Username,
Email: req.Email,
UserLevel: req.UserLevel,
Roles: parseRoles(rolesStr),
SessionID: sessionToken,
ProgramUserID: 0,
ProgramUserTable: "",
}
return &LoginResponse{
Token: sessionToken,
User: userCtx,
ExpiresIn: 86400,
}, nil
}
func (a *DatabaseAuthenticator) logoutDirect(ctx context.Context, req LogoutRequest) error {
token := req.Token
token = strings.TrimPrefix(token, "Bearer ")
token = strings.TrimPrefix(token, "bearer ")
var rows int64
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE session_token = ? AND user_id = ?`, a.tableNames.UserSessions))
res, err := db.ExecContext(ctx, query, token, req.UserID)
if err != nil {
return err
}
rows, err = res.RowsAffected()
return err
})
if err != nil {
return fmt.Errorf("logout query failed: %w", err)
}
if rows == 0 {
return fmt.Errorf("session not found")
}
if req.Token != "" {
cacheKey := fmt.Sprintf("auth:session:%s", req.Token)
_ = a.cache.Delete(ctx, cacheKey)
}
return nil
}
func (a *DatabaseAuthenticator) sessionDirect(ctx context.Context, token string) (*UserContext, error) {
var userID int
var username, email, roles, programUserTable sql.NullString
var userLevel, programUserID sql.NullInt64
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(
`SELECT s.user_id, u.username, u.email, u.user_level, u.roles, u.program_user_id, u.program_user_table
FROM %s s JOIN %s u ON s.user_id = u.id
WHERE s.session_token = ? AND s.expires_at > ? AND u.is_active = ?`,
a.tableNames.UserSessions, a.tableNames.Users))
return db.QueryRowContext(ctx, query, token, time.Now(), true).Scan(&userID, &username, &email, &userLevel, &roles, &programUserID, &programUserTable)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("invalid or expired session")
}
return nil, fmt.Errorf("session query failed: %w", err)
}
return &UserContext{
UserID: userID,
UserName: username.String,
Email: email.String,
UserLevel: int(userLevel.Int64),
SessionID: token,
Roles: parseRoles(roles.String),
ProgramUserID: int(programUserID.Int64),
ProgramUserTable: programUserTable.String,
}, nil
}
func (a *DatabaseAuthenticator) updateSessionActivityDirect(ctx context.Context, sessionToken string) error {
return a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_activity_at = ? WHERE session_token = ? AND expires_at > ?`, a.tableNames.UserSessions))
_, err := db.ExecContext(ctx, query, time.Now(), sessionToken, time.Now())
return err
})
}
func (a *DatabaseAuthenticator) refreshTokenDirect(ctx context.Context, oldToken string) (*LoginResponse, error) {
var userID int
var username, email, roles, ipAddress, userAgent, programUserTable sql.NullString
var userLevel, programUserID sql.NullInt64
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(
`SELECT s.user_id, u.username, u.email, u.user_level, u.roles, s.ip_address, s.user_agent, u.program_user_id, u.program_user_table
FROM %s s JOIN %s u ON s.user_id = u.id
WHERE s.session_token = ? AND s.expires_at > ? AND u.is_active = ?`,
a.tableNames.UserSessions, a.tableNames.Users))
return db.QueryRowContext(ctx, query, oldToken, time.Now(), true).Scan(&userID, &username, &email, &userLevel, &roles, &ipAddress, &userAgent, &programUserID, &programUserTable)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("invalid or expired refresh token")
}
return nil, fmt.Errorf("refresh token query failed: %w", err)
}
newToken, err := generateSessionToken()
if err != nil {
return nil, fmt.Errorf("failed to generate session token: %w", err)
}
now := time.Now()
expiresAt := now.Add(24 * time.Hour)
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
insertQuery := rewritePlaceholders(db, fmt.Sprintf(
`INSERT INTO %s (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
a.tableNames.UserSessions))
if _, err := db.ExecContext(ctx, insertQuery, newToken, userID, expiresAt, ipAddress.String, userAgent.String, now, now); err != nil {
return err
}
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE session_token = ?`, a.tableNames.UserSessions))
_, err := db.ExecContext(ctx, delQuery, oldToken)
return err
})
if err != nil {
return nil, fmt.Errorf("refresh token generation failed: %w", err)
}
userCtx := &UserContext{
UserID: userID,
UserName: username.String,
Email: email.String,
UserLevel: int(userLevel.Int64),
SessionID: newToken,
Roles: parseRoles(roles.String),
ProgramUserID: int(programUserID.Int64),
ProgramUserTable: programUserTable.String,
}
return &LoginResponse{
Token: newToken,
User: userCtx,
ExpiresIn: int64(24 * time.Hour.Seconds()),
}, nil
}
func (a *DatabaseAuthenticator) requestPasswordResetDirect(ctx context.Context, req PasswordResetRequest) (*PasswordResetResponse, error) {
if req.Email == "" && req.Username == "" {
return nil, fmt.Errorf("email or username is required")
}
var userID int
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
var query string
var arg string
if req.Email != "" {
query = rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE email = ? AND is_active = ?`, a.tableNames.Users))
arg = req.Email
} else {
query = rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE username = ? AND is_active = ?`, a.tableNames.Users))
arg = req.Username
}
return db.QueryRowContext(ctx, query, arg, true).Scan(&userID)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Return generic success even when user not found to avoid user enumeration.
return &PasswordResetResponse{Token: "", ExpiresIn: 0}, nil
}
return nil, fmt.Errorf("password reset request query failed: %w", err)
}
rawBytes := make([]byte, 32)
if _, err := rand.Read(rawBytes); err != nil {
return nil, fmt.Errorf("failed to generate reset token: %w", err)
}
rawToken := hex.EncodeToString(rawBytes)
hash := sha256.Sum256([]byte(rawToken))
tokenHash := hex.EncodeToString(hash[:])
now := time.Now()
expiresAt := now.Add(1 * time.Hour)
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ? AND used = ?`, a.tableNames.UserPasswordResets))
if _, err := db.ExecContext(ctx, delQuery, userID, false); err != nil {
return err
}
insQuery := rewritePlaceholders(db, fmt.Sprintf(`INSERT INTO %s (user_id, token_hash, expires_at, created_at, used) VALUES (?, ?, ?, ?, ?)`, a.tableNames.UserPasswordResets))
_, err := db.ExecContext(ctx, insQuery, userID, tokenHash, expiresAt, now, false)
return err
})
if err != nil {
return nil, fmt.Errorf("password reset request query failed: %w", err)
}
return &PasswordResetResponse{Token: rawToken, ExpiresIn: 3600}, nil
}
func (a *DatabaseAuthenticator) completePasswordResetDirect(ctx context.Context, req PasswordResetCompleteRequest) error {
if req.Token == "" {
return fmt.Errorf("token is required")
}
if req.NewPassword == "" {
return fmt.Errorf("new_password is required")
}
hash := sha256.Sum256([]byte(req.Token))
tokenHash := hex.EncodeToString(hash[:])
var resetID, userID int
var expiresAt time.Time
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT id, user_id, expires_at FROM %s WHERE token_hash = ? AND used = ?`, a.tableNames.UserPasswordResets))
return db.QueryRowContext(ctx, query, tokenHash, false).Scan(&resetID, &userID, &expiresAt)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("invalid or expired token")
}
return fmt.Errorf("password reset complete query failed: %w", err)
}
if !expiresAt.After(time.Now()) {
return fmt.Errorf("invalid or expired token")
}
now := time.Now()
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
updUser := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET password = ?, updated_at = ? WHERE id = ?`, a.tableNames.Users))
if _, err := db.ExecContext(ctx, updUser, req.NewPassword, now, userID); err != nil {
return err
}
delSessions := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, a.tableNames.UserSessions))
if _, err := db.ExecContext(ctx, delSessions, userID); err != nil {
return err
}
updReset := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET used = ?, used_at = ? WHERE id = ?`, a.tableNames.UserPasswordResets))
_, err := db.ExecContext(ctx, updReset, true, now, resetID)
return err
})
if err != nil {
return fmt.Errorf("password reset complete query failed: %w", err)
}
return nil
}
// claimStrings extracts ip_address/user_agent from a request's Claims map, mirroring
// p_request->'claims'->>'ip_address' / 'user_agent' in the plpgsql procedures.
func claimStrings(claims map[string]any) (ipAddress, userAgent string) {
if claims == nil {
return "", ""
}
if v, ok := claims["ip_address"].(string); ok {
ipAddress = v
}
if v, ok := claims["user_agent"].(string); ok {
userAgent = v
}
return ipAddress, userAgent
}
// jwtLoginDirect mirrors resolvespec_jwt_login.
func (a *JWTAuthenticator) jwtLoginDirect(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
var userID int
var email, roles sql.NullString
var userLevel sql.NullInt64
runQuery := func() error {
query := rewritePlaceholders(a.getDB(), fmt.Sprintf(`SELECT id, email, user_level, roles FROM %s WHERE username = ? AND is_active = ?`, a.tableNames.Users))
return a.getDB().QueryRowContext(ctx, query, req.Username, true).Scan(&userID, &email, &userLevel, &roles)
}
err := runQuery()
if isDBClosed(err) {
if reconnErr := a.reconnectDB(); reconnErr == nil {
err = runQuery()
}
}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("invalid credentials")
}
return nil, fmt.Errorf("login query failed: %w", err)
}
expiresAt := time.Now().Add(24 * time.Hour)
tokenString := fmt.Sprintf("token_%d_%d", userID, expiresAt.Unix())
return &LoginResponse{
Token: tokenString,
User: &UserContext{
UserID: userID,
UserName: req.Username,
Email: email.String,
UserLevel: int(userLevel.Int64),
Roles: parseRoles(roles.String),
},
ExpiresIn: int64(24 * time.Hour.Seconds()),
}, nil
}
// jwtLogoutDirect mirrors resolvespec_jwt_logout (adds token to the blacklist table).
func (a *JWTAuthenticator) jwtLogoutDirect(ctx context.Context, req LogoutRequest) error {
db := a.getDB()
expiresAt := time.Now().Add(24 * time.Hour)
query := rewritePlaceholders(db, fmt.Sprintf(`INSERT INTO %s (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)`, a.tableNames.TokenBlacklist))
_, err := db.ExecContext(ctx, query, req.Token, req.UserID, expiresAt, time.Now())
if err != nil {
return fmt.Errorf("logout query failed: %w", err)
}
return nil
}
+5 -1
View File
@@ -511,6 +511,10 @@ func TestDatabaseAuthenticator(t *testing.T) {
})
t.Run("authenticate with cookie", func(t *testing.T) {
cookieAuth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{
EnableCookieSession: true,
})
req := httptest.NewRequest("GET", "/test", nil)
req.AddCookie(&http.Cookie{
Name: "session_token",
@@ -524,7 +528,7 @@ func TestDatabaseAuthenticator(t *testing.T) {
WithArgs("cookie-token-456", "cookie").
WillReturnRows(rows)
userCtx, err := auth.Authenticate(req)
userCtx, err := cookieAuth.Authenticate(req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
+169
View File
@@ -0,0 +1,169 @@
package security
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"strconv"
"strings"
"sync"
"time"
)
// QueryMode selects how a provider talks to the database: via the configured
// resolvespec_* stored procedure (ModeProcedure), via portable Go/SQL logic
// (ModeDirect), or auto-detected per connection (ModeAuto, the default).
type QueryMode int
const (
// ModeAuto probes whether the configured stored procedure exists on a
// Postgres connection and uses it if so, otherwise falls back to Direct mode.
ModeAuto QueryMode = iota
// ModeProcedure always calls the configured resolvespec_* stored procedure.
ModeProcedure
// ModeDirect always uses the portable Go/SQL implementation, never the
// stored procedure.
ModeDirect
)
// dbCapability probes and caches whether a given *sql.DB is Postgres and
// whether specific stored procedures exist on it. One instance is shared by
// a provider (DatabaseAuthenticator, DatabaseTwoFactorProvider, etc.) across
// all of its operations.
type dbCapability struct {
funcExists sync.Map // procName (string) -> exists (bool)
}
// newDBCapability creates a new, empty capability cache.
func newDBCapability() *dbCapability {
return &dbCapability{}
}
// reset clears all cached probe results. Call after reconnecting to a
// (possibly different) database.
func (c *dbCapability) reset() {
c.funcExists.Range(func(key, _ any) bool {
c.funcExists.Delete(key)
return true
})
}
// probeFunctionExists checks, via a Postgres-specific system catalog query,
// whether a function named procName exists. Any error (wrong dialect,
// placeholder syntax rejected, relation missing, etc.) is treated as "does
// not exist" rather than propagated - the probe must never be able to panic
// or block resolution of the query mode.
func probeFunctionExists(ctx context.Context, db *sql.DB, procName string) bool {
if db == nil {
return false
}
var exists bool
defer func() {
// Guard against any unexpected panic from a misbehaving driver.
_ = recover()
}()
row := db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM pg_proc WHERE proname = $1 LIMIT 1)`, procName)
if err := row.Scan(&exists); err != nil {
return false
}
return exists
}
// ShouldUseProcedure decides whether the stored-procedure code path should be
// used for procName given mode. ModeProcedure/ModeDirect are unconditional.
//
// ModeAuto resolves as follows:
// - A recognized portable-only driver (SQLite, MySQL) always uses Direct
// mode; no query is issued.
// - A recognized Postgres driver (lib/pq, pgx) probes pg_proc for procName
// and uses the stored procedure only if it actually exists there.
// - Any other/unrecognized driver (including test doubles such as
// sqlmock) cannot be safely dialect-probed without risking an
// unexpected query against a strictly-ordered mock, so it defaults to
// the stored-procedure path, preserving pre-existing behavior for
// callers that configure Postgres-flavored access without an
// identifiable driver type.
func (c *dbCapability) ShouldUseProcedure(ctx context.Context, mode QueryMode, db *sql.DB, procName string) bool {
switch mode {
case ModeProcedure:
return true
case ModeDirect:
return false
default:
if cached, ok := c.funcExists.Load(procName); ok {
return cached.(bool)
}
var exists bool
switch {
case driverIsPortableOnly(db):
exists = false
case driverIsPostgres(db):
exists = probeFunctionExists(ctx, db, procName)
default:
exists = true
}
c.funcExists.Store(procName, exists)
return exists
}
}
// driverIsPostgres reports whether db's underlying driver looks like a
// Postgres driver (lib/pq or pgx), based on the driver's Go type name.
func driverIsPostgres(db *sql.DB) bool {
if db == nil {
return false
}
t := strings.ToLower(fmt.Sprintf("%T", db.Driver()))
return strings.Contains(t, "pq.") || strings.Contains(t, "pgx") || strings.Contains(t, "postgres")
}
// driverIsPortableOnly reports whether db's underlying driver is a dialect
// that never has the resolvespec_* Postgres functions available (SQLite,
// MySQL), so ModeAuto can skip probing entirely and go straight to Direct.
func driverIsPortableOnly(db *sql.DB) bool {
if db == nil {
return false
}
t := strings.ToLower(fmt.Sprintf("%T", db.Driver()))
return strings.Contains(t, "sqlite") || strings.Contains(t, "mysql")
}
// rewritePlaceholders converts a query written with "?" placeholders
// (SQLite/MySQL style) to Postgres "$1", "$2", ... style when db's driver is
// Postgres. All Direct-mode SQL in this package is written with "?" and
// passed through this helper before execution so the same query source works
// against SQLite, MySQL, and (in the rare fallback case) Postgres without the
// resolvespec_* functions installed.
func rewritePlaceholders(db *sql.DB, query string) string {
if !driverIsPostgres(db) {
return query
}
var b strings.Builder
n := 0
for _, r := range query {
if r == '?' {
n++
b.WriteString("$")
b.WriteString(strconv.Itoa(n))
} else {
b.WriteRune(r)
}
}
return b.String()
}
// generateSessionToken produces a session token in the same shape the
// plpgsql stored procedures generate: "sess_" + hex(32 random bytes) + "_" +
// unix timestamp, so downstream code that parses/displays tokens is
// unaffected by which mode created them.
func generateSessionToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return fmt.Sprintf("sess_%s_%d", hex.EncodeToString(buf), time.Now().Unix()), nil
}
+165
View File
@@ -0,0 +1,165 @@
package security
import (
"context"
"database/sql"
"testing"
"github.com/DATA-DOG/go-sqlmock"
_ "github.com/mattn/go-sqlite3"
)
func openTestSQLite(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("failed to open sqlite db: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func TestShouldUseProcedure_ModeProcedure_AlwaysTrue(t *testing.T) {
db := openTestSQLite(t)
c := newDBCapability()
if !c.ShouldUseProcedure(context.Background(), ModeProcedure, db, "resolvespec_login") {
t.Error("ModeProcedure should always resolve to true")
}
}
func TestShouldUseProcedure_ModeDirect_AlwaysFalse(t *testing.T) {
db := openTestSQLite(t)
c := newDBCapability()
if c.ShouldUseProcedure(context.Background(), ModeDirect, db, "resolvespec_login") {
t.Error("ModeDirect should always resolve to false")
}
}
func TestShouldUseProcedure_Auto_SQLite_ResolvesDirect(t *testing.T) {
db := openTestSQLite(t)
c := newDBCapability()
if c.ShouldUseProcedure(context.Background(), ModeAuto, db, "resolvespec_login") {
t.Error("ModeAuto against a SQLite connection should resolve to Direct (false)")
}
}
func TestShouldUseProcedure_Auto_UnknownDriver_DefaultsToProcedure(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create mock db: %v", err)
}
defer db.Close()
c := newDBCapability()
if !c.ShouldUseProcedure(context.Background(), ModeAuto, db, "resolvespec_login") {
t.Error("ModeAuto against an unrecognized driver (e.g. a test double) should default to Procedure (true) to preserve existing behavior")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
}
func TestShouldUseProcedure_CachesResult(t *testing.T) {
db := openTestSQLite(t)
c := newDBCapability()
first := c.ShouldUseProcedure(context.Background(), ModeAuto, db, "resolvespec_login")
if _, ok := c.funcExists.Load("resolvespec_login"); !ok {
t.Error("expected result to be cached")
}
second := c.ShouldUseProcedure(context.Background(), ModeAuto, db, "resolvespec_login")
if first != second {
t.Errorf("cached result changed: first=%v second=%v", first, second)
}
}
func TestDBCapability_Reset_ClearsCache(t *testing.T) {
c := newDBCapability()
c.funcExists.Store("resolvespec_login", true)
c.reset()
if _, ok := c.funcExists.Load("resolvespec_login"); ok {
t.Error("reset should clear all cached entries")
}
}
func TestProbeFunctionExists_Postgres_FunctionExists(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create mock db: %v", err)
}
defer db.Close()
rows := sqlmock.NewRows([]string{"exists"}).AddRow(true)
mock.ExpectQuery(`SELECT EXISTS \(SELECT 1 FROM pg_proc WHERE proname = \$1 LIMIT 1\)`).
WithArgs("resolvespec_login").
WillReturnRows(rows)
if !probeFunctionExists(context.Background(), db, "resolvespec_login") {
t.Error("expected probeFunctionExists to return true")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
}
func TestProbeFunctionExists_Postgres_FunctionMissing(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create mock db: %v", err)
}
defer db.Close()
rows := sqlmock.NewRows([]string{"exists"}).AddRow(false)
mock.ExpectQuery(`SELECT EXISTS \(SELECT 1 FROM pg_proc WHERE proname = \$1 LIMIT 1\)`).
WithArgs("resolvespec_login").
WillReturnRows(rows)
if probeFunctionExists(context.Background(), db, "resolvespec_login") {
t.Error("expected probeFunctionExists to return false")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled expectations: %v", err)
}
}
func TestProbeFunctionExists_QueryError_ReturnsFalse(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create mock db: %v", err)
}
defer db.Close()
mock.ExpectQuery(`SELECT EXISTS \(SELECT 1 FROM pg_proc WHERE proname = \$1 LIMIT 1\)`).
WithArgs("resolvespec_login").
WillReturnError(sql.ErrConnDone)
if probeFunctionExists(context.Background(), db, "resolvespec_login") {
t.Error("expected probeFunctionExists to return false on query error")
}
}
func TestProbeFunctionExists_NilDB(t *testing.T) {
if probeFunctionExists(context.Background(), nil, "resolvespec_login") {
t.Error("expected probeFunctionExists(nil) to return false")
}
}
func TestRewritePlaceholders_NonPostgres_NoOp(t *testing.T) {
db := openTestSQLite(t)
q := "SELECT * FROM users WHERE id = ? AND name = ?"
if got := rewritePlaceholders(db, q); got != q {
t.Errorf("rewritePlaceholders on non-Postgres = %q, want unchanged %q", got, q)
}
}
func TestGenerateSessionToken_Format(t *testing.T) {
token, err := generateSessionToken()
if err != nil {
t.Fatalf("generateSessionToken() error = %v", err)
}
if len(token) < len("sess_")+64+2 {
t.Errorf("generateSessionToken() = %q, unexpected length", token)
}
if token[:5] != "sess_" {
t.Errorf("generateSessionToken() = %q, want prefix 'sess_'", token)
}
}
+101
View File
@@ -0,0 +1,101 @@
package security
import (
"errors"
"fmt"
"reflect"
)
// ErrDirectModeUnsupported is returned by Direct-mode operations that have no
// portable equivalent because they depend on an external schema this package
// does not own (e.g. core.secaccess / core.hub_link for column/row security).
// Callers needing that functionality must run against Postgres with the
// resolvespec_column_security / resolvespec_row_security stored procedures
// installed (ModeProcedure or ModeAuto with the procedures present).
var ErrDirectModeUnsupported = errors.New("direct mode does not support column/row security; requires the resolvespec_column_security/resolvespec_row_security stored procedures")
// TableNames defines all configurable table names used by Direct-mode SQL
// in the security package. Override individual fields to remap to custom
// table names. Use DefaultTableNames() for baseline defaults, and
// MergeTableNames() to apply partial overrides.
type TableNames struct {
Users string // default: "users"
UserSessions string // default: "user_sessions"
TokenBlacklist string // default: "token_blacklist"
UserTOTPBackupCodes string // default: "user_totp_backup_codes"
UserPasskeyCredentials string // default: "user_passkey_credentials"
UserPasswordResets string // default: "user_password_resets"
OAuthClients string // default: "oauth_clients"
OAuthCodes string // default: "oauth_codes"
}
// DefaultTableNames returns a TableNames with all default table names.
func DefaultTableNames() *TableNames {
return &TableNames{
Users: "users",
UserSessions: "user_sessions",
TokenBlacklist: "token_blacklist",
UserTOTPBackupCodes: "user_totp_backup_codes",
UserPasskeyCredentials: "user_passkey_credentials",
UserPasswordResets: "user_password_resets",
OAuthClients: "oauth_clients",
OAuthCodes: "oauth_codes",
}
}
// MergeTableNames returns a copy of base with any non-empty fields from override applied.
// If override is nil, a copy of base is returned.
func MergeTableNames(base, override *TableNames) *TableNames {
if override == nil {
copied := *base
return &copied
}
merged := *base
if override.Users != "" {
merged.Users = override.Users
}
if override.UserSessions != "" {
merged.UserSessions = override.UserSessions
}
if override.TokenBlacklist != "" {
merged.TokenBlacklist = override.TokenBlacklist
}
if override.UserTOTPBackupCodes != "" {
merged.UserTOTPBackupCodes = override.UserTOTPBackupCodes
}
if override.UserPasskeyCredentials != "" {
merged.UserPasskeyCredentials = override.UserPasskeyCredentials
}
if override.UserPasswordResets != "" {
merged.UserPasswordResets = override.UserPasswordResets
}
if override.OAuthClients != "" {
merged.OAuthClients = override.OAuthClients
}
if override.OAuthCodes != "" {
merged.OAuthCodes = override.OAuthCodes
}
return &merged
}
// ValidateTableNames checks that all non-empty fields in names are valid SQL identifiers.
func ValidateTableNames(names *TableNames) error {
v := reflect.ValueOf(names).Elem()
typ := v.Type()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
if field.Kind() != reflect.String {
continue
}
val := field.String()
if val != "" && !validSQLIdentifier.MatchString(val) {
return fmt.Errorf("TableNames.%s contains invalid characters: %q", typ.Field(i).Name, val)
}
}
return nil
}
// resolveTableNames merges an optional override with defaults.
func resolveTableNames(override *TableNames) *TableNames {
return MergeTableNames(DefaultTableNames(), override)
}
+134
View File
@@ -0,0 +1,134 @@
package security
import (
"reflect"
"testing"
)
func TestDefaultTableNames_AllFieldsNonEmpty(t *testing.T) {
names := DefaultTableNames()
v := reflect.ValueOf(names).Elem()
typ := v.Type()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
if field.Kind() != reflect.String {
continue
}
if field.String() == "" {
t.Errorf("DefaultTableNames().%s is empty", typ.Field(i).Name)
}
}
}
func TestMergeTableNames_PartialOverride(t *testing.T) {
base := DefaultTableNames()
override := &TableNames{Users: "custom_users", OAuthCodes: "custom_oauth_codes"}
merged := MergeTableNames(base, override)
if merged.Users != "custom_users" {
t.Errorf("MergeTableNames().Users = %q, want %q", merged.Users, "custom_users")
}
if merged.OAuthCodes != "custom_oauth_codes" {
t.Errorf("MergeTableNames().OAuthCodes = %q, want %q", merged.OAuthCodes, "custom_oauth_codes")
}
if merged.UserSessions != "user_sessions" {
t.Errorf("MergeTableNames().UserSessions = %q, want default", merged.UserSessions)
}
}
func TestMergeTableNames_NilOverride(t *testing.T) {
base := DefaultTableNames()
merged := MergeTableNames(base, nil)
if merged == base {
t.Error("MergeTableNames with nil override should return a copy, not the same pointer")
}
if *merged != *base {
t.Errorf("MergeTableNames(base, nil) = %+v, want %+v", merged, base)
}
}
func TestMergeTableNames_DoesNotMutateBase(t *testing.T) {
base := DefaultTableNames()
original := base.Users
override := &TableNames{Users: "custom_users"}
_ = MergeTableNames(base, override)
if base.Users != original {
t.Errorf("MergeTableNames mutated base: Users = %q, want %q", base.Users, original)
}
}
func TestValidateTableNames_Valid(t *testing.T) {
if err := ValidateTableNames(DefaultTableNames()); err != nil {
t.Errorf("ValidateTableNames(defaults) error = %v", err)
}
}
func TestValidateTableNames_Invalid(t *testing.T) {
names := DefaultTableNames()
names.Users = "users; DROP TABLE users; --"
if err := ValidateTableNames(names); err == nil {
t.Error("ValidateTableNames should reject names with invalid characters")
}
}
func TestResolveTableNames_NoOverride(t *testing.T) {
names := resolveTableNames(nil)
if names.Users != "users" {
t.Errorf("resolveTableNames(nil).Users = %q, want default", names.Users)
}
}
func TestResolveTableNames_WithOverride(t *testing.T) {
names := resolveTableNames(&TableNames{Users: "custom_users"})
if names.Users != "custom_users" {
t.Errorf("resolveTableNames().Users = %q, want %q", names.Users, "custom_users")
}
if names.UserSessions != "user_sessions" {
t.Errorf("resolveTableNames().UserSessions = %q, want default", names.UserSessions)
}
}
func TestDefaultKeyStoreTableNames(t *testing.T) {
names := DefaultKeyStoreTableNames()
if names.UserKeys != "user_keys" {
t.Errorf("DefaultKeyStoreTableNames().UserKeys = %q, want %q", names.UserKeys, "user_keys")
}
}
func TestMergeKeyStoreTableNames_PartialOverride(t *testing.T) {
base := DefaultKeyStoreTableNames()
merged := MergeKeyStoreTableNames(base, &KeyStoreTableNames{UserKeys: "custom_keys"})
if merged.UserKeys != "custom_keys" {
t.Errorf("MergeKeyStoreTableNames().UserKeys = %q, want %q", merged.UserKeys, "custom_keys")
}
}
func TestMergeKeyStoreTableNames_NilOverride(t *testing.T) {
base := DefaultKeyStoreTableNames()
merged := MergeKeyStoreTableNames(base, nil)
if merged == base {
t.Error("MergeKeyStoreTableNames with nil override should return a copy, not the same pointer")
}
if *merged != *base {
t.Errorf("MergeKeyStoreTableNames(base, nil) = %+v, want %+v", merged, base)
}
}
func TestValidateKeyStoreTableNames_Invalid(t *testing.T) {
names := &KeyStoreTableNames{UserKeys: "bad name!"}
if err := ValidateKeyStoreTableNames(names); err == nil {
t.Error("ValidateKeyStoreTableNames should reject names with invalid characters")
}
}
func TestValidateKeyStoreTableNames_Valid(t *testing.T) {
if err := ValidateKeyStoreTableNames(DefaultKeyStoreTableNames()); err != nil {
t.Errorf("ValidateKeyStoreTableNames(defaults) error = %v", err)
}
}
+10
View File
@@ -43,14 +43,24 @@ func (m *MockAuthenticator) Login(ctx context.Context, req security.LoginRequest
}, nil
}
func (m *MockAuthenticator) LoginWithCookie(ctx context.Context, req security.LoginRequest, _ http.ResponseWriter) (*security.LoginResponse, error) {
return m.Login(ctx, req)
}
func (m *MockAuthenticator) Logout(ctx context.Context, req security.LogoutRequest) error {
return nil
}
func (m *MockAuthenticator) LogoutWithCookie(ctx context.Context, req security.LogoutRequest, _ http.ResponseWriter) error {
return m.Logout(ctx, req)
}
func (m *MockAuthenticator) Authenticate(r *http.Request) (*security.UserContext, error) {
return m.users["testuser"], nil
}
func (m *MockAuthenticator) SetAuthenticateCallback(_ func(r *http.Request) (*security.UserContext, error)) {}
func TestTwoFactorAuthenticator_Setup(t *testing.T) {
baseAuth := NewMockAuthenticator()
provider := security.NewMemoryTwoFactorProvider(nil)
+109 -12
View File
@@ -1,20 +1,27 @@
package security
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"sync"
)
// DatabaseTwoFactorProvider implements TwoFactorAuthProvider using PostgreSQL stored procedures
// Procedure names are configurable via SQLNames (see DefaultSQLNames for defaults)
// See totp_database_schema.sql for procedure definitions
type DatabaseTwoFactorProvider struct {
db *sql.DB
totpGen *TOTPGenerator
sqlNames *SQLNames
db *sql.DB
dbMu sync.RWMutex
dbFactory func() (*sql.DB, error)
totpGen *TOTPGenerator
sqlNames *SQLNames
tableNames *TableNames
queryMode QueryMode
capability *dbCapability
}
// NewDatabaseTwoFactorProvider creates a new database-backed 2FA provider
@@ -23,12 +30,69 @@ func NewDatabaseTwoFactorProvider(db *sql.DB, config *TwoFactorConfig, names ...
config = DefaultTwoFactorConfig()
}
return &DatabaseTwoFactorProvider{
db: db,
totpGen: NewTOTPGenerator(config),
sqlNames: resolveSQLNames(names...),
db: db,
totpGen: NewTOTPGenerator(config),
sqlNames: resolveSQLNames(names...),
tableNames: DefaultTableNames(),
capability: newDBCapability(),
}
}
// WithDBFactory configures a factory used to reopen the database connection if it is closed.
func (p *DatabaseTwoFactorProvider) WithDBFactory(factory func() (*sql.DB, error)) *DatabaseTwoFactorProvider {
p.dbFactory = factory
return p
}
// WithTableNames configures Direct-mode table names. If names is nil, defaults are used.
func (p *DatabaseTwoFactorProvider) WithTableNames(names *TableNames) *DatabaseTwoFactorProvider {
p.tableNames = resolveTableNames(names)
return p
}
// WithQueryMode selects stored-procedure vs Direct-mode SQL (default ModeAuto).
func (p *DatabaseTwoFactorProvider) WithQueryMode(mode QueryMode) *DatabaseTwoFactorProvider {
p.queryMode = mode
return p
}
func (p *DatabaseTwoFactorProvider) getDB() *sql.DB {
p.dbMu.RLock()
defer p.dbMu.RUnlock()
return p.db
}
func (p *DatabaseTwoFactorProvider) reconnectDB() error {
if p.dbFactory == nil {
return fmt.Errorf("no db factory configured for reconnect")
}
newDB, err := p.dbFactory()
if err != nil {
return err
}
p.dbMu.Lock()
p.db = newDB
p.dbMu.Unlock()
if p.capability != nil {
p.capability.reset()
}
return nil
}
func (p *DatabaseTwoFactorProvider) runDBOpWithReconnect(run func(*sql.DB) error) error {
db := p.getDB()
if db == nil {
return fmt.Errorf("database connection is nil")
}
err := run(db)
if isDBClosed(err) {
if reconnErr := p.reconnectDB(); reconnErr == nil {
err = run(p.getDB())
}
}
return err
}
// Generate2FASecret creates a new secret for a user
func (p *DatabaseTwoFactorProvider) Generate2FASecret(userID int, issuer, accountName string) (*TwoFactorSecret, error) {
secret, err := p.totpGen.GenerateSecret()
@@ -72,12 +136,17 @@ func (p *DatabaseTwoFactorProvider) Enable2FA(userID int, secret string, backupC
return fmt.Errorf("failed to marshal backup codes: %w", err)
}
ctx := context.Background()
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPEnable) {
return p.enable2FADirect(ctx, userID, secret, hashedCodes)
}
// Call stored procedure
var success bool
var errorMsg sql.NullString
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1, $2, $3::jsonb)`, p.sqlNames.TOTPEnable)
err = p.db.QueryRow(query, userID, secret, string(codesJSON)).Scan(&success, &errorMsg)
err = p.getDB().QueryRow(query, userID, secret, string(codesJSON)).Scan(&success, &errorMsg)
if err != nil {
return fmt.Errorf("enable 2FA query failed: %w", err)
}
@@ -94,11 +163,16 @@ func (p *DatabaseTwoFactorProvider) Enable2FA(userID int, secret string, backupC
// Disable2FA deactivates 2FA for a user
func (p *DatabaseTwoFactorProvider) Disable2FA(userID int) error {
ctx := context.Background()
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPDisable) {
return p.disable2FADirect(ctx, userID)
}
var success bool
var errorMsg sql.NullString
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1)`, p.sqlNames.TOTPDisable)
err := p.db.QueryRow(query, userID).Scan(&success, &errorMsg)
err := p.getDB().QueryRow(query, userID).Scan(&success, &errorMsg)
if err != nil {
return fmt.Errorf("disable 2FA query failed: %w", err)
}
@@ -115,12 +189,17 @@ func (p *DatabaseTwoFactorProvider) Disable2FA(userID int) error {
// Get2FAStatus checks if user has 2FA enabled
func (p *DatabaseTwoFactorProvider) Get2FAStatus(userID int) (bool, error) {
ctx := context.Background()
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPGetStatus) {
return p.get2FAStatusDirect(ctx, userID)
}
var success bool
var errorMsg sql.NullString
var enabled bool
query := fmt.Sprintf(`SELECT p_success, p_error, p_enabled FROM %s($1)`, p.sqlNames.TOTPGetStatus)
err := p.db.QueryRow(query, userID).Scan(&success, &errorMsg, &enabled)
err := p.getDB().QueryRow(query, userID).Scan(&success, &errorMsg, &enabled)
if err != nil {
return false, fmt.Errorf("get 2FA status query failed: %w", err)
}
@@ -137,12 +216,17 @@ func (p *DatabaseTwoFactorProvider) Get2FAStatus(userID int) (bool, error) {
// Get2FASecret retrieves the user's 2FA secret
func (p *DatabaseTwoFactorProvider) Get2FASecret(userID int) (string, error) {
ctx := context.Background()
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPGetSecret) {
return p.get2FASecretDirect(ctx, userID)
}
var success bool
var errorMsg sql.NullString
var secret sql.NullString
query := fmt.Sprintf(`SELECT p_success, p_error, p_secret FROM %s($1)`, p.sqlNames.TOTPGetSecret)
err := p.db.QueryRow(query, userID).Scan(&success, &errorMsg, &secret)
err := p.getDB().QueryRow(query, userID).Scan(&success, &errorMsg, &secret)
if err != nil {
return "", fmt.Errorf("get 2FA secret query failed: %w", err)
}
@@ -175,6 +259,14 @@ func (p *DatabaseTwoFactorProvider) GenerateBackupCodes(userID int, count int) (
hashedCodes[i] = hex.EncodeToString(hash[:])
}
ctx := context.Background()
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPRegenerateBackup) {
if err := p.regenerateBackupCodesDirect(ctx, userID, hashedCodes); err != nil {
return nil, err
}
return codes, nil
}
// Convert to JSON array
codesJSON, err := json.Marshal(hashedCodes)
if err != nil {
@@ -186,7 +278,7 @@ func (p *DatabaseTwoFactorProvider) GenerateBackupCodes(userID int, count int) (
var errorMsg sql.NullString
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1, $2::jsonb)`, p.sqlNames.TOTPRegenerateBackup)
err = p.db.QueryRow(query, userID, string(codesJSON)).Scan(&success, &errorMsg)
err = p.getDB().QueryRow(query, userID, string(codesJSON)).Scan(&success, &errorMsg)
if err != nil {
return nil, fmt.Errorf("regenerate backup codes query failed: %w", err)
}
@@ -208,12 +300,17 @@ func (p *DatabaseTwoFactorProvider) ValidateBackupCode(userID int, code string)
hash := sha256.Sum256([]byte(code))
codeHash := hex.EncodeToString(hash[:])
ctx := context.Background()
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPValidateBackupCode) {
return p.validateBackupCodeDirect(ctx, userID, codeHash)
}
var success bool
var errorMsg sql.NullString
var valid bool
query := fmt.Sprintf(`SELECT p_success, p_error, p_valid FROM %s($1, $2)`, p.sqlNames.TOTPValidateBackupCode)
err := p.db.QueryRow(query, userID, codeHash).Scan(&success, &errorMsg, &valid)
err := p.getDB().QueryRow(query, userID, codeHash).Scan(&success, &errorMsg, &valid)
if err != nil {
return false, fmt.Errorf("validate backup code query failed: %w", err)
}
@@ -0,0 +1,151 @@
package security
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// Direct-mode implementations mirroring the resolvespec_totp_* stored
// procedures in database_schema.sql using plain SQL against TableNames.
func (p *DatabaseTwoFactorProvider) enable2FADirect(ctx context.Context, userID int, secret string, hashedCodes []string) error {
return p.runDBOpWithReconnect(func(db *sql.DB) error {
updQuery := rewritePlaceholders(db, fmt.Sprintf(
`UPDATE %s SET totp_secret = ?, totp_enabled = ?, totp_enabled_at = ? WHERE id = ?`, p.tableNames.Users))
res, err := db.ExecContext(ctx, updQuery, secret, true, time.Now(), userID)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err != nil {
return err
} else if rows == 0 {
return fmt.Errorf("user not found")
}
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
if _, err := db.ExecContext(ctx, delQuery, userID); err != nil {
return err
}
insQuery := rewritePlaceholders(db, fmt.Sprintf(`INSERT INTO %s (user_id, code_hash) VALUES (?, ?)`, p.tableNames.UserTOTPBackupCodes))
for _, hash := range hashedCodes {
if _, err := db.ExecContext(ctx, insQuery, userID, hash); err != nil {
return err
}
}
return nil
})
}
func (p *DatabaseTwoFactorProvider) disable2FADirect(ctx context.Context, userID int) error {
return p.runDBOpWithReconnect(func(db *sql.DB) error {
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET totp_secret = NULL, totp_enabled = ? WHERE id = ?`, p.tableNames.Users))
res, err := db.ExecContext(ctx, updQuery, false, userID)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err != nil {
return err
} else if rows == 0 {
return fmt.Errorf("user not found")
}
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
_, err = db.ExecContext(ctx, delQuery, userID)
return err
})
}
func (p *DatabaseTwoFactorProvider) get2FAStatusDirect(ctx context.Context, userID int) (bool, error) {
var enabled sql.NullBool
err := p.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT totp_enabled FROM %s WHERE id = ?`, p.tableNames.Users))
return db.QueryRowContext(ctx, query, userID).Scan(&enabled)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, fmt.Errorf("user not found")
}
return false, fmt.Errorf("get 2FA status query failed: %w", err)
}
return enabled.Bool, nil
}
func (p *DatabaseTwoFactorProvider) get2FASecretDirect(ctx context.Context, userID int) (string, error) {
var secret sql.NullString
var enabled sql.NullBool
err := p.runDBOpWithReconnect(func(db *sql.DB) error {
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT totp_secret, totp_enabled FROM %s WHERE id = ?`, p.tableNames.Users))
return db.QueryRowContext(ctx, query, userID).Scan(&secret, &enabled)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("user not found")
}
return "", fmt.Errorf("get 2FA secret query failed: %w", err)
}
if !enabled.Bool {
return "", fmt.Errorf("TOTP not enabled for user")
}
return secret.String, nil
}
func (p *DatabaseTwoFactorProvider) regenerateBackupCodesDirect(ctx context.Context, userID int, hashedCodes []string) error {
return p.runDBOpWithReconnect(func(db *sql.DB) error {
var count int
checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE id = ? AND totp_enabled = ?`, p.tableNames.Users))
if err := db.QueryRowContext(ctx, checkQuery, userID, true).Scan(&count); err != nil {
return err
}
if count == 0 {
return fmt.Errorf("user not found or TOTP not enabled")
}
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
if _, err := db.ExecContext(ctx, delQuery, userID); err != nil {
return err
}
insQuery := rewritePlaceholders(db, fmt.Sprintf(`INSERT INTO %s (user_id, code_hash) VALUES (?, ?)`, p.tableNames.UserTOTPBackupCodes))
for _, hash := range hashedCodes {
if _, err := db.ExecContext(ctx, insQuery, userID, hash); err != nil {
return err
}
}
return nil
})
}
func (p *DatabaseTwoFactorProvider) validateBackupCodeDirect(ctx context.Context, userID int, codeHash string) (bool, error) {
var valid bool
err := p.runDBOpWithReconnect(func(db *sql.DB) error {
var codeID int
var used bool
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT id, used FROM %s WHERE user_id = ? AND code_hash = ?`, p.tableNames.UserTOTPBackupCodes))
err := db.QueryRowContext(ctx, query, userID, codeHash).Scan(&codeID, &used)
if errors.Is(err, sql.ErrNoRows) {
valid = false
return nil
}
if err != nil {
return err
}
if used {
return fmt.Errorf("backup code already used")
}
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET used = ?, used_at = ? WHERE id = ?`, p.tableNames.UserTOTPBackupCodes))
if _, err := db.ExecContext(ctx, updQuery, true, time.Now(), codeID); err != nil {
return err
}
valid = true
return nil
})
if err != nil {
return false, err
}
return valid, nil
}
+1
View File
@@ -16,6 +16,7 @@ func FromConfigInstanceToServerConfig(sic *config.ServerInstanceConfig, handler
Description: sic.Description,
Handler: handler,
GZIP: sic.GZIP,
HTTP2: sic.HTTP2,
SSLCert: sic.SSLCert,
SSLKey: sic.SSLKey,
+8
View File
@@ -19,6 +19,10 @@ type Config struct {
// GZIP compression support
GZIP bool
// HTTP2 enables HTTP/2 with the Extended CONNECT protocol (RFC 8441) for WebSocket support.
// Requires TLS; pair with SSLCert/SSLKey, SelfSignedSSL, or AutoTLS.
HTTP2 bool
// TLS/HTTPS configuration options (mutually exclusive)
// Option 1: Provide certificate and key files directly
SSLCert string
@@ -38,6 +42,10 @@ type Config struct {
// AutoTLSEmail is the email for Let's Encrypt registration (optional but recommended)
AutoTLSEmail string
// PanicHandler is called when a request handler panics.
// If nil, the default middleware.PanicRecovery is used (logs, records metric, returns 500).
PanicHandler func(w http.ResponseWriter, r *http.Request, rcv any)
// Graceful shutdown configuration
// ShutdownTimeout is the maximum time to wait for graceful shutdown
// Default: 30 seconds
+50 -10
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"sync"
"sync/atomic"
"syscall"
@@ -451,8 +452,19 @@ func newInstance(cfg Config) (*serverInstance, error) {
handler = gz(handler)
}
// Wrap with the panic recovery middleware
handler = middleware.PanicRecovery(handler)
// Wrap with panic recovery — use caller-supplied handler if provided
if cfg.PanicHandler != nil {
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rcv := recover(); rcv != nil {
cfg.PanicHandler(w, r, rcv)
}
}()
handler.ServeHTTP(w, r)
})
} else {
handler = middleware.PanicRecovery(handler)
}
// Configure TLS if any TLS option is enabled
tlsConfig, certFile, keyFile, err := configureTLS(cfg)
@@ -461,15 +473,43 @@ func newInstance(cfg Config) (*serverInstance, error) {
}
// Create gracefulServer
httpServer := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
TLSConfig: tlsConfig,
}
// Enable HTTP/2 with Extended CONNECT (RFC 8441) for WebSocket-over-H2 support.
// The GODEBUG=http2xconnect=1 flag is read by net/http's init(); setting it here
// ensures it propagates to subprocesses and any future process restarts.
// For the current process, set GODEBUG=http2xconnect=1 in the environment before launch.
if httpServer.Protocols == nil {
httpServer.Protocols = &http.Protocols{}
httpServer.Protocols.SetHTTP1(true)
}
if cfg.HTTP2 {
if existing := os.Getenv("GODEBUG"); !strings.Contains(existing, "http2xconnect=1") {
if existing == "" {
os.Setenv("GODEBUG", "http2xconnect=1")
} else {
os.Setenv("GODEBUG", existing+",http2xconnect=1")
}
}
if httpServer.HTTP2 == nil {
httpServer.HTTP2 = &http.HTTP2Config{}
}
httpServer.Protocols.SetHTTP2(true)
httpServer.Protocols.SetUnencryptedHTTP2(true)
} else {
httpServer.Protocols.SetHTTP1(true)
httpServer.Protocols.SetHTTP2(false)
}
gracefulSrv := &gracefulServer{
server: &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
TLSConfig: tlsConfig,
},
server: httpServer,
shutdownTimeout: cfg.ShutdownTimeout,
drainTimeout: cfg.DrainTimeout,
shutdownComplete: make(chan struct{}),
+20 -1
View File
@@ -70,16 +70,35 @@ func (m *mountPoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Try to open the file
file, err := m.provider.Open(strings.TrimPrefix(filePath, "/"))
if err != nil {
// For extensionless paths, also try path/index.html
if path.Ext(filePath) == "" {
indexFallback := path.Join(filePath, "index.html")
file, err = m.provider.Open(strings.TrimPrefix(indexFallback, "/"))
if err == nil {
defer file.Close()
m.serveFile(w, r, indexFallback, file)
return
}
indexFallback = fmt.Sprintf("%s.html", filePath)
file, err = m.provider.Open(strings.TrimPrefix(indexFallback, "/"))
if err == nil {
defer file.Close()
m.serveFile(w, r, indexFallback, file)
return
}
}
// File doesn't exist - check if we should use fallback
if m.fallbackStrategy != nil && m.fallbackStrategy.ShouldFallback(filePath) {
fallbackPath := m.fallbackStrategy.GetFallbackPath(filePath)
file, err = m.provider.Open(strings.TrimPrefix(fallbackPath, "/"))
if err == nil {
// Successfully opened fallback file
defer file.Close()
m.serveFile(w, r, fallbackPath, file)
return
}
}
// No fallback or fallback failed - return 404
+8 -2
View File
@@ -49,9 +49,15 @@ func (f *HTMLFallbackStrategy) GetFallbackPath(filePath string) string {
return f.indexFile
}
// isStaticAsset checks if the path looks like a static asset (has a file extension).
// isStaticAsset checks if the path looks like a static asset (has a non-HTML file extension).
// Paths with no extension are not considered static assets so fallback logic can resolve
// them to path.html or path/index.html.
func (f *HTMLFallbackStrategy) isStaticAsset(filePath string) bool {
return path.Ext(filePath) != ""
ext := strings.ToLower(path.Ext(filePath))
if ext == "" || ext == ".html" || ext == ".htm" {
return false
}
return true
}
// ExtensionBasedFallback implements a fallback strategy that skips fallback for known static file extensions.
+25 -6
View File
@@ -3,6 +3,7 @@ package websocketspec
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
@@ -17,6 +18,17 @@ import (
"github.com/bitechdev/ResolveSpec/pkg/reflection"
)
// newErrorResponseFromErr creates an error response from a Go error, including the SQL
// query in the error info when the error is a database SQLError.
func newErrorResponseFromErr(id, code string, err error) *ResponseMessage {
resp := NewErrorResponse(id, code, err.Error())
var sqlErr *common.SQLError
if errors.As(err, &sqlErr) {
resp.Error.SQL = sqlErr.SQL
}
return resp
}
// Handler handles WebSocket connections and messages
type Handler struct {
db common.Database
@@ -174,6 +186,7 @@ func (h *Handler) handleRequest(conn *Connection, msg *Message) {
Options: msg.Options,
ID: recordID,
Data: msg.Data,
Tx: h.db,
Metadata: make(map[string]interface{}),
}
@@ -235,7 +248,7 @@ func (h *Handler) handleRead(conn *Connection, msg *Message, hookCtx *HookContex
if err != nil {
logger.Error("[WebSocketSpec] Read operation failed: %v", err)
errResp := NewErrorResponse(msg.ID, "read_error", err.Error())
errResp := newErrorResponseFromErr(msg.ID, "read_error", err)
_ = conn.SendJSON(errResp)
return
}
@@ -271,7 +284,7 @@ func (h *Handler) handleCreate(conn *Connection, msg *Message, hookCtx *HookCont
data, err := h.create(hookCtx)
if err != nil {
logger.Error("[WebSocketSpec] Create operation failed: %v", err)
errResp := NewErrorResponse(msg.ID, "create_error", err.Error())
errResp := newErrorResponseFromErr(msg.ID, "create_error", err)
_ = conn.SendJSON(errResp)
return
}
@@ -309,7 +322,7 @@ func (h *Handler) handleUpdate(conn *Connection, msg *Message, hookCtx *HookCont
data, err := h.update(hookCtx)
if err != nil {
logger.Error("[WebSocketSpec] Update operation failed: %v", err)
errResp := NewErrorResponse(msg.ID, "update_error", err.Error())
errResp := newErrorResponseFromErr(msg.ID, "update_error", err)
_ = conn.SendJSON(errResp)
return
}
@@ -347,7 +360,7 @@ func (h *Handler) handleDelete(conn *Connection, msg *Message, hookCtx *HookCont
err := h.delete(hookCtx)
if err != nil {
logger.Error("[WebSocketSpec] Delete operation failed: %v", err)
errResp := NewErrorResponse(msg.ID, "delete_error", err.Error())
errResp := newErrorResponseFromErr(msg.ID, "delete_error", err)
_ = conn.SendJSON(errResp)
return
}
@@ -658,6 +671,12 @@ func (h *Handler) create(hookCtx *HookContext) (interface{}, error) {
return nil, fmt.Errorf("failed to create record: %w", err)
}
// Re-fetch the created record to capture DB-generated defaults/triggers.
if pkVal := reflection.GetPrimaryKeyValue(hookCtx.ModelPtr); pkVal != nil {
hookCtx.ID = fmt.Sprintf("%v", pkVal)
return h.readByID(hookCtx)
}
return hookCtx.ModelPtr, nil
}
@@ -821,7 +840,7 @@ func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionStr
func (h *Handler) setRowNumbersOnRecords(records interface{}, offset int) {
// Get the reflect value of the records
recordsValue := reflect.ValueOf(records)
if recordsValue.Kind() == reflect.Ptr {
if recordsValue.Kind() == reflect.Pointer {
recordsValue = recordsValue.Elem()
}
@@ -836,7 +855,7 @@ func (h *Handler) setRowNumbersOnRecords(records interface{}, offset int) {
record := recordsValue.Index(i)
// Dereference if it's a pointer
if record.Kind() == reflect.Ptr {
if record.Kind() == reflect.Pointer {
if record.IsNil() {
continue
}
+5
View File
@@ -239,6 +239,11 @@ func (m *MockInsertQuery) Exec(ctx context.Context) (common.Result, error) {
return args.Get(0).(common.Result), args.Error(1)
}
func (m *MockInsertQuery) Scan(ctx context.Context, dest interface{}) error {
args := m.Called(ctx, dest)
return args.Error(0)
}
// MockUpdateQuery is a mock implementation of common.UpdateQuery
type MockUpdateQuery struct {
mock.Mock
+3
View File
@@ -111,6 +111,9 @@ type HookContext struct {
AbortMessage string // Message to return if aborted
AbortCode int // HTTP status code if aborted
// Tx provides access to the database/transaction for executing additional SQL
Tx common.Database
// Metadata is additional context data
Metadata map[string]interface{}
}
+3
View File
@@ -99,6 +99,9 @@ type ErrorInfo struct {
// Details contains additional error context
Details map[string]interface{} `json:"details,omitempty"`
// SQL is the query that caused the error, populated for database errors
SQL string `json:"sql,omitempty"`
}
// RequestMessage represents a client request
+11
View File
@@ -71,6 +71,17 @@ func (s *securityContext) GetUserID() (int, bool) {
return security.GetUserID(s.ctx.Context)
}
// GetUserRef returns an opaque user identifier for row security lookups.
// It prefers the full *security.UserContext (so providers can read JWT claims,
// e.g. a UUID subject) and falls back to the int user ID.
func (s *securityContext) GetUserRef() (any, bool) {
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
return userCtx, true
}
userID, ok := security.GetUserID(s.ctx.Context)
return userID, ok
}
func (s *securityContext) GetSchema() string {
return s.ctx.Schema
}