Compare commits

..

5 Commits

Author SHA1 Message Date
Hein 3fec7b1a90 fix(handler): update Content-Range headers for API response
Build , Vet Test, and Lint / Lint Code (push) Failing after 0s
Build , Vet Test, and Lint / Build (push) Failing after 0s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after 1s
Tests / Unit Tests (push) Failing after 1s
Tests / Integration Tests (push) Failing after 0s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 48s
* change Content-Range format to include 'items'
* add X-Api-Range-From and X-Api-Modelname headers
* add X-Api-Range-Etotal header for total filtered items
2026-06-24 10:02:54 +02:00
Hein 910390f62d fix(headers): correct order of limit and offset parsing 2026-06-24 09:47:14 +02:00
Hein b9bed67bd7 feat(security): add program user ID and table to user context
Build , Vet Test, and Lint / Lint Code (push) Failing after 0s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after 0s
Build , Vet Test, and Lint / Build (push) Failing after 1s
Tests / Unit Tests (push) Failing after 0s
Tests / Integration Tests (push) Failing after 1s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Failing after 14m57s
2026-06-23 10:18:22 +02:00
Hein 11ef16f75a fix(sql_helpers): adjust parenthesis nesting depth comment 2026-06-23 09:41:40 +02:00
Hein 48b72a7631 fix(sql_helpers): enhance splitByAND to handle BETWEEN and quotes
* Add support for BETWEEN-aware AND detection
* Ensure AND inside single-quoted strings does not cause splits
* Update tests to cover new BETWEEN and quote scenarios
2026-06-23 09:41:27 +02:00
6 changed files with 158 additions and 41 deletions
+43 -18
View File
@@ -446,18 +446,36 @@ func containsTopLevelOR(clause string) bool {
return false return false
} }
// splitByAND splits a WHERE clause by AND operators (case-insensitive) // splitByAND splits a WHERE clause by AND operators (case-insensitive).
// This is parenthesis-aware and won't split on AND operators inside subqueries // 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 { func splitByAND(where string) []string {
conditions := []string{} conditions := []string{}
currentCondition := strings.Builder{} 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 i := 0
for i < len(where) { for i < len(where) {
ch := where[i] 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 == '(' { if ch == '(' {
depth++ depth++
currentCondition.WriteByte(ch) currentCondition.WriteByte(ch)
@@ -470,32 +488,39 @@ func splitByAND(where string) []string {
continue 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 { if depth == 0 {
// Check if we're at an AND operator (case-insensitive) // Detect " BETWEEN " (9 chars, case-insensitive) so the very next
// We need at least " AND " (5 chars) or " and " (5 chars) // top-level AND is recognised as part of the BETWEEN syntax.
if i+5 <= len(where) { if i+9 <= len(where) && strings.ToLower(where[i:i+9]) == " between " {
substring := where[i : i+5] afterBetween = true
lowerSubstring := strings.ToLower(substring) currentCondition.WriteString(where[i : i+9])
i += 9
continue
}
if lowerSubstring == " and " { // Detect " AND " (5 chars, case-insensitive).
// Found an AND operator at the top level if i+5 <= len(where) && strings.ToLower(where[i:i+5]) == " and " {
// Add the current condition to the list if afterBetween {
conditions = append(conditions, currentCondition.String()) // This AND closes a BETWEEN expression — do NOT split.
currentCondition.Reset() afterBetween = false
// Skip past the AND operator currentCondition.WriteString(where[i : i+5])
i += 5 i += 5
continue 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) currentCondition.WriteByte(ch)
i++ i++
} }
// Add the last condition // Add the last condition.
if currentCondition.Len() > 0 { if currentCondition.Len() > 0 {
conditions = append(conditions, currentCondition.String()) conditions = append(conditions, currentCondition.String())
} }
+51
View File
@@ -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", 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"}, 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 { for _, tt := range tests {
@@ -917,6 +949,25 @@ where: "(true AND status = 'active')",
tableName: "unregistered_table", tableName: "unregistered_table",
expected: "(true AND unregistered_table.status = 'active')", 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 { for _, tt := range tests {
+4 -1
View File
@@ -2711,9 +2711,12 @@ func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{
} }
w.SetHeader("Content-Type", "application/json") 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-Total", fmt.Sprintf("%d", metadata.Filtered))
w.SetHeader("X-Api-Range-Size", fmt.Sprintf("%d", metadata.Count)) 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 // Format response based on response format option
switch options.ResponseFormat { switch options.ResponseFormat {
+5 -4
View File
@@ -225,12 +225,13 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request, model interface{}) E
limitValueParts := strings.Split(limitValue, ",") limitValueParts := strings.Split(limitValue, ",")
if len(limitValueParts) > 1 { if len(limitValueParts) > 1 {
if offset, err := strconv.Atoi(limitValueParts[0]); err == nil { if limit, err := strconv.Atoi(limitValueParts[0]); err == nil {
options.Offset = &offset
}
if limit, err := strconv.Atoi(limitValueParts[1]); err == nil {
options.Limit = &limit options.Limit = &limit
} }
if offset, err := strconv.Atoi(limitValueParts[1]); err == nil {
options.Offset = &offset
}
} else { } else {
if limit, err := strconv.Atoi(limitValueParts[0]); err == nil { if limit, err := strconv.Atoi(limitValueParts[0]); err == nil {
options.Limit = &limit options.Limit = &limit
+53 -18
View File
@@ -13,6 +13,9 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP, last_login_at TIMESTAMP,
-- Program-level user mapping
program_user_id INTEGER DEFAULT 0,
program_user_table VARCHAR(255) DEFAULT '',
-- OAuth2 fields -- OAuth2 fields
remote_id VARCHAR(255), -- Provider's user ID (e.g., Google sub, GitHub id) remote_id VARCHAR(255), -- Provider's user ID (e.g., Google sub, GitHub id)
auth_provider VARCHAR(50), -- 'local', 'google', 'github', 'microsoft', 'facebook', etc. auth_provider VARCHAR(50), -- 'local', 'google', 'github', 'microsoft', 'facebook', etc.
@@ -99,6 +102,8 @@ DECLARE
v_expires_at TIMESTAMP; v_expires_at TIMESTAMP;
v_ip_address TEXT; v_ip_address TEXT;
v_user_agent TEXT; v_user_agent TEXT;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN BEGIN
-- Extract login request fields -- Extract login request fields
v_username := p_request->>'username'; v_username := p_request->>'username';
@@ -106,8 +111,8 @@ BEGIN
v_user_agent := p_request->'claims'->>'user_agent'; v_user_agent := p_request->'claims'->>'user_agent';
-- Validate user credentials -- Validate user credentials
SELECT id, username, email, password, user_level, 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 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 FROM users
WHERE username = v_username AND is_active = true; WHERE username = v_username AND is_active = true;
@@ -146,7 +151,9 @@ BEGIN
'email', v_email, 'email', v_email,
'user_level', v_user_level, 'user_level', v_user_level,
'roles', string_to_array(COALESCE(v_roles, ''), ','), '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 'expires_in', 86400 -- 24 hours in seconds
); );
@@ -195,12 +202,16 @@ DECLARE
v_user_level INTEGER; v_user_level INTEGER;
v_roles TEXT; v_roles TEXT;
v_session_id TEXT; v_session_id TEXT;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN BEGIN
-- Query session and user data -- Query session and user data
SELECT 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 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 FROM user_sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.session_token = p_session_token WHERE s.session_token = p_session_token
@@ -222,7 +233,9 @@ BEGIN
'email', v_email, 'email', v_email,
'user_level', v_user_level, 'user_level', v_user_level,
'session_id', v_session_id, '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; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
@@ -266,10 +279,14 @@ DECLARE
v_expires_at TIMESTAMP; v_expires_at TIMESTAMP;
v_ip_address TEXT; v_ip_address TEXT;
v_user_agent TEXT; v_user_agent TEXT;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN BEGIN
-- Verify old session exists and is valid -- 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 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 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 FROM user_sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.session_token = p_old_session_token WHERE s.session_token = p_old_session_token
@@ -302,7 +319,9 @@ BEGIN
'email', v_email, 'email', v_email,
'user_level', v_user_level, 'user_level', v_user_level,
'session_id', v_new_session_token, '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; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
@@ -439,6 +458,8 @@ DECLARE
v_ip_address TEXT; v_ip_address TEXT;
v_user_agent TEXT; v_user_agent TEXT;
v_roles_array TEXT[]; v_roles_array TEXT[];
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN BEGIN
-- Extract registration request fields -- Extract registration request fields
v_username := p_request->>'username'; v_username := p_request->>'username';
@@ -447,6 +468,8 @@ BEGIN
v_user_level := COALESCE((p_request->>'user_level')::integer, 0); v_user_level := COALESCE((p_request->>'user_level')::integer, 0);
v_ip_address := p_request->'claims'->>'ip_address'; v_ip_address := p_request->'claims'->>'ip_address';
v_user_agent := p_request->'claims'->>'user_agent'; 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 -- Convert roles array from JSON to comma-separated string
SELECT array_to_string(ARRAY(SELECT jsonb_array_elements_text(p_request->'roles')), ',') 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')); -- v_password := crypt(v_password, gen_salt('bf'));
-- Create new user -- Create new user
INSERT INTO users (username, email, password, user_level, roles, is_active, created_at, updated_at) 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()) 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; RETURNING id INTO v_user_id;
-- Generate session token -- Generate session token
@@ -512,7 +535,9 @@ BEGIN
'email', v_email, 'email', v_email,
'user_level', v_user_level, 'user_level', v_user_level,
'roles', string_to_array(COALESCE(v_roles, ''), ','), '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 'expires_in', 86400 -- 24 hours in seconds
); );
@@ -671,12 +696,16 @@ DECLARE
v_user_level INTEGER; v_user_level INTEGER;
v_roles TEXT; v_roles TEXT;
v_expires_at TIMESTAMP; v_expires_at TIMESTAMP;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN BEGIN
-- Query session and user data from user_sessions table -- Query session and user data from user_sessions table
SELECT 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 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 FROM user_sessions s
JOIN users u ON s.user_id = u.id JOIN users u ON s.user_id = u.id
WHERE s.session_token = p_session_token WHERE s.session_token = p_session_token
@@ -698,7 +727,9 @@ BEGIN
'email', v_email, 'email', v_email,
'user_level', v_user_level, 'user_level', v_user_level,
'session_id', p_session_token, '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; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
@@ -815,10 +846,12 @@ DECLARE
v_email TEXT; v_email TEXT;
v_user_level INTEGER; v_user_level INTEGER;
v_roles TEXT; v_roles TEXT;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN BEGIN
-- Query user data -- Query user data
SELECT username, email, user_level, roles SELECT username, email, user_level, roles, program_user_id, program_user_table
INTO v_username, v_email, v_user_level, v_roles INTO v_username, v_email, v_user_level, v_roles, v_program_user_id, v_program_user_table
FROM users FROM users
WHERE id = p_user_id WHERE id = p_user_id
AND is_active = true; AND is_active = true;
@@ -837,7 +870,9 @@ BEGIN
'user_name', v_username, 'user_name', v_username,
'email', v_email, 'email', v_email,
'user_level', v_user_level, '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; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
+2
View File
@@ -18,6 +18,8 @@ type UserContext struct {
Claims map[string]any `json:"claims"` Claims map[string]any `json:"claims"`
Meta map[string]any `json:"meta"` // Additional metadata that can hold any JSON-serializable values 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 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 // LoginRequest contains credentials for login