Compare commits

...

1 Commits

Author SHA1 Message Date
Hein 598fd687f6 feat(security): add GetUserRef method for opaque user identifiers
Tests / Integration Tests (push) Failing after 1s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m6s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m21s
Tests / Unit Tests (push) Failing after 21s
Build , Vet Test, and Lint / Build (push) Successful in 50s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 54s
2026-07-10 13:47:32 +02:00
19 changed files with 161 additions and 65 deletions
+10
View File
@@ -71,6 +71,16 @@ func (f *funcSpecSecurityContext) GetUserID() (int, bool) {
return int(f.ctx.UserContext.UserID), true return int(f.ctx.UserContext.UserID), true
} }
// GetUserRef returns an opaque user identifier for row security lookups.
// It returns the full *security.UserContext so providers can read JWT claims
// (e.g. a UUID subject) instead of relying on the int user ID.
func (f *funcSpecSecurityContext) GetUserRef() (any, bool) {
if f.ctx.UserContext == nil {
return nil, false
}
return f.ctx.UserContext, true
}
func (f *funcSpecSecurityContext) GetSchema() string { func (f *funcSpecSecurityContext) GetSchema() string {
// funcspec doesn't have a schema concept, extract from SQL query or use default // funcspec doesn't have a schema concept, extract from SQL query or use default
return "public" return "public"
+11
View File
@@ -71,6 +71,17 @@ func (s *securityContext) GetUserID() (int, bool) {
return security.GetUserID(s.ctx.Context) return security.GetUserID(s.ctx.Context)
} }
// GetUserRef returns an opaque user identifier for row security lookups.
// It prefers the full *security.UserContext (so providers can read JWT claims,
// e.g. a UUID subject) and falls back to the int user ID.
func (s *securityContext) GetUserRef() (any, bool) {
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
return userCtx, true
}
userID, ok := security.GetUserID(s.ctx.Context)
return userID, ok
}
func (s *securityContext) GetSchema() string { func (s *securityContext) GetSchema() string {
return s.ctx.Schema return s.ctx.Schema
} }
+11
View File
@@ -84,6 +84,17 @@ func (s *securityContext) GetUserID() (int, bool) {
return security.GetUserID(s.ctx.Context) return security.GetUserID(s.ctx.Context)
} }
// GetUserRef returns an opaque user identifier for row security lookups.
// It prefers the full *security.UserContext (so providers can read JWT claims,
// e.g. a UUID subject) and falls back to the int user ID.
func (s *securityContext) GetUserRef() (any, bool) {
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
return userCtx, true
}
userID, ok := security.GetUserID(s.ctx.Context)
return userID, ok
}
func (s *securityContext) GetSchema() string { func (s *securityContext) GetSchema() string {
return s.ctx.Schema return s.ctx.Schema
} }
+11
View File
@@ -78,6 +78,17 @@ func (s *securityContext) GetUserID() (int, bool) {
return security.GetUserID(s.ctx.Context) return security.GetUserID(s.ctx.Context)
} }
// GetUserRef returns an opaque user identifier for row security lookups.
// It prefers the full *security.UserContext (so providers can read JWT claims,
// e.g. a UUID subject) and falls back to the int user ID.
func (s *securityContext) GetUserRef() (any, bool) {
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
return userCtx, true
}
userID, ok := security.GetUserID(s.ctx.Context)
return userID, ok
}
func (s *securityContext) GetSchema() string { func (s *securityContext) GetSchema() string {
return s.ctx.Schema return s.ctx.Schema
} }
+14
View File
@@ -1494,6 +1494,20 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
// Fetch the updated record after the transaction commits to capture any trigger changes // Fetch the updated record after the transaction commits to capture any trigger changes
fetchedRecord := reflect.New(reflect.TypeOf(model)).Interface() fetchedRecord := reflect.New(reflect.TypeOf(model)).Interface()
selectQuery := h.db.NewSelect().Model(fetchedRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID) selectQuery := h.db.NewSelect().Model(fetchedRecord).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
// Execute BeforeScan hooks so row security is re-applied to the post-update
// re-fetch, same as it is for the initial read and the update query itself.
// Without this, the re-fetch can return a row the caller isn't authorized to see.
hookCtx.Query = selectQuery
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err)
h.sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err)
return
}
if modifiedQuery, ok := hookCtx.Query.(common.SelectQuery); ok {
selectQuery = modifiedQuery
}
if err := selectQuery.ScanModel(ctx); err != nil { if err := selectQuery.ScanModel(ctx); err != nil {
logger.Error("Failed to fetch updated record: %v", err) logger.Error("Failed to fetch updated record: %v", err)
h.sendError(w, http.StatusInternalServerError, "fetch_error", "Failed to fetch updated record", err) h.sendError(w, http.StatusInternalServerError, "fetch_error", "Failed to fetch updated record", err)
+11
View File
@@ -77,6 +77,17 @@ func (s *securityContext) GetUserID() (int, bool) {
return security.GetUserID(s.ctx.Context) return security.GetUserID(s.ctx.Context)
} }
// GetUserRef returns an opaque user identifier for row security lookups.
// It prefers the full *security.UserContext (so providers can read JWT claims,
// e.g. a UUID subject) and falls back to the int user ID.
func (s *securityContext) GetUserRef() (any, bool) {
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
return userCtx, true
}
userID, ok := security.GetUserID(s.ctx.Context)
return userID, ok
}
func (s *securityContext) GetSchema() string { func (s *securityContext) GetSchema() string {
return s.ctx.Schema return s.ctx.Schema
} }
+2 -2
View File
@@ -74,8 +74,8 @@ func (c *CompositeSecurityProvider) GetColumnSecurity(ctx context.Context, userI
} }
// GetRowSecurity delegates to the row security provider // GetRowSecurity delegates to the row security provider
func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) { func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
return c.rowSec.GetRowSecurity(ctx, userID, schema, table) return c.rowSec.GetRowSecurity(ctx, userRef, schema, table)
} }
// Optional interface implementations (if wrapped providers support them) // Optional interface implementations (if wrapped providers support them)
+1 -1
View File
@@ -79,7 +79,7 @@ type mockRowSec struct {
supportsCache bool supportsCache bool
} }
func (m *mockRowSec) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) { func (m *mockRowSec) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
return m.rowSec, m.err return m.rowSec, m.err
} }
+22 -8
View File
@@ -14,6 +14,11 @@ import (
type SecurityContext interface { type SecurityContext interface {
GetContext() context.Context GetContext() context.Context
GetUserID() (int, bool) GetUserID() (int, bool)
// GetUserRef returns an opaque user identifier for row security lookups.
// Unlike GetUserID, it is not required to be an integer: implementations backed by
// non-integer identifiers (e.g. UUIDs) can return a string, or the full
// *security.UserContext so a RowSecurityProvider can read JWT claims directly.
GetUserRef() (any, bool)
GetSchema() string GetSchema() string
GetEntity() string GetEntity() string
GetModel() interface{} GetModel() interface{}
@@ -45,8 +50,13 @@ func loadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error
// return err // return err
} }
// Load row security rules using the provider // Load row security rules using the provider. Row security uses the opaque
_, err = securityList.LoadRowSecurity(secCtx.GetContext(), userID, schema, tablename, false) // user ref (not the int-only user ID) so non-integer user identifiers work.
userRef, refOK := secCtx.GetUserRef()
if !refOK {
userRef = userID
}
_, err = securityList.LoadRowSecurity(secCtx.GetContext(), userRef, schema, tablename, false)
if err != nil { if err != nil {
logger.Warn("Failed to load row security: %v", err) logger.Warn("Failed to load row security: %v", err)
// Don't fail the request if no security rules exist // Don't fail the request if no security rules exist
@@ -58,25 +68,29 @@ func loadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error
// applyRowSecurity applies row-level security filters to the query (generic version) // applyRowSecurity applies row-level security filters to the query (generic version)
func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error { func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error {
userID, ok := secCtx.GetUserID() userRef, ok := secCtx.GetUserRef()
if !ok { if !ok {
userID, idOK := secCtx.GetUserID()
if !idOK {
return nil // No user context, skip return nil // No user context, skip
} }
userRef = userID
}
schema := secCtx.GetSchema() schema := secCtx.GetSchema()
tablename := secCtx.GetEntity() tablename := secCtx.GetEntity()
// Get row security template // Get row security template
rowSec, err := securityList.GetRowSecurityTemplate(userID, schema, tablename) rowSec, err := securityList.GetRowSecurityTemplate(userRef, schema, tablename)
if err != nil { if err != nil {
// No row security defined, allow query to proceed // No row security defined, allow query to proceed
logger.Debug("No row security for %s.%s@%d: %v", schema, tablename, userID, err) logger.Debug("No row security for %s.%s@%v: %v", schema, tablename, userRef, err)
return nil return nil
} }
// Check if user has a blocking rule // Check if user has a blocking rule
if rowSec.HasBlock { if rowSec.HasBlock {
logger.Warn("User %d blocked from accessing %s.%s", userID, schema, tablename) logger.Warn("User %v blocked from accessing %s.%s", userRef, schema, tablename)
return fmt.Errorf("access denied to %s", tablename) return fmt.Errorf("access denied to %s", tablename)
} }
@@ -112,8 +126,8 @@ func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error
// Generate the WHERE clause from template // Generate the WHERE clause from template
whereClause := rowSec.GetTemplate(pkName, modelType) whereClause := rowSec.GetTemplate(pkName, modelType)
logger.Info("Applying row security filter for user %d on %s.%s: %s", logger.Info("Applying row security filter for user %v on %s.%s: %s",
userID, schema, tablename, whereClause) userRef, schema, tablename, whereClause)
// Apply the WHERE clause to the query // Apply the WHERE clause to the query
query := secCtx.GetQuery() query := secCtx.GetQuery()
+4
View File
@@ -26,6 +26,10 @@ func (m *mockSecurityContext) GetUserID() (int, bool) {
return m.userID, m.hasUser return m.userID, m.hasUser
} }
func (m *mockSecurityContext) GetUserRef() (any, bool) {
return m.userID, m.hasUser
}
func (m *mockSecurityContext) GetSchema() string { func (m *mockSecurityContext) GetSchema() string {
return m.schema return m.schema
} }
+6 -2
View File
@@ -121,8 +121,12 @@ type ColumnSecurityProvider interface {
// RowSecurityProvider handles row-level security (filtering) // RowSecurityProvider handles row-level security (filtering)
type RowSecurityProvider interface { type RowSecurityProvider interface {
// GetRowSecurity loads row security rules for a user and entity // GetRowSecurity loads row security rules for a user and entity.
GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) // userRef identifies the user and is opaque to the caller: it may be an int ID,
// a string/UUID, or the full *security.UserContext (see SecurityContext.GetUserRef),
// so providers backed by non-integer user identifiers (e.g. UUIDs) or that need
// access to JWT claims can implement row security without relying on a numeric ID.
GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error)
} }
// SecurityProvider is the main interface combining all security concerns // SecurityProvider is the main interface combining all security concerns
+1 -4
View File
@@ -260,10 +260,7 @@ func (p *DatabasePasskeyProvider) BeginAuthentication(ctx context.Context, usern
// If username is provided, get user's credentials // If username is provided, get user's credentials
var allowCredentials []PasskeyCredentialDescriptor var allowCredentials []PasskeyCredentialDescriptor
if username != "" { if username != "" {
var creds []struct { var creds []passkeyCredential
ID string `json:"credential_id"`
Transports []string `json:"transports"`
}
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetCredsByUsername) { if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetCredsByUsername) {
_, directCreds, err := p.getCredsByUsernameDirect(ctx, username) _, directCreds, err := p.getCredsByUsernameDirect(ctx, username)
+13 -18
View File
@@ -39,7 +39,7 @@ func (p *DatabasePasskeyProvider) storeCredentialDirect(ctx context.Context, par
var exists int var exists int
checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials)) 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 { if err := db.QueryRowContext(ctx, checkQuery, params.CredentialID).Scan(&exists); err == nil {
return fmt.Errorf("Credential already exists") return fmt.Errorf("credential already exists")
} else if !errors.Is(err, sql.ErrNoRows) { } else if !errors.Is(err, sql.ErrNoRows) {
return err return err
} }
@@ -48,7 +48,7 @@ func (p *DatabasePasskeyProvider) storeCredentialDirect(ctx context.Context, par
userCheckQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE id = ?`, p.tableNames.Users)) 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 err := db.QueryRowContext(ctx, userCheckQuery, params.UserID).Scan(&userExists); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("User not found") return fmt.Errorf("user not found")
} }
return err return err
} }
@@ -78,7 +78,7 @@ func (p *DatabasePasskeyProvider) getCredentialDirect(ctx context.Context, crede
}) })
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return 0, 0, fmt.Errorf("Credential not found") return 0, 0, fmt.Errorf("credential not found")
} }
return 0, 0, fmt.Errorf("failed to get credential: %w", err) return 0, 0, fmt.Errorf("failed to get credential: %w", err)
} }
@@ -91,7 +91,7 @@ func (p *DatabasePasskeyProvider) updateCounterDirect(ctx context.Context, crede
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT sign_count FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials)) 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 err := db.QueryRowContext(ctx, query, credentialIDB64).Scan(&oldCounter); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("Credential not found") return fmt.Errorf("credential not found")
} }
return err return err
} }
@@ -188,7 +188,7 @@ func (p *DatabasePasskeyProvider) deleteCredentialDirect(ctx context.Context, us
return err return err
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("Credential not found") return fmt.Errorf("credential not found")
} }
return nil return nil
}) })
@@ -206,24 +206,19 @@ func (p *DatabasePasskeyProvider) updateNameDirect(ctx context.Context, userID i
return err return err
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("Credential not found") return fmt.Errorf("credential not found")
} }
return nil return nil
}) })
} }
func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context, username string) (int, []struct { type passkeyCredential struct {
ID string `json:"credential_id"` ID string `json:"credential_id"`
Transports []string `json:"transports"` 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 { func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context, username string) (userID int, creds []passkeyCredential, err error) {
err = p.runDBOpWithReconnect(func(db *sql.DB) error {
userQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE username = ? AND is_active = ?`, p.tableNames.Users)) 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 { if err := db.QueryRowContext(ctx, userQuery, username, true).Scan(&userID); err != nil {
return err return err
@@ -236,7 +231,7 @@ func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context,
} }
defer rows.Close() defer rows.Close()
creds = make([]credT, 0) creds = make([]passkeyCredential, 0)
for rows.Next() { for rows.Next() {
var credID string var credID string
var transportsJSON sql.NullString var transportsJSON sql.NullString
@@ -247,13 +242,13 @@ func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context,
if transportsJSON.Valid && transportsJSON.String != "" { if transportsJSON.Valid && transportsJSON.String != "" {
_ = json.Unmarshal([]byte(transportsJSON.String), &transports) _ = json.Unmarshal([]byte(transportsJSON.String), &transports)
} }
creds = append(creds, credT{ID: credID, Transports: transports}) creds = append(creds, passkeyCredential{ID: credID, Transports: transports})
} }
return rows.Err() return rows.Err()
}) })
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return 0, nil, fmt.Errorf("User not found") return 0, nil, fmt.Errorf("user not found")
} }
return 0, nil, fmt.Errorf("failed to get credentials: %w", err) return 0, nil, fmt.Errorf("failed to get credentials: %w", err)
} }
+10 -7
View File
@@ -34,7 +34,10 @@ type RowSecurity struct {
Tablename string `json:"tablename"` Tablename string `json:"tablename"`
Template string `json:"template"` Template string `json:"template"`
HasBlock bool `json:"has_block"` HasBlock bool `json:"has_block"`
UserID int `json:"user_id"` // UserID is the opaque user reference the security rules were loaded for.
// It may be an int, a string/UUID, or a *UserContext, depending on what the
// RowSecurityProvider/SecurityContext.GetUserRef implementation returns.
UserID any `json:"user_id"`
} }
func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Type) string { func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Type) string {
@@ -42,7 +45,7 @@ func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Typ
str = strings.ReplaceAll(str, "{PrimaryKeyName}", pPrimaryKeyName) str = strings.ReplaceAll(str, "{PrimaryKeyName}", pPrimaryKeyName)
str = strings.ReplaceAll(str, "{TableName}", m.Tablename) str = strings.ReplaceAll(str, "{TableName}", m.Tablename)
str = strings.ReplaceAll(str, "{SchemaName}", m.Schema) str = strings.ReplaceAll(str, "{SchemaName}", m.Schema)
str = strings.ReplaceAll(str, "{UserID}", fmt.Sprintf("%d", m.UserID)) str = strings.ReplaceAll(str, "{UserID}", fmt.Sprintf("%v", m.UserID))
return str return str
} }
@@ -413,7 +416,7 @@ func (m *SecurityList) ClearSecurity(pUserID int, pSchema, pTablename string) er
return nil return nil
} }
func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema, pTablename string, pOverwrite bool) (RowSecurity, error) { func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserRef any, pSchema, pTablename string, pOverwrite bool) (RowSecurity, error) {
if m.provider == nil { if m.provider == nil {
return RowSecurity{}, fmt.Errorf("security provider not set") return RowSecurity{}, fmt.Errorf("security provider not set")
} }
@@ -424,10 +427,10 @@ func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema
if m.RowSecurity == nil { if m.RowSecurity == nil {
m.RowSecurity = make(map[string]RowSecurity, 0) m.RowSecurity = make(map[string]RowSecurity, 0)
} }
secKey := fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID) secKey := fmt.Sprintf("%s.%s@%v", pSchema, pTablename, pUserRef)
// Call the provider to load security rules // Call the provider to load security rules
record, err := m.provider.GetRowSecurity(ctx, pUserID, pSchema, pTablename) record, err := m.provider.GetRowSecurity(ctx, pUserRef, pSchema, pTablename)
if err != nil { if err != nil {
return RowSecurity{}, fmt.Errorf("GetRowSecurity failed: %v", err) return RowSecurity{}, fmt.Errorf("GetRowSecurity failed: %v", err)
} }
@@ -436,7 +439,7 @@ func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema
return record, nil return record, nil
} }
func (m *SecurityList) GetRowSecurityTemplate(pUserID int, pSchema, pTablename string) (RowSecurity, error) { func (m *SecurityList) GetRowSecurityTemplate(pUserRef any, pSchema, pTablename string) (RowSecurity, error) {
defer logger.CatchPanic("GetRowSecurityTemplate")() defer logger.CatchPanic("GetRowSecurityTemplate")()
if m.RowSecurity == nil { if m.RowSecurity == nil {
@@ -446,7 +449,7 @@ func (m *SecurityList) GetRowSecurityTemplate(pUserID int, pSchema, pTablename s
m.RowSecurityMutex.RLock() m.RowSecurityMutex.RLock()
defer m.RowSecurityMutex.RUnlock() defer m.RowSecurityMutex.RUnlock()
rowSec, ok := m.RowSecurity[fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)] rowSec, ok := m.RowSecurity[fmt.Sprintf("%s.%s@%v", pSchema, pTablename, pUserRef)]
if !ok { if !ok {
return RowSecurity{}, fmt.Errorf("no row security data") return RowSecurity{}, fmt.Errorf("no row security data")
} }
+1 -1
View File
@@ -44,7 +44,7 @@ func (m *mockSecurityProvider) GetColumnSecurity(ctx context.Context, userID int
return m.columnSecurity, nil return m.columnSecurity, nil
} }
func (m *mockSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) { func (m *mockSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
return m.rowSecurity, nil return m.rowSecurity, nil
} }
+6 -6
View File
@@ -907,7 +907,7 @@ func (p *DatabaseRowSecurityProvider) reconnectDB() error {
return nil return nil
} }
func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) { func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.RowSecurity) { if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.RowSecurity) {
return RowSecurity{}, ErrDirectModeUnsupported return RowSecurity{}, ErrDirectModeUnsupported
} }
@@ -917,7 +917,7 @@ func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userID
runQuery := func() error { runQuery := func() error {
query := fmt.Sprintf(`SELECT p_template, p_block FROM %s($1, $2, $3)`, p.sqlNames.RowSecurity) query := fmt.Sprintf(`SELECT p_template, p_block FROM %s($1, $2, $3)`, p.sqlNames.RowSecurity)
return p.getDB().QueryRowContext(ctx, query, schema, table, userID).Scan(&template, &hasBlock) return p.getDB().QueryRowContext(ctx, query, schema, table, userRef).Scan(&template, &hasBlock)
} }
err := runQuery() err := runQuery()
if isDBClosed(err) { if isDBClosed(err) {
@@ -932,7 +932,7 @@ func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userID
return RowSecurity{ return RowSecurity{
Schema: schema, Schema: schema,
Tablename: table, Tablename: table,
UserID: userID, UserID: userRef,
Template: template, Template: template,
HasBlock: hasBlock, HasBlock: hasBlock,
}, nil }, nil
@@ -969,14 +969,14 @@ func NewConfigRowSecurityProvider(templates map[string]string, blocked map[strin
} }
} }
func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) { func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
key := fmt.Sprintf("%s.%s", schema, table) key := fmt.Sprintf("%s.%s", schema, table)
if p.blocked[key] { if p.blocked[key] {
return RowSecurity{ return RowSecurity{
Schema: schema, Schema: schema,
Tablename: table, Tablename: table,
UserID: userID, UserID: userRef,
HasBlock: true, HasBlock: true,
}, nil }, nil
} }
@@ -985,7 +985,7 @@ func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userID i
return RowSecurity{ return RowSecurity{
Schema: schema, Schema: schema,
Tablename: table, Tablename: table,
UserID: userID, UserID: userRef,
Template: template, Template: template,
HasBlock: false, HasBlock: false,
}, nil }, nil
+9 -9
View File
@@ -23,8 +23,8 @@ import (
// than introducing a mismatch between modes. // than introducing a mismatch between modes.
var ( var (
errUsernameExists = errors.New("Username already exists") errUsernameExists = errors.New("username already exists")
errEmailExists = errors.New("Email already exists") errEmailExists = errors.New("email already exists")
) )
func (a *DatabaseAuthenticator) loginDirect(ctx context.Context, req LoginRequest) (*LoginResponse, error) { func (a *DatabaseAuthenticator) loginDirect(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
@@ -40,7 +40,7 @@ func (a *DatabaseAuthenticator) loginDirect(ctx context.Context, req LoginReques
}) })
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("Invalid credentials") return nil, fmt.Errorf("invalid credentials")
} }
return nil, fmt.Errorf("login query failed: %w", err) return nil, fmt.Errorf("login query failed: %w", err)
} }
@@ -88,13 +88,13 @@ func (a *DatabaseAuthenticator) loginDirect(ctx context.Context, req LoginReques
func (a *DatabaseAuthenticator) registerDirect(ctx context.Context, req RegisterRequest) (*LoginResponse, error) { func (a *DatabaseAuthenticator) registerDirect(ctx context.Context, req RegisterRequest) (*LoginResponse, error) {
if req.Username == "" { if req.Username == "" {
return nil, fmt.Errorf("Username is required") return nil, fmt.Errorf("username is required")
} }
if req.Email == "" { if req.Email == "" {
return nil, fmt.Errorf("Email is required") return nil, fmt.Errorf("email is required")
} }
if req.Password == "" { if req.Password == "" {
return nil, fmt.Errorf("Password is required") return nil, fmt.Errorf("password is required")
} }
rolesStr := strings.Join(req.Roles, ",") rolesStr := strings.Join(req.Roles, ",")
@@ -197,7 +197,7 @@ func (a *DatabaseAuthenticator) logoutDirect(ctx context.Context, req LogoutRequ
return fmt.Errorf("logout query failed: %w", err) return fmt.Errorf("logout query failed: %w", err)
} }
if rows == 0 { if rows == 0 {
return fmt.Errorf("Session not found") return fmt.Errorf("session not found")
} }
if req.Token != "" { if req.Token != "" {
@@ -222,7 +222,7 @@ func (a *DatabaseAuthenticator) sessionDirect(ctx context.Context, token string)
}) })
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("Invalid or expired session") return nil, fmt.Errorf("invalid or expired session")
} }
return nil, fmt.Errorf("session query failed: %w", err) return nil, fmt.Errorf("session query failed: %w", err)
} }
@@ -262,7 +262,7 @@ func (a *DatabaseAuthenticator) refreshTokenDirect(ctx context.Context, oldToken
}) })
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("Invalid or expired refresh token") return nil, fmt.Errorf("invalid or expired refresh token")
} }
return nil, fmt.Errorf("refresh token query failed: %w", err) return nil, fmt.Errorf("refresh token query failed: %w", err)
} }
@@ -22,7 +22,7 @@ func (p *DatabaseTwoFactorProvider) enable2FADirect(ctx context.Context, userID
if rows, err := res.RowsAffected(); err != nil { if rows, err := res.RowsAffected(); err != nil {
return err return err
} else if rows == 0 { } else if rows == 0 {
return fmt.Errorf("User not found") return fmt.Errorf("user not found")
} }
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes)) delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
@@ -50,7 +50,7 @@ func (p *DatabaseTwoFactorProvider) disable2FADirect(ctx context.Context, userID
if rows, err := res.RowsAffected(); err != nil { if rows, err := res.RowsAffected(); err != nil {
return err return err
} else if rows == 0 { } else if rows == 0 {
return fmt.Errorf("User not found") return fmt.Errorf("user not found")
} }
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes)) delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
@@ -67,7 +67,7 @@ func (p *DatabaseTwoFactorProvider) get2FAStatusDirect(ctx context.Context, user
}) })
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return false, fmt.Errorf("User not found") return false, fmt.Errorf("user not found")
} }
return false, fmt.Errorf("get 2FA status query failed: %w", err) return false, fmt.Errorf("get 2FA status query failed: %w", err)
} }
@@ -83,7 +83,7 @@ func (p *DatabaseTwoFactorProvider) get2FASecretDirect(ctx context.Context, user
}) })
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("User not found") return "", fmt.Errorf("user not found")
} }
return "", fmt.Errorf("get 2FA secret query failed: %w", err) return "", fmt.Errorf("get 2FA secret query failed: %w", err)
} }
@@ -101,7 +101,7 @@ func (p *DatabaseTwoFactorProvider) regenerateBackupCodesDirect(ctx context.Cont
return err return err
} }
if count == 0 { if count == 0 {
return fmt.Errorf("User not found or TOTP not enabled") return fmt.Errorf("user not found or TOTP not enabled")
} }
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes)) delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
@@ -134,7 +134,7 @@ func (p *DatabaseTwoFactorProvider) validateBackupCodeDirect(ctx context.Context
return err return err
} }
if used { if used {
return fmt.Errorf("Backup code already used") return fmt.Errorf("backup code already used")
} }
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET used = ?, used_at = ? WHERE id = ?`, p.tableNames.UserTOTPBackupCodes)) updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET used = ?, used_at = ? WHERE id = ?`, p.tableNames.UserTOTPBackupCodes))
+11
View File
@@ -71,6 +71,17 @@ func (s *securityContext) GetUserID() (int, bool) {
return security.GetUserID(s.ctx.Context) return security.GetUserID(s.ctx.Context)
} }
// GetUserRef returns an opaque user identifier for row security lookups.
// It prefers the full *security.UserContext (so providers can read JWT claims,
// e.g. a UUID subject) and falls back to the int user ID.
func (s *securityContext) GetUserRef() (any, bool) {
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
return userCtx, true
}
userID, ok := security.GetUserID(s.ctx.Context)
return userID, ok
}
func (s *securityContext) GetSchema() string { func (s *securityContext) GetSchema() string {
return s.ctx.Schema return s.ctx.Schema
} }