mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-07-07 19:37:38 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eee83f9dc6 | |||
| 8a06aacfb2 | |||
| 705c4f8001 | |||
| d648614611 | |||
| 3f86eb0f06 | |||
| 3dac55cb19 | |||
| bbb2c6d127 | |||
| 3fec7b1a90 | |||
| 910390f62d | |||
| b9bed67bd7 | |||
| 11ef16f75a | |||
| 48b72a7631 | |||
| 4c512acf25 | |||
| 07a402634e | |||
| 0e8f8925c6 | |||
| 5a359a160b | |||
| a2799fa224 | |||
| 1419542650 | |||
| c120b49529 | |||
| 66348dac97 | |||
| a87cd18b1b | |||
| 29449c93d5 | |||
| 3b6e5c75be | |||
| 549ccb8468 | |||
| 1af9c76337 | |||
| 938a2ef3d9 | |||
| 69cc3e2839 | |||
| 4018af0636 | |||
| c4e79d6950 | |||
| 982a0e62ac | |||
| 5d459c95a7 | |||
| e9f7726e43 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -747,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()
|
||||
}
|
||||
|
||||
@@ -762,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)
|
||||
@@ -771,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)
|
||||
@@ -784,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
|
||||
@@ -792,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)
|
||||
@@ -816,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()
|
||||
}
|
||||
|
||||
@@ -884,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()
|
||||
}
|
||||
|
||||
@@ -930,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()
|
||||
}
|
||||
|
||||
@@ -1018,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()
|
||||
}
|
||||
|
||||
@@ -1083,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()
|
||||
}
|
||||
|
||||
@@ -1109,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()
|
||||
}
|
||||
|
||||
@@ -1332,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())
|
||||
@@ -1489,7 +1489,7 @@ 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
|
||||
}
|
||||
@@ -1606,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
|
||||
}
|
||||
|
||||
@@ -800,7 +800,7 @@ func (g *GormInsertQuery) Scan(ctx context.Context, dest interface{}) (err error
|
||||
col := g.returningColumns[0]
|
||||
if g.model != nil {
|
||||
val := reflect.ValueOf(g.model)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
if val.Kind() == reflect.Pointer {
|
||||
val = val.Elem()
|
||||
}
|
||||
if val.Kind() == reflect.Struct {
|
||||
|
||||
@@ -1195,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")
|
||||
}
|
||||
|
||||
@@ -1222,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
|
||||
}
|
||||
@@ -1299,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())
|
||||
@@ -1312,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())
|
||||
@@ -1329,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()
|
||||
}
|
||||
|
||||
@@ -1378,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()
|
||||
}
|
||||
|
||||
@@ -1411,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")
|
||||
}
|
||||
|
||||
@@ -1466,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -178,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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -141,7 +141,7 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
|
||||
logger.Debug("Skipping insert for %s - no data columns besides _request", tableName)
|
||||
}
|
||||
|
||||
case "update", "change":
|
||||
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)
|
||||
@@ -174,7 +174,7 @@ 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
|
||||
@@ -224,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()
|
||||
}
|
||||
|
||||
@@ -410,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()
|
||||
}
|
||||
|
||||
@@ -471,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
|
||||
@@ -586,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -713,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)
|
||||
|
||||
+52
-18
@@ -446,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)
|
||||
@@ -470,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())
|
||||
}
|
||||
@@ -614,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, "(")
|
||||
|
||||
|
||||
@@ -520,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 {
|
||||
@@ -917,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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -267,7 +290,7 @@ 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.Ptr {
|
||||
if modelType != nil && modelType.Kind() == reflect.Pointer {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
for idx := range options.Preload {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -17,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"
|
||||
)
|
||||
@@ -174,7 +175,7 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
|
||||
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, ValidSQL(strVal, "colvalue"))
|
||||
sqlquery = strings.ReplaceAll(sqlquery, kw, safeSubstituteVar(sqlquery, kw, strVal))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -367,13 +368,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 {
|
||||
@@ -533,7 +538,7 @@ func (h *Handler) SqlQuery(sqlquery string, options SqlQueryOptions) HTTPFuncTyp
|
||||
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, ValidSQL(strVal, "colvalue"))
|
||||
sqlquery = strings.ReplaceAll(sqlquery, kw, safeSubstituteVar(sqlquery, kw, strVal))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -1006,6 +1011,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 ($...$)
|
||||
@@ -1048,6 +1084,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 != "" {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func IsEmptyValue(v any) bool {
|
||||
return true
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() == reflect.Ptr {
|
||||
if rv.Kind() == reflect.Pointer {
|
||||
if rv.IsNil() {
|
||||
return true
|
||||
}
|
||||
@@ -80,12 +80,12 @@ func IsEmptyValue(v any) bool {
|
||||
// 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()
|
||||
@@ -104,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -990,7 +990,7 @@ func GetRelationType(model interface{}, fieldName string) RelationType {
|
||||
// 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.Ptr || modelType.Kind() == reflect.Slice {
|
||||
for modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
if modelType.Kind() != reflect.Struct {
|
||||
@@ -1123,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")
|
||||
}
|
||||
|
||||
@@ -1226,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 {
|
||||
@@ -1418,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())
|
||||
|
||||
@@ -1588,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
|
||||
}
|
||||
@@ -1616,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 {
|
||||
@@ -1655,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
|
||||
}
|
||||
@@ -1724,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
|
||||
|
||||
+102
-7
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
+194
-32
@@ -243,7 +243,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
|
||||
// Validate and unwrap model type to get base struct
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -602,23 +602,44 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
||||
}
|
||||
|
||||
// Standard processing without nested relations
|
||||
pkName := reflection.GetPrimaryKeyName(model)
|
||||
query := h.db.NewInsert().Table(tableName)
|
||||
for key, value := range v {
|
||||
query = query.Value(key, common.ConvertSliceForBun(value))
|
||||
}
|
||||
result, err := query.Exec(ctx)
|
||||
if err != nil {
|
||||
logger.Error("Error creating record: %v", err)
|
||||
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
|
||||
return
|
||||
var responseData interface{} = v
|
||||
if pkName == "" {
|
||||
// No PK on model — insert and return input as-is.
|
||||
result, err := query.Exec(ctx)
|
||||
if err != nil {
|
||||
logger.Error("Error creating record: %v", err)
|
||||
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
|
||||
return
|
||||
}
|
||||
logger.Info("Successfully created record, rows affected: %d", result.RowsAffected())
|
||||
} else {
|
||||
var insertedID interface{}
|
||||
if err := query.Returning(pkName).Scan(ctx, &insertedID); err != nil {
|
||||
logger.Error("Error creating record: %v", err)
|
||||
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
|
||||
return
|
||||
}
|
||||
logger.Info("Successfully created record with %s: %v", pkName, insertedID)
|
||||
fetchedRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
|
||||
if fetchErr := h.db.NewSelect().Model(fetchedRecord).
|
||||
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), insertedID).
|
||||
ScanModel(ctx); fetchErr == nil {
|
||||
responseData = mergeWithInput(fetchedRecord, v)
|
||||
} else {
|
||||
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, insertedID, fetchErr)
|
||||
}
|
||||
}
|
||||
logger.Info("Successfully created record, rows affected: %d", result.RowsAffected())
|
||||
// Invalidate cache for this table
|
||||
cacheTags := buildCacheTags(schema, tableName)
|
||||
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
|
||||
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
|
||||
}
|
||||
h.sendResponse(w, v, nil)
|
||||
h.sendResponse(w, responseData, nil)
|
||||
|
||||
case []map[string]interface{}:
|
||||
// Check if any item needs nested processing
|
||||
@@ -666,15 +687,30 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
||||
}
|
||||
|
||||
// Standard batch insert without nested relations
|
||||
pkName := reflection.GetPrimaryKeyName(model)
|
||||
modelElemType := reflection.GetPointerElement(reflect.TypeOf(model))
|
||||
originals := make([]map[string]interface{}, 0, len(v))
|
||||
insertedIDs := make([]interface{}, 0, len(v))
|
||||
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
|
||||
for _, item := range v {
|
||||
txQuery := tx.NewInsert().Table(tableName)
|
||||
for key, value := range item {
|
||||
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value))
|
||||
}
|
||||
if _, err := txQuery.Exec(ctx); err != nil {
|
||||
if pkName == "" {
|
||||
if _, err := txQuery.Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
originals = append(originals, item)
|
||||
insertedIDs = append(insertedIDs, nil)
|
||||
continue
|
||||
}
|
||||
var returnedID interface{}
|
||||
if err := txQuery.Returning(pkName).Scan(ctx, &returnedID); err != nil {
|
||||
return err
|
||||
}
|
||||
originals = append(originals, item)
|
||||
insertedIDs = append(insertedIDs, returnedID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -689,7 +725,24 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
||||
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
|
||||
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
|
||||
}
|
||||
h.sendResponse(w, v, nil)
|
||||
// Re-fetch each record after transaction commits; fall back to input on failure.
|
||||
responseItems := make([]interface{}, 0, len(insertedIDs))
|
||||
for i, pkVal := range insertedIDs {
|
||||
if pkVal == nil {
|
||||
responseItems = append(responseItems, originals[i])
|
||||
continue
|
||||
}
|
||||
fetchedRecord := reflect.New(modelElemType).Interface()
|
||||
if fetchErr := h.db.NewSelect().Model(fetchedRecord).
|
||||
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), pkVal).
|
||||
ScanModel(ctx); fetchErr == nil {
|
||||
responseItems = append(responseItems, mergeWithInput(fetchedRecord, originals[i]))
|
||||
} else {
|
||||
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, pkVal, fetchErr)
|
||||
responseItems = append(responseItems, originals[i])
|
||||
}
|
||||
}
|
||||
h.sendResponse(w, responseItems, nil)
|
||||
|
||||
case []interface{}:
|
||||
// Handle []interface{} type from JSON unmarshaling
|
||||
@@ -742,19 +795,34 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
||||
}
|
||||
|
||||
// Standard batch insert without nested relations
|
||||
list := make([]interface{}, 0)
|
||||
pkName := reflection.GetPrimaryKeyName(model)
|
||||
modelElemType := reflection.GetPointerElement(reflect.TypeOf(model))
|
||||
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 {
|
||||
if itemMap, ok := item.(map[string]interface{}); ok {
|
||||
txQuery := tx.NewInsert().Table(tableName)
|
||||
for key, value := range itemMap {
|
||||
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value))
|
||||
}
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
txQuery := tx.NewInsert().Table(tableName)
|
||||
for key, value := range itemMap {
|
||||
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value))
|
||||
}
|
||||
if pkName == "" {
|
||||
if _, err := txQuery.Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
list = append(list, item)
|
||||
originals = append(originals, itemMap)
|
||||
insertedIDs = append(insertedIDs, nil)
|
||||
continue
|
||||
}
|
||||
var returnedID interface{}
|
||||
if err := txQuery.Returning(pkName).Scan(ctx, &returnedID); err != nil {
|
||||
return err
|
||||
}
|
||||
originals = append(originals, itemMap)
|
||||
insertedIDs = append(insertedIDs, returnedID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -769,7 +837,24 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
||||
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
|
||||
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
|
||||
}
|
||||
h.sendResponse(w, list, nil)
|
||||
// Re-fetch each record after transaction commits; fall back to input on failure.
|
||||
responseItems := make([]interface{}, 0, len(insertedIDs))
|
||||
for i, pkVal := range insertedIDs {
|
||||
if pkVal == nil {
|
||||
responseItems = append(responseItems, originals[i])
|
||||
continue
|
||||
}
|
||||
fetchedRecord := reflect.New(modelElemType).Interface()
|
||||
if fetchErr := h.db.NewSelect().Model(fetchedRecord).
|
||||
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), pkVal).
|
||||
ScanModel(ctx); fetchErr == nil {
|
||||
responseItems = append(responseItems, mergeWithInput(fetchedRecord, originals[i]))
|
||||
} else {
|
||||
logger.Warn("Failed to re-fetch created record with %s=%v: %v", pkName, pkVal, fetchErr)
|
||||
responseItems = append(responseItems, originals[i])
|
||||
}
|
||||
}
|
||||
h.sendResponse(w, responseItems, nil)
|
||||
|
||||
default:
|
||||
logger.Error("Invalid data type for create operation: %T", data)
|
||||
@@ -836,7 +921,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
||||
err := h.db.RunInTransaction(ctx, func(tx common.Database) error {
|
||||
// First, read the existing record from the database
|
||||
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
|
||||
selectQuery := tx.NewSelect().Model(existingRecord).Column("*")
|
||||
selectQuery := tx.NewSelect().Model(existingRecord).Column(reflection.GetSQLModelColumns(model)...)
|
||||
|
||||
// Apply conditions to select
|
||||
if urlID != "" {
|
||||
@@ -955,13 +1040,34 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch the updated record after the transaction commits to capture any trigger changes
|
||||
updatedRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
|
||||
fetchQuery := h.db.NewSelect().Model(updatedRecord).Column(reflection.GetSQLModelColumns(model)...)
|
||||
if urlID != "" {
|
||||
fetchQuery = fetchQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), urlID)
|
||||
} else if reqID != nil {
|
||||
switch id := reqID.(type) {
|
||||
case string:
|
||||
fetchQuery = fetchQuery.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
|
||||
case []string:
|
||||
if len(id) > 0 {
|
||||
fetchQuery = fetchQuery.Where(fmt.Sprintf("%s IN (?)", common.QuoteIdent(pkName)), id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := fetchQuery.ScanModel(ctx); err != nil {
|
||||
logger.Error("Failed to fetch updated record: %v", err)
|
||||
h.sendError(w, http.StatusInternalServerError, "fetch_error", "Failed to fetch updated record", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Successfully updated record(s)")
|
||||
// Invalidate cache for this table
|
||||
cacheTags := buildCacheTags(schema, tableName)
|
||||
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
|
||||
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
|
||||
}
|
||||
h.sendResponse(w, data, nil)
|
||||
h.sendResponse(w, updatedRecord, nil)
|
||||
|
||||
case []map[string]interface{}:
|
||||
// Batch update with array of objects
|
||||
@@ -1017,7 +1123,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
||||
|
||||
// First, read the existing record
|
||||
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
|
||||
selectQuery := tx.NewSelect().Model(existingRecord).Column("*").Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
|
||||
selectQuery := tx.NewSelect().Model(existingRecord).Column(reflection.GetSQLModelColumns(model)...).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
|
||||
if err := selectQuery.ScanModel(ctx); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
continue // Skip if record not found
|
||||
@@ -1089,13 +1195,29 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
||||
h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating records", err)
|
||||
return
|
||||
}
|
||||
logger.Info("Successfully updated %d records", len(updates))
|
||||
|
||||
// Fetch updated records after the transaction commits to capture any trigger changes
|
||||
fetchedUpdates := make([]interface{}, 0, len(updates))
|
||||
for _, item := range updates {
|
||||
if itemID, ok := item["id"]; ok && itemID != nil {
|
||||
fetchedRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
|
||||
fetchQuery := h.db.NewSelect().Model(fetchedRecord).Column(reflection.GetSQLModelColumns(model)...).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
|
||||
if err := fetchQuery.ScanModel(ctx); err != nil {
|
||||
logger.Error("Failed to fetch updated record with ID %v: %v", itemID, err)
|
||||
h.sendError(w, http.StatusInternalServerError, "fetch_error", "Failed to fetch updated record", err)
|
||||
return
|
||||
}
|
||||
fetchedUpdates = append(fetchedUpdates, fetchedRecord)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Successfully updated %d records", len(fetchedUpdates))
|
||||
// Invalidate cache for this table
|
||||
cacheTags := buildCacheTags(schema, tableName)
|
||||
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
|
||||
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
|
||||
}
|
||||
h.sendResponse(w, updates, nil)
|
||||
h.sendResponse(w, fetchedUpdates, nil)
|
||||
|
||||
case []interface{}:
|
||||
// Batch update with []interface{}
|
||||
@@ -1157,7 +1279,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
||||
|
||||
// First, read the existing record
|
||||
existingRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
|
||||
selectQuery := tx.NewSelect().Model(existingRecord).Column("*").Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
|
||||
selectQuery := tx.NewSelect().Model(existingRecord).Column(reflection.GetSQLModelColumns(model)...).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
|
||||
if err := selectQuery.ScanModel(ctx); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
continue // Skip if record not found
|
||||
@@ -1232,13 +1354,31 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
||||
h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating records", err)
|
||||
return
|
||||
}
|
||||
logger.Info("Successfully updated %d records", len(list))
|
||||
|
||||
// Fetch updated records after the transaction commits to capture any trigger changes
|
||||
fetchedList := make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
if itemMap, ok := item.(map[string]interface{}); ok {
|
||||
if itemID, ok := itemMap["id"]; ok && itemID != nil {
|
||||
fetchedRecord := reflect.New(reflection.GetPointerElement(reflect.TypeOf(model))).Interface()
|
||||
fetchQuery := h.db.NewSelect().Model(fetchedRecord).Column(reflection.GetSQLModelColumns(model)...).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
|
||||
if err := fetchQuery.ScanModel(ctx); err != nil {
|
||||
logger.Error("Failed to fetch updated record with ID %v: %v", itemID, err)
|
||||
h.sendError(w, http.StatusInternalServerError, "fetch_error", "Failed to fetch updated record", err)
|
||||
return
|
||||
}
|
||||
fetchedList = append(fetchedList, fetchedRecord)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Successfully updated %d records", len(fetchedList))
|
||||
// Invalidate cache for this table
|
||||
cacheTags := buildCacheTags(schema, tableName)
|
||||
if err := invalidateCacheForTags(ctx, cacheTags); err != nil {
|
||||
logger.Warn("Failed to invalidate cache for table %s: %v", tableName, err)
|
||||
}
|
||||
h.sendResponse(w, list, nil)
|
||||
h.sendResponse(w, fetchedList, nil)
|
||||
|
||||
default:
|
||||
logger.Error("Invalid data type for update operation: %T", data)
|
||||
@@ -1407,7 +1547,7 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
||||
|
||||
// First, fetch the record that will be deleted
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Ptr {
|
||||
if modelType.Kind() == reflect.Pointer {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
recordToDelete := reflect.New(modelType).Interface()
|
||||
@@ -1682,7 +1822,7 @@ func (h *Handler) generateMetadata(schema, entity string, model interface{}) *co
|
||||
modelType := reflect.TypeOf(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()
|
||||
}
|
||||
|
||||
@@ -1826,7 +1966,7 @@ func getColumnType(field reflect.StructField) string {
|
||||
|
||||
func isNullable(field reflect.StructField) bool {
|
||||
// Check if it's a pointer type
|
||||
if field.Type.Kind() == reflect.Ptr {
|
||||
if field.Type.Kind() == reflect.Pointer {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1852,7 +1992,7 @@ func (h *Handler) applyPreloads(model interface{}, query common.SelectQuery, pre
|
||||
modelType := reflect.TypeOf(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()
|
||||
}
|
||||
|
||||
@@ -2000,7 +2140,7 @@ func toSnakeCase(s string) string {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -2015,7 +2155,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
|
||||
}
|
||||
@@ -2067,3 +2207,25 @@ func (h *Handler) HandleOpenAPI(w common.ResponseWriter, r common.Request) {
|
||||
func (h *Handler) SetOpenAPIGenerator(generator func() (string, error)) {
|
||||
h.openAPIGenerator = generator
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
+189
-39
@@ -289,7 +289,8 @@ func (h *Handler) HandleGet(w common.ResponseWriter, r common.Request, params ma
|
||||
Limit: 0,
|
||||
Offset: 0,
|
||||
}
|
||||
h.sendFormattedResponse(w, tableMetadata, responseMetadata, options)
|
||||
tableName := h.getTableName(schema, entity, model)
|
||||
h.sendFormattedResponse(w, tableMetadata, responseMetadata, tableName, model, options)
|
||||
}
|
||||
|
||||
// handleMeta processes meta operation requests
|
||||
@@ -348,7 +349,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
|
||||
// Validate and unwrap model type to get base struct
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -848,7 +849,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
return
|
||||
}
|
||||
|
||||
h.sendFormattedResponse(w, modelPtr, metadata, options)
|
||||
h.sendFormattedResponse(w, modelPtr, metadata, tableName, model, options)
|
||||
}
|
||||
|
||||
// applyPreloadWithRecursion applies a preload with support for ComputedQL and recursive preloading
|
||||
@@ -1218,8 +1219,8 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
||||
if provider, ok := modelValue.(common.TableNameProvider); !ok || provider.TableName() == "" {
|
||||
query = query.Table(tableName)
|
||||
}
|
||||
|
||||
query = query.Returning("*")
|
||||
fields := reflection.GetSQLModelColumns(model)
|
||||
query = query.Returning(fields...)
|
||||
|
||||
// Execute BeforeScan hooks - pass query chain so hooks can modify it
|
||||
itemHookCtx := &HookContext{
|
||||
@@ -1480,18 +1481,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the updated record to return the new values
|
||||
modelValue := reflect.New(reflect.TypeOf(model)).Interface()
|
||||
selectQuery = tx.NewSelect().Model(modelValue).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
|
||||
if err := selectQuery.ScanModel(ctx); err != nil {
|
||||
return fmt.Errorf("failed to fetch updated record: %w", err)
|
||||
}
|
||||
|
||||
updatedRecord = modelValue
|
||||
|
||||
// Store result for hooks
|
||||
hookCtx.Result = updatedRecord
|
||||
_ = result // Keep result variable for potential future use
|
||||
_ = result
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -1501,6 +1491,16 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch the updated record after the transaction commits to capture any trigger changes
|
||||
fetchedRecord := reflect.New(reflect.TypeOf(model)).Interface()
|
||||
selectQuery := h.db.NewSelect().Model(fetchedRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
|
||||
if err := selectQuery.ScanModel(ctx); err != nil {
|
||||
logger.Error("Failed to fetch updated record: %v", err)
|
||||
h.sendError(w, http.StatusInternalServerError, "fetch_error", "Failed to fetch updated record", err)
|
||||
return
|
||||
}
|
||||
updatedRecord = fetchedRecord
|
||||
|
||||
// Merge the updated record with the original request data
|
||||
// This preserves extra keys from the request and updates values from the database
|
||||
mergedData := h.mergeRecordWithRequest(updatedRecord, dataMap)
|
||||
@@ -1892,7 +1892,7 @@ func (h *Handler) extractNestedRelations(
|
||||
) (_cleanedData map[string]interface{}, _relations map[string]interface{}, _err error) {
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -1934,7 +1934,7 @@ func (h *Handler) processChildRelationsWithParentID(
|
||||
) error {
|
||||
// Get model type for reflection
|
||||
modelType := reflect.TypeOf(parentModel)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1990,7 +1990,7 @@ func (h *Handler) processChildRelationsForField(
|
||||
if relatedModelType.Kind() == reflect.Slice {
|
||||
relatedModelType = relatedModelType.Elem()
|
||||
}
|
||||
if relatedModelType.Kind() == reflect.Ptr {
|
||||
if relatedModelType.Kind() == reflect.Pointer {
|
||||
relatedModelType = relatedModelType.Elem()
|
||||
}
|
||||
|
||||
@@ -2012,11 +2012,15 @@ func (h *Handler) processChildRelationsForField(
|
||||
// Priority: Use foreign key field name if specified, otherwise use parent's PK name
|
||||
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)
|
||||
// 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
|
||||
}
|
||||
foreignKeyFieldName = reflection.GetJSONNameForField(relatedModelType, childField)
|
||||
if foreignKeyFieldName == "" {
|
||||
// Fallback to lowercase field name
|
||||
foreignKeyFieldName = strings.ToLower(relInfo.ForeignKey)
|
||||
foreignKeyFieldName = strings.ToLower(childField)
|
||||
}
|
||||
} else {
|
||||
// Fallback: use parent's primary key name
|
||||
@@ -2040,7 +2044,10 @@ func (h *Handler) processChildRelationsForField(
|
||||
// Process based on relation type and data structure
|
||||
switch v := relationValue.(type) {
|
||||
case map[string]interface{}:
|
||||
// Single related object - add parent ID to foreign key field
|
||||
if !isValidNestedRequest(v) {
|
||||
logger.Debug("Skipping single relation %s - missing or invalid _request value", relationName)
|
||||
return nil
|
||||
}
|
||||
// IMPORTANT: In recursive relationships, don't overwrite the primary key
|
||||
if parentID != nil && foreignKeyFieldName != "" && foreignKeyFieldName != childPKFieldName {
|
||||
v[foreignKeyFieldName] = parentID
|
||||
@@ -2057,7 +2064,10 @@ func (h *Handler) processChildRelationsForField(
|
||||
// Multiple related objects
|
||||
for i, item := range v {
|
||||
if itemMap, ok := item.(map[string]interface{}); ok {
|
||||
// Add parent ID to foreign key field
|
||||
if !isValidNestedRequest(itemMap) {
|
||||
logger.Debug("Skipping relation array[%d] %s - missing or invalid _request value", i, relationName)
|
||||
continue
|
||||
}
|
||||
// IMPORTANT: In recursive relationships, don't overwrite the primary key
|
||||
if parentID != nil && foreignKeyFieldName != "" && foreignKeyFieldName != childPKFieldName {
|
||||
itemMap[foreignKeyFieldName] = parentID
|
||||
@@ -2075,7 +2085,10 @@ func (h *Handler) processChildRelationsForField(
|
||||
case []map[string]interface{}:
|
||||
// Multiple related objects (typed slice)
|
||||
for i, itemMap := range v {
|
||||
// Add parent ID to foreign key field
|
||||
if !isValidNestedRequest(itemMap) {
|
||||
logger.Debug("Skipping relation typed array[%d] %s - missing or invalid _request value", i, relationName)
|
||||
continue
|
||||
}
|
||||
// IMPORTANT: In recursive relationships, don't overwrite the primary key
|
||||
if parentID != nil && foreignKeyFieldName != "" && foreignKeyFieldName != childPKFieldName {
|
||||
itemMap[foreignKeyFieldName] = parentID
|
||||
@@ -2096,6 +2109,24 @@ func (h *Handler) processChildRelationsForField(
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidNestedRequest returns true only when the item carries a _request key
|
||||
// whose value is one of the recognised mutation verbs.
|
||||
func isValidNestedRequest(item map[string]interface{}) bool {
|
||||
raw, ok := item["_request"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "insert", "add", "change", "update", "delete", "remove":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getTableNameForRelatedModel gets the table name for a related model.
|
||||
// If the model's TableName() is schema-qualified (e.g. "public.users") the
|
||||
// separator is adjusted for the active driver: underscore for SQLite, dot otherwise.
|
||||
@@ -2382,7 +2413,7 @@ func (h *Handler) generateMetadata(schema, entity string, model interface{}) *co
|
||||
modelType := reflect.TypeOf(model)
|
||||
|
||||
// Unwrap pointers, slices, and arrays to get to the base struct 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()
|
||||
}
|
||||
|
||||
@@ -2431,7 +2462,7 @@ func (h *Handler) generateMetadata(schema, entity string, model interface{}) *co
|
||||
// Check if this is a relation field (slice or struct, but not time.Time)
|
||||
if field.Type.Kind() == reflect.Slice ||
|
||||
(field.Type.Kind() == reflect.Struct && field.Type.Name() != "Time") ||
|
||||
(field.Type.Kind() == reflect.Ptr && field.Type.Elem().Kind() == reflect.Struct && field.Type.Elem().Name() != "Time") {
|
||||
(field.Type.Kind() == reflect.Pointer && field.Type.Elem().Kind() == reflect.Struct && field.Type.Elem().Name() != "Time") {
|
||||
metadata.Relations = append(metadata.Relations, jsonName)
|
||||
continue
|
||||
}
|
||||
@@ -2477,7 +2508,7 @@ func (h *Handler) getColumnType(t reflect.Type) string {
|
||||
return "float"
|
||||
case reflect.Bool:
|
||||
return "boolean"
|
||||
case reflect.Ptr:
|
||||
case reflect.Pointer:
|
||||
return h.getColumnType(t.Elem())
|
||||
default:
|
||||
return "unknown"
|
||||
@@ -2485,7 +2516,7 @@ func (h *Handler) getColumnType(t reflect.Type) string {
|
||||
}
|
||||
|
||||
func (h *Handler) isNullable(field reflect.StructField) bool {
|
||||
return field.Type.Kind() == reflect.Ptr
|
||||
return field.Type.Kind() == reflect.Pointer
|
||||
}
|
||||
|
||||
func (h *Handler) sendResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata) {
|
||||
@@ -2530,7 +2561,7 @@ func (h *Handler) normalizeResultArray(data interface{}) interface{} {
|
||||
|
||||
// Use reflection to check if data is a slice or array
|
||||
dataValue := reflect.ValueOf(data)
|
||||
if dataValue.Kind() == reflect.Ptr {
|
||||
if dataValue.Kind() == reflect.Pointer {
|
||||
dataValue = dataValue.Elem()
|
||||
}
|
||||
|
||||
@@ -2555,8 +2586,103 @@ func (h *Handler) normalizeResultArray(data interface{}) interface{} {
|
||||
return data
|
||||
}
|
||||
|
||||
// sendFormattedResponse sends response with formatting options
|
||||
func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata, options ExtendedRequestOptions) {
|
||||
// buildDetailFields returns the field metadata list for the detail API format,
|
||||
// containing only non-relation scalar columns derived from the model's struct tags.
|
||||
func (h *Handler) buildDetailFields(model interface{}) []reflection.ModelFieldDetail {
|
||||
if model == nil {
|
||||
return []reflection.ModelFieldDetail{}
|
||||
}
|
||||
|
||||
modelType := reflect.TypeOf(model)
|
||||
for modelType != nil && (modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
if modelType == nil || modelType.Kind() != reflect.Struct {
|
||||
return []reflection.ModelFieldDetail{}
|
||||
}
|
||||
|
||||
fields := make([]reflection.ModelFieldDetail, 0, modelType.NumField())
|
||||
for i := 0; i < modelType.NumField(); i++ {
|
||||
field := modelType.Field(i)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip relation fields (slices, structs that aren't time.Time, ptrs to struct)
|
||||
ft := field.Type
|
||||
if ft.Kind() == reflect.Pointer {
|
||||
ft = ft.Elem()
|
||||
}
|
||||
if ft.Kind() == reflect.Slice ||
|
||||
(ft.Kind() == reflect.Struct && ft.Name() != "Time") {
|
||||
continue
|
||||
}
|
||||
|
||||
jsonName := strings.Split(jsonTag, ",")[0]
|
||||
if jsonName == "" {
|
||||
jsonName = field.Name
|
||||
}
|
||||
|
||||
gormTag := field.Tag.Get("gorm")
|
||||
sqlName := fnFindTagVal(gormTag, "column:")
|
||||
if sqlName == "" {
|
||||
sqlName = jsonName
|
||||
}
|
||||
|
||||
sqlDataType := fnFindTagVal(gormTag, "type:")
|
||||
|
||||
var sqlKey string
|
||||
gormLower := strings.ToLower(gormTag)
|
||||
switch {
|
||||
case strings.Contains(gormLower, "identity") || strings.Contains(gormLower, "primary_key") || strings.Contains(gormLower, "primarykey"):
|
||||
sqlKey = "primary_key"
|
||||
case strings.Contains(gormLower, "uniqueindex"):
|
||||
sqlKey = "uniqueindex"
|
||||
case strings.Contains(gormLower, "unique"):
|
||||
sqlKey = "unique"
|
||||
}
|
||||
|
||||
nullable := field.Type.Kind() == reflect.Pointer
|
||||
if strings.Contains(gormLower, "not null") {
|
||||
nullable = false
|
||||
} else if strings.Contains(gormLower, "nullable") || strings.Contains(gormLower, ",null") {
|
||||
nullable = true
|
||||
}
|
||||
|
||||
fields = append(fields, reflection.ModelFieldDetail{
|
||||
Name: jsonName,
|
||||
DataType: h.getColumnType(field.Type),
|
||||
SQLName: sqlName,
|
||||
SQLDataType: sqlDataType,
|
||||
SQLKey: sqlKey,
|
||||
Nullable: nullable,
|
||||
})
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// fnFindTagVal extracts a value from a semicolon-separated struct tag string.
|
||||
func fnFindTagVal(tag, key string) string {
|
||||
lower := strings.ToLower(tag)
|
||||
idx := strings.Index(lower, strings.ToLower(key))
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
val := tag[idx+len(key):]
|
||||
if end := strings.Index(val, ";"); end >= 0 {
|
||||
val = val[:end]
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// sendFormattedResponse sends response with formatting options.
|
||||
// model is used when ResponseFormat is "detail" to generate the fields metadata list.
|
||||
func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{}, metadata *common.Metadata, tableName string, model interface{}, options ExtendedRequestOptions) {
|
||||
// Handle nil data - convert to empty array
|
||||
if data == nil {
|
||||
data = []interface{}{}
|
||||
@@ -2585,9 +2711,12 @@ func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{
|
||||
}
|
||||
|
||||
w.SetHeader("Content-Type", "application/json")
|
||||
w.SetHeader("Content-Range", fmt.Sprintf("%d-%d/%d", metadata.Offset, int64(metadata.Offset)+metadata.Count, metadata.Filtered))
|
||||
w.SetHeader("Content-Range", fmt.Sprintf("items %d-%d/%d", metadata.Offset, int64(metadata.Offset)+metadata.Count, metadata.Filtered))
|
||||
w.SetHeader("X-Api-Range-Total", fmt.Sprintf("%d", metadata.Filtered))
|
||||
w.SetHeader("X-Api-Range-Size", fmt.Sprintf("%d", metadata.Count))
|
||||
w.SetHeader("X-Api-Range-From", fmt.Sprintf("%d", metadata.Offset))
|
||||
w.SetHeader("X-Api-Range-Etotal", fmt.Sprintf("%d", metadata.Filtered))
|
||||
w.SetHeader("X-Api-Modelname", tableName)
|
||||
|
||||
// Format response based on response format option
|
||||
switch options.ResponseFormat {
|
||||
@@ -2609,8 +2738,29 @@ func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{
|
||||
if err := w.WriteJSON(response); err != nil {
|
||||
logger.Error("Failed to write JSON response: %v", err)
|
||||
}
|
||||
case "detail":
|
||||
// Detail format: { count, fields, items, tablename, tableprefix, total }
|
||||
var count, total int64
|
||||
if metadata != nil {
|
||||
count = metadata.Count
|
||||
total = metadata.Total
|
||||
}
|
||||
tablePrefix := reflection.ExtractTableNameOnly(tableName)
|
||||
fieldList := h.buildDetailFields(model)
|
||||
response := map[string]interface{}{
|
||||
"count": strconv.FormatInt(count, 10),
|
||||
"fields": fieldList,
|
||||
"items": data,
|
||||
"tablename": tableName,
|
||||
"tableprefix": tablePrefix,
|
||||
"total": strconv.FormatInt(total, 10),
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := w.WriteJSON(response); err != nil {
|
||||
logger.Error("Failed to write JSON response: %v", err)
|
||||
}
|
||||
default:
|
||||
// Default/detail format: standard response with metadata
|
||||
// Default format: standard response with metadata
|
||||
response := common.Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
@@ -2868,7 +3018,7 @@ func (h *Handler) buildFilterSQL(filter *common.FilterOption, tableName string)
|
||||
func (h *Handler) setRowNumbersOnRecords(records any, 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()
|
||||
}
|
||||
|
||||
@@ -2883,7 +3033,7 @@ func (h *Handler) setRowNumbersOnRecords(records any, 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
|
||||
}
|
||||
@@ -2938,7 +3088,7 @@ func (h *Handler) filterExtendedOptions(validator *common.ColumnValidator, optio
|
||||
// Filter Expand columns using the expand relation's model
|
||||
filteredExpands := make([]ExpandOption, 0, len(options.Expand))
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Ptr {
|
||||
if modelType.Kind() == reflect.Pointer {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
+25
-11
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -140,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)
|
||||
|
||||
@@ -212,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
|
||||
@@ -964,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()
|
||||
}
|
||||
|
||||
@@ -999,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()
|
||||
}
|
||||
|
||||
@@ -1049,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 {
|
||||
@@ -1076,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 {
|
||||
|
||||
+90
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,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()
|
||||
}
|
||||
|
||||
@@ -155,13 +155,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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,39 @@ 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
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetCredsByUsername) {
|
||||
_, directCreds, err := p.getCredsByUsernameDirect(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creds = directCreds
|
||||
} else {
|
||||
var success bool
|
||||
var errorMsg sql.NullString
|
||||
var userID sql.NullInt64
|
||||
var credentialsJSON sql.NullString
|
||||
|
||||
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 +328,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 +405,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 +489,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 +519,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
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context, username string) (int, []struct {
|
||||
ID string `json:"credential_id"`
|
||||
Transports []string `json:"transports"`
|
||||
}, error) {
|
||||
type credT = struct {
|
||||
ID string `json:"credential_id"`
|
||||
Transports []string `json:"transports"`
|
||||
}
|
||||
var userID int
|
||||
var creds []credT
|
||||
|
||||
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([]credT, 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, credT{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
|
||||
}
|
||||
+125
-24
@@ -71,12 +71,15 @@ 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
|
||||
@@ -105,6 +108,10 @@ 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)
|
||||
@@ -139,6 +146,7 @@ func NewDatabaseAuthenticatorWithOptions(db *sql.DB, opts DatabaseAuthenticatorO
|
||||
}
|
||||
|
||||
sqlNames := MergeSQLNames(DefaultSQLNames(), opts.SQLNames)
|
||||
tableNames := resolveTableNames(opts.TableNames)
|
||||
|
||||
return &DatabaseAuthenticator{
|
||||
db: db,
|
||||
@@ -146,6 +154,9 @@ func NewDatabaseAuthenticatorWithOptions(db *sql.DB, opts DatabaseAuthenticatorO
|
||||
cache: cacheInstance,
|
||||
cacheTTL: opts.CacheTTL,
|
||||
sqlNames: sqlNames,
|
||||
tableNames: tableNames,
|
||||
queryMode: opts.QueryMode,
|
||||
capability: newDBCapability(),
|
||||
passkeyProvider: opts.PasskeyProvider,
|
||||
enableCookieSession: opts.EnableCookieSession,
|
||||
cookieOptions: opts.CookieOptions,
|
||||
@@ -170,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
|
||||
}
|
||||
|
||||
@@ -194,6 +208,9 @@ func (a *DatabaseAuthenticator) SetAuthenticateCallback(fn func(r *http.Request)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -230,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 {
|
||||
@@ -265,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 {
|
||||
@@ -378,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
|
||||
@@ -453,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 {
|
||||
@@ -471,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
|
||||
@@ -528,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,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()
|
||||
@@ -566,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
|
||||
@@ -632,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
|
||||
|
||||
@@ -681,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 {
|
||||
@@ -713,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
|
||||
@@ -781,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 {
|
||||
@@ -813,10 +901,17 @@ 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) {
|
||||
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.RowSecurity) {
|
||||
return RowSecurity{}, ErrDirectModeUnsupported
|
||||
}
|
||||
|
||||
var template string
|
||||
var hasBlock bool
|
||||
|
||||
@@ -950,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)
|
||||
@@ -987,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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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{}),
|
||||
|
||||
@@ -70,6 +70,25 @@ 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)
|
||||
@@ -80,16 +99,6 @@ func (m *mountPoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No fallback or fallback failed - return 404
|
||||
|
||||
@@ -671,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
|
||||
}
|
||||
|
||||
@@ -834,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()
|
||||
}
|
||||
|
||||
@@ -849,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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user