diff --git a/EXPLAIN_PLAN_ANALYSIS.sql b/EXPLAIN_PLAN_ANALYSIS.sql new file mode 100644 index 0000000..ef5c7d4 --- /dev/null +++ b/EXPLAIN_PLAN_ANALYSIS.sql @@ -0,0 +1,148 @@ +-- ============================================================================ +-- EXPLAIN PLAN ANALYSIS: DynamicCrudService.getColumnMetadata Query +-- ============================================================================ +-- This query retrieves table column metadata including: +-- • Basic column properties (name, type, length, precision, scale) +-- • Primary key detection via EXISTS subquery +-- • Unique constraint detection via EXISTS subquery +-- • Foreign key relationships via LEFT JOIN to derived table +-- • Column comments from metadata +-- ============================================================================ + +-- Step 1: Generate the EXPLAIN PLAN +EXPLAIN PLAN SET STATEMENT_ID = 'DCRUD_METADATA' FOR +SELECT utc.column_name, + utc.data_type, + utc.nullable, + utc.column_id, + utc.data_length, + utc.data_precision, + utc.data_scale, + utc.data_default, + CASE + WHEN EXISTS ( + SELECT 1 + FROM user_cons_columns ucc + JOIN user_constraints uc ON ucc.constraint_name = uc.constraint_name + WHERE uc.constraint_type = 'P' + AND uc.table_name = utc.table_name + AND ucc.column_name = utc.column_name + ) THEN 'Y' ELSE 'N' END AS is_primary_key, + CASE + WHEN EXISTS ( + SELECT 1 + FROM user_cons_columns ucc + JOIN user_constraints uc ON ucc.constraint_name = uc.constraint_name + WHERE uc.constraint_type = 'U' + AND uc.table_name = utc.table_name + AND ucc.column_name = utc.column_name + ) THEN 'Y' ELSE 'N' END AS is_unique, + NVL(ucc.comments, '') AS column_comment, + fkc.ref_table_name, + fkc.ref_column_name +FROM user_tab_columns utc +LEFT JOIN user_col_comments ucc ON utc.table_name = ucc.table_name + AND utc.column_name = ucc.column_name +LEFT JOIN ( + SELECT ucc1.table_name, + ucc1.column_name, + ucc2.table_name AS ref_table_name, + ucc2.column_name AS ref_column_name + FROM user_cons_columns ucc1 + JOIN user_constraints uc1 ON ucc1.constraint_name = uc1.constraint_name + JOIN user_constraints uc2 ON uc1.r_constraint_name = uc2.constraint_name + JOIN user_cons_columns ucc2 ON uc2.constraint_name = ucc2.constraint_name + WHERE uc1.constraint_type = 'R' +) fkc ON utc.table_name = fkc.table_name AND utc.column_name = fkc.column_name +WHERE utc.table_name = UPPER('USERS') -- Example: Replace with actual table name +ORDER BY utc.column_id; + +-- Step 2: Retrieve and display the plan +SET LONG 20000 LONGCHUNKSIZE 20000 +SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY('PLAN_TABLE', 'DCRUD_METADATA', 'ALL')); + +-- Step 3: Clean up +DELETE FROM PLAN_TABLE WHERE STATEMENT_ID = 'DCRUD_METADATA'; +COMMIT; + +-- ============================================================================ +-- PERFORMANCE ANALYSIS & OPTIMIZATION NOTES +-- ============================================================================ +-- +-- Current Issues: +-- 1. TWO SEPARATE EXISTS subqueries running for EACH column (is_primary_key, is_unique) +-- These cause repeated scans of user_cons_columns and user_constraints +-- +-- 2. LEFT JOIN to derived table with 4-way join (ucc1→uc1→uc2→ucc2) +-- This materializes ALL foreign key relationships then filters +-- +-- 3. Multiple JOINs on user_constraints (HIGH-CARDINALITY DICTIONARY VIEW) +-- Oracle's data dictionary views are not always well-indexed +-- +-- Estimated Problem: O(N) EXISTS checks + Derived table materialization +-- Impact: Slow for tables with many constraints or foreign keys +-- +-- ============================================================================ +-- RECOMMENDED OPTIMIZATION: Collapse to Single Dictionary Query +-- ============================================================================ + +-- OPTIMIZED VERSION: Use all_constraints/all_cons_columns to get all metadata at once + +EXPLAIN PLAN SET STATEMENT_ID = 'DCRUD_METADATA_OPT' FOR +SELECT utc.column_name, + utc.data_type, + utc.nullable, + utc.column_id, + utc.data_length, + utc.data_precision, + utc.data_scale, + utc.data_default, + MAX(CASE WHEN uc.constraint_type = 'P' THEN 'Y' ELSE 'N' END) AS is_primary_key, + MAX(CASE WHEN uc.constraint_type = 'U' THEN 'Y' ELSE 'N' END) AS is_unique, + NVL(ucc_comments.comments, '') AS column_comment, + MAX(CASE WHEN uc.constraint_type = 'R' THEN ucc_fk.table_name ELSE NULL END) AS ref_table_name, + MAX(CASE WHEN uc.constraint_type = 'R' THEN ucc_fk.column_name ELSE NULL END) AS ref_column_name +FROM user_tab_columns utc +LEFT JOIN user_col_comments ucc_comments + ON utc.table_name = ucc_comments.table_name + AND utc.column_name = ucc_comments.column_name +LEFT JOIN user_cons_columns ucc_local + ON utc.table_name = ucc_local.table_name + AND utc.column_name = ucc_local.column_name +LEFT JOIN user_constraints uc + ON ucc_local.constraint_name = uc.constraint_name +LEFT JOIN user_constraints uc_ref + ON uc.constraint_type = 'R' + AND uc.r_constraint_name = uc_ref.constraint_name +LEFT JOIN user_cons_columns ucc_fk + ON uc_ref.constraint_name = ucc_fk.constraint_name +WHERE utc.table_name = UPPER('USERS') -- Example: Replace with actual table name +GROUP BY utc.column_id, utc.column_name, utc.data_type, utc.nullable, + utc.data_length, utc.data_precision, utc.data_scale, utc.data_default, + ucc_comments.comments +ORDER BY utc.column_id; + +-- Step 4: Compare optimized plan +SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY('PLAN_TABLE', 'DCRUD_METADATA_OPT', 'ALL')); + +-- Step 5: Clean up +DELETE FROM PLAN_TABLE WHERE STATEMENT_ID = 'DCRUD_METADATA_OPT'; +COMMIT; + +-- ============================================================================ +-- HOW TO USE THIS ANALYSIS +-- ============================================================================ +-- 1. Connect to your Oracle database as the same user (SYSTEM or SSF_USER) +-- 2. Run the script: @EXPLAIN_PLAN_ANALYSIS.sql +-- 3. Compare the two execution plans (original vs optimized) +-- 4. Check for: +-- - "TABLE ACCESS FULL" on user_cons_columns (indicates high cost) +-- - Multiple FILTER operations (EXISTS checks) +-- - Nested loop joins on large result sets +-- 5. Note the I/O cost and estimated rows +-- +-- For large schemas with many tables/constraints, consider: +-- - Caching metadata in application layer +-- - Adding application-side metadata cache with TTL +-- - Creating materialized view of table/constraint metadata +-- ============================================================================ diff --git a/OPTIMIZED_QUERY.sql b/OPTIMIZED_QUERY.sql new file mode 100644 index 0000000..c7b8f24 --- /dev/null +++ b/OPTIMIZED_QUERY.sql @@ -0,0 +1,235 @@ +-- ============================================================================ +-- OPTIMIZED: DynamicCrudService.getColumnMetadata Query +-- ============================================================================ +-- This version eliminates redundant EXISTS checks and uses a single LEFT JOIN +-- strategy with GROUP BY to materialize constraint metadata once. +-- +-- Performance improvements: +-- - Eliminates O(N) separate EXISTS subqueries +-- - Single pass through constraints instead of N passes +-- - Replaces correlated subqueries with efficient LEFT JOINs +-- - Uses aggregation to handle 1-to-many relationships +-- ============================================================================ + +EXPLAIN PLAN SET STATEMENT_ID = 'DCRUD_METADATA_OPTIMIZED' FOR +SELECT + utc.column_name, + utc.data_type, + utc.nullable, + utc.column_id, + utc.data_length, + utc.data_precision, + utc.data_scale, + utc.data_default, + -- Use MAX/MIN aggregation to collapse constraint checks into single pass + MAX(CASE WHEN uc.constraint_type = 'P' THEN 'Y' ELSE 'N' END) AS is_primary_key, + MAX(CASE WHEN uc.constraint_type = 'U' THEN 'Y' ELSE 'N' END) AS is_unique, + MAX(ucc_comments.comments) AS column_comment, + -- For foreign keys, take first matching reference (will be same for all matching constraint rows) + MAX(CASE WHEN uc.constraint_type = 'R' THEN uc2.table_name ELSE NULL END) AS ref_table_name, + MAX(CASE WHEN uc.constraint_type = 'R' THEN ucc_ref.column_name ELSE NULL END) AS ref_column_name +FROM + user_tab_columns utc + -- Comments: simple 1:1 LEFT JOIN + LEFT JOIN user_col_comments ucc_comments + ON utc.table_name = ucc_comments.table_name + AND utc.column_name = ucc_comments.column_name + -- Constraints: single LEFT JOIN + GROUP BY replaces 2 EXISTS checks + 1 complex join + LEFT JOIN user_cons_columns ucc_local + ON utc.table_name = ucc_local.table_name + AND utc.column_name = ucc_local.column_name + LEFT JOIN user_constraints uc + ON ucc_local.constraint_name = uc.constraint_name + -- Foreign key reference table lookup (only for FK constraints) + LEFT JOIN user_constraints uc2 + ON uc.constraint_type = 'R' + AND uc.r_constraint_name = uc2.constraint_name + LEFT JOIN user_cons_columns ucc_ref + ON uc.constraint_type = 'R' + AND uc2.constraint_name = ucc_ref.constraint_name + AND ucc_ref.position = 1 -- Take first column of composite FK (99% of cases) +WHERE + utc.table_name = UPPER('USERS') +GROUP BY + utc.column_id, + utc.column_name, + utc.data_type, + utc.nullable, + utc.data_length, + utc.data_precision, + utc.data_scale, + utc.data_default +ORDER BY + utc.column_id; + +-- Display the optimized plan +SET LONG 20000 LONGCHUNKSIZE 20000 +SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY('PLAN_TABLE', 'DCRUD_METADATA_OPTIMIZED', 'ALL')); + +-- ============================================================================ +-- ALTERNATIVE: Even Simpler Using Analytic Functions (Oracle 12.1+) +-- ============================================================================ +-- If you're on 12.1+, use FIRST_VALUE with OVER clauses for cleaner code +-- ============================================================================ + +EXPLAIN PLAN SET STATEMENT_ID = 'DCRUD_METADATA_ANALYTIC' FOR +SELECT + column_id, + column_name, + data_type, + nullable, + data_length, + data_precision, + data_scale, + data_default, + MAX(is_primary_key) OVER (PARTITION BY column_id) AS is_primary_key, + MAX(is_unique) OVER (PARTITION BY column_id) AS is_unique, + FIRST_VALUE(comments) OVER (PARTITION BY column_id ORDER BY comments NULLS LAST) AS column_comment, + FIRST_VALUE(ref_table_name) OVER (PARTITION BY column_id ORDER BY ref_table_name NULLS LAST) AS ref_table_name, + FIRST_VALUE(ref_column_name) OVER (PARTITION BY column_id ORDER BY ref_column_name NULLS LAST) AS ref_column_name +FROM ( + SELECT + utc.column_id, + utc.column_name, + utc.data_type, + utc.nullable, + utc.data_length, + utc.data_precision, + utc.data_scale, + utc.data_default, + CASE WHEN uc.constraint_type = 'P' THEN 'Y' ELSE 'N' END AS is_primary_key, + CASE WHEN uc.constraint_type = 'U' THEN 'Y' ELSE 'N' END AS is_unique, + ucc_comments.comments, + uc2.table_name AS ref_table_name, + ucc_ref.column_name AS ref_column_name, + ROW_NUMBER() OVER (PARTITION BY utc.column_id ORDER BY uc.constraint_type, uc2.table_name) AS rn + FROM + user_tab_columns utc + LEFT JOIN user_col_comments ucc_comments + ON utc.table_name = ucc_comments.table_name + AND utc.column_name = ucc_comments.column_name + LEFT JOIN user_cons_columns ucc_local + ON utc.table_name = ucc_local.table_name + AND utc.column_name = ucc_local.column_name + LEFT JOIN user_constraints uc + ON ucc_local.constraint_name = uc.constraint_name + LEFT JOIN user_constraints uc2 + ON uc.constraint_type = 'R' + AND uc.r_constraint_name = uc2.constraint_name + LEFT JOIN user_cons_columns ucc_ref + ON uc.constraint_type = 'R' + AND uc2.constraint_name = ucc_ref.constraint_name + AND ucc_ref.position = 1 + WHERE + utc.table_name = UPPER('USERS') +) +WHERE + rn = 1 +ORDER BY + column_id; + +-- Display the analytic plan +SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY('PLAN_TABLE', 'DCRUD_METADATA_ANALYTIC', 'ALL')); + +-- ============================================================================ +-- COMPARE EXECUTION TIMES +-- ============================================================================ +-- Run this to measure performance difference: + +SET TIMING ON + +-- Original query (slow) +SELECT COUNT(*) FROM ( +SELECT utc.column_name, + utc.data_type, + utc.nullable, + utc.column_id, + utc.data_length, + utc.data_precision, + utc.data_scale, + utc.data_default, + CASE + WHEN EXISTS ( + SELECT 1 + FROM user_cons_columns ucc + JOIN user_constraints uc ON ucc.constraint_name = uc.constraint_name + WHERE uc.constraint_type = 'P' + AND uc.table_name = utc.table_name + AND ucc.column_name = utc.column_name + ) THEN 'Y' ELSE 'N' END AS is_primary_key, + CASE + WHEN EXISTS ( + SELECT 1 + FROM user_cons_columns ucc + JOIN user_constraints uc ON ucc.constraint_name = uc.constraint_name + WHERE uc.constraint_type = 'U' + AND uc.table_name = utc.table_name + AND ucc.column_name = utc.column_name + ) THEN 'Y' ELSE 'N' END AS is_unique, + NVL(ucc.comments, '') AS column_comment, + fkc.ref_table_name, + fkc.ref_column_name +FROM user_tab_columns utc +LEFT JOIN user_col_comments ucc ON utc.table_name = ucc.table_name + AND utc.column_name = ucc.column_name +LEFT JOIN ( + SELECT ucc1.table_name, + ucc1.column_name, + ucc2.table_name AS ref_table_name, + ucc2.column_name AS ref_column_name + FROM user_cons_columns ucc1 + JOIN user_constraints uc1 ON ucc1.constraint_name = uc1.constraint_name + JOIN user_constraints uc2 ON uc1.r_constraint_name = uc2.constraint_name + JOIN user_cons_columns ucc2 ON uc2.constraint_name = ucc2.constraint_name + WHERE uc1.constraint_type = 'R' +) fkc ON utc.table_name = fkc.table_name AND utc.column_name = fkc.column_name +WHERE utc.table_name = UPPER('USERS') +); + +-- Optimized query (fast) +SELECT COUNT(*) FROM ( +SELECT + utc.column_name, + utc.data_type, + utc.nullable, + utc.column_id, + utc.data_length, + utc.data_precision, + utc.data_scale, + utc.data_default, + MAX(CASE WHEN uc.constraint_type = 'P' THEN 'Y' ELSE 'N' END) AS is_primary_key, + MAX(CASE WHEN uc.constraint_type = 'U' THEN 'Y' ELSE 'N' END) AS is_unique, + MAX(ucc_comments.comments) AS column_comment, + MAX(CASE WHEN uc.constraint_type = 'R' THEN uc2.table_name ELSE NULL END) AS ref_table_name, + MAX(CASE WHEN uc.constraint_type = 'R' THEN ucc_ref.column_name ELSE NULL END) AS ref_column_name +FROM + user_tab_columns utc + LEFT JOIN user_col_comments ucc_comments + ON utc.table_name = ucc_comments.table_name + AND utc.column_name = ucc_comments.column_name + LEFT JOIN user_cons_columns ucc_local + ON utc.table_name = ucc_local.table_name + AND utc.column_name = ucc_local.column_name + LEFT JOIN user_constraints uc + ON ucc_local.constraint_name = uc.constraint_name + LEFT JOIN user_constraints uc2 + ON uc.constraint_type = 'R' + AND uc.r_constraint_name = uc2.constraint_name + LEFT JOIN user_cons_columns ucc_ref + ON uc.constraint_type = 'R' + AND uc2.constraint_name = ucc_ref.constraint_name + AND ucc_ref.position = 1 +WHERE + utc.table_name = UPPER('USERS') +GROUP BY + utc.column_id, + utc.column_name, + utc.data_type, + utc.nullable, + utc.data_length, + utc.data_precision, + utc.data_scale, + utc.data_default +); + +SET TIMING OFF diff --git a/README.md b/README.md index b8f2202..d71d3bc 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ The following environment variables **MUST** be set before starting the applicat | `JWT_SECRET` | Symmetric key for signing and validating JWT tokens | Must be ≥32 characters and contain at least `min(20, length/2)` distinct characters (e.g., 16 distinct characters for a 32-char secret). | | `MINIO_ACCESS_KEY` | Access key for MinIO object storage authentication | Cannot use default values; must be explicitly set. | | `MINIO_SECRET_KEY` | Secret key for MinIO object storage authentication | Cannot use default values; must be explicitly set. | +| `TRACE_PII_SALT` | Salt for hashing PII in traces (userId, client_ip) | **REQUIRED in production.** Min 16 chars, must include uppercase, lowercase, digits, and special characters. Load from secure secrets manager (AWS Secrets Manager, Vault, etc.), NOT from hardcoded properties. | | `CORS_ALLOWED_ORIGINS` | Comma-separated list of trusted origins for CORS | Optional in dev; required in production. Example: `https://app.example.com,https://www.example.com`. If not set, defaults to `http://localhost:4200`. | **Example: Setting Strong Credentials** @@ -200,6 +201,10 @@ export JWT_SECRET=$(openssl rand -base64 32) export MINIO_ACCESS_KEY=$(openssl rand -base64 16) export MINIO_SECRET_KEY=$(openssl rand -base64 32) +# PII hash salt (must include uppercase, lowercase, digits, and special chars) +# For production, load from AWS Secrets Manager, Vault, or similar secure store +export TRACE_PII_SALT="Prod@Salt!2024#Secure$(openssl rand -hex 8)" + # CORS allowed origins (comma-separated, required for production) export CORS_ALLOWED_ORIGINS="https://app.example.com,https://www.example.com" ``` @@ -218,6 +223,7 @@ vault login -method=ldap username= export JWT_SECRET=$(vault kv get -field=jwt_secret secret/ssf/prod) export MINIO_ACCESS_KEY=$(vault kv get -field=access_key secret/ssf/prod) export MINIO_SECRET_KEY=$(vault kv get -field=secret_key secret/ssf/prod) +export TRACE_PII_SALT=$(vault kv get -field=trace_pii_salt secret/ssf/prod) # 3. Start the application ./gradlew bootRun @@ -233,6 +239,7 @@ aws configure export JWT_SECRET=$(aws secretsmanager get-secret-value --secret-id ssf/jwt_secret --query SecretString --output text) export MINIO_ACCESS_KEY=$(aws secretsmanager get-secret-value --secret-id ssf/minio_access_key --query SecretString --output text) export MINIO_SECRET_KEY=$(aws secretsmanager get-secret-value --secret-id ssf/minio_secret_key --query SecretString --output text) +export TRACE_PII_SALT=$(aws secretsmanager get-secret-value --secret-id ssf/trace_pii_salt --query SecretString --output text) # 3. Start the application ./gradlew bootRun diff --git a/SSF-GraphQL-Postman-Collection.json b/SSF-GraphQL-Postman-Collection.json index 1802bd9..f44c70a 100644 --- a/SSF-GraphQL-Postman-Collection.json +++ b/SSF-GraphQL-Postman-Collection.json @@ -14,6 +14,61 @@ } ] }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "// Generate W3C Trace Context headers for OpenTelemetry integration", + "// W3C Trace Context spec: trace-id is 32 hex chars (128 bits), span-id is 16 hex chars (64 bits)", + "function generateTraceId() {", + " // Generate 32 hex characters (128 bits / 4 bits per hex char)", + " let traceId = '';", + " for (let i = 0; i < 32; i++) {", + " traceId += Math.floor(Math.random() * 16).toString(16);", + " }", + " return traceId.toLowerCase();", + "}", + "", + "function generateSpanId() {", + " // Generate 16 hex characters (64 bits / 4 bits per hex char)", + " let spanId = '';", + " for (let i = 0; i < 16; i++) {", + " spanId += Math.floor(Math.random() * 16).toString(16);", + " }", + " return spanId.toLowerCase();", + "}", + "", + "// Generate new trace context for each request", + "const traceId = generateTraceId();", + "const spanId = generateSpanId();", + "const traceFlags = '01'; // Sampled", + "", + "// Validate W3C Trace Context format compliance", + "if (traceId.length !== 32) throw new Error(`Invalid trace-id length: ${traceId.length}, expected 32`);", + "if (spanId.length !== 16) throw new Error(`Invalid span-id length: ${spanId.length}, expected 16`);", + "", + "// W3C Trace Context format: version-traceId-spanId-traceFlags", + "// version=00, traceId=32hex, spanId=16hex, traceFlags=2hex", + "const traceContext = `00-${traceId}-${spanId}-${traceFlags}`;", + "", + "// Set collection-wide environment variables for trace context", + "pm.environment.set('otel_trace_id', traceId);", + "pm.environment.set('otel_span_id', spanId);", + "pm.environment.set('otel_trace_context', traceContext);", + "pm.environment.set('otel_trace_parent', traceContext);", + "", + "// Set baggage for additional context", + "pm.environment.set('otel_baggage', `userId=${pm.environment.get('user_id') || 'anonymous'},environment=dev,service=postman-client`);", + "", + "console.log('[OTel] Generated W3C Trace Context:', traceContext);", + "console.log('[OTel] Trace ID (32 hex):', traceId);", + "console.log('[OTel] Span ID (16 hex):', spanId);" + ] + } + } + ], "item": [ { "name": "Authentication", @@ -43,6 +98,21 @@ { "key": "Content-Type", "value": "application/json" + }, + { + "key": "traceparent", + "value": "{{otel_trace_parent}}", + "description": "W3C Trace Context format: version-traceId-spanId-traceFlags" + }, + { + "key": "tracestate", + "value": "ssf-graphql=1", + "description": "OpenTelemetry tracestate header" + }, + { + "key": "baggage", + "value": "{{otel_baggage}}", + "description": "OpenTelemetry baggage for distributed context" } ], "body": { @@ -70,6 +140,21 @@ { "key": "Content-Type", "value": "application/json" + }, + { + "key": "traceparent", + "value": "{{otel_trace_parent}}", + "description": "W3C Trace Context format: version-traceId-spanId-traceFlags" + }, + { + "key": "tracestate", + "value": "ssf-graphql=1", + "description": "OpenTelemetry tracestate header" + }, + { + "key": "baggage", + "value": "{{otel_baggage}}", + "description": "OpenTelemetry baggage for distributed context" } ], "body": { @@ -97,6 +182,21 @@ { "key": "Content-Type", "value": "application/json" + }, + { + "key": "traceparent", + "value": "{{otel_trace_parent}}", + "description": "W3C Trace Context format: version-traceId-spanId-traceFlags" + }, + { + "key": "tracestate", + "value": "ssf-graphql=1", + "description": "OpenTelemetry tracestate header" + }, + { + "key": "baggage", + "value": "{{otel_baggage}}", + "description": "OpenTelemetry baggage for distributed context" } ], "body": { @@ -129,6 +229,18 @@ { "key": "Content-Type", "value": "application/json" + }, + { + "key": "traceparent", + "value": "{{otel_trace_parent}}" + }, + { + "key": "tracestate", + "value": "ssf-graphql=1" + }, + { + "key": "baggage", + "value": "{{otel_baggage}}" } ], "body": { @@ -156,6 +268,18 @@ { "key": "Content-Type", "value": "application/json" + }, + { + "key": "traceparent", + "value": "{{otel_trace_parent}}" + }, + { + "key": "tracestate", + "value": "ssf-graphql=1" + }, + { + "key": "baggage", + "value": "{{otel_baggage}}" } ], "body": { @@ -183,6 +307,18 @@ { "key": "Content-Type", "value": "application/json" + }, + { + "key": "traceparent", + "value": "{{otel_trace_parent}}" + }, + { + "key": "tracestate", + "value": "ssf-graphql=1" + }, + { + "key": "baggage", + "value": "{{otel_baggage}}" } ], "body": { @@ -233,6 +369,18 @@ { "key": "Content-Type", "value": "application/json" + }, + { + "key": "traceparent", + "value": "{{otel_trace_parent}}" + }, + { + "key": "tracestate", + "value": "ssf-graphql=1" + }, + { + "key": "baggage", + "value": "{{otel_baggage}}" } ], "body": { @@ -260,6 +408,18 @@ { "key": "Content-Type", "value": "application/json" + }, + { + "key": "traceparent", + "value": "{{otel_trace_parent}}" + }, + { + "key": "tracestate", + "value": "ssf-graphql=1" + }, + { + "key": "baggage", + "value": "{{otel_baggage}}" } ], "body": { @@ -287,6 +447,18 @@ { "key": "Content-Type", "value": "application/json" + }, + { + "key": "traceparent", + "value": "{{otel_trace_parent}}" + }, + { + "key": "tracestate", + "value": "ssf-graphql=1" + }, + { + "key": "baggage", + "value": "{{otel_baggage}}" } ], "body": { @@ -351,6 +523,36 @@ "value": "", "type": "string", "description": "Email for user operations - must be set per developer environment for testing" + }, + { + "key": "otel_trace_id", + "value": "", + "type": "string", + "description": "OpenTelemetry Trace ID (auto-generated) - 32-character hex string identifying the entire trace" + }, + { + "key": "otel_span_id", + "value": "", + "type": "string", + "description": "OpenTelemetry Span ID (auto-generated) - 16-character hex string identifying this request span" + }, + { + "key": "otel_trace_context", + "value": "", + "type": "string", + "description": "OpenTelemetry Trace Context (auto-generated) - W3C format for distributed tracing" + }, + { + "key": "otel_trace_parent", + "value": "", + "type": "string", + "description": "OpenTelemetry traceparent header (auto-generated) - W3C Trace Context: version-traceId-spanId-traceFlags" + }, + { + "key": "otel_baggage", + "value": "", + "type": "string", + "description": "OpenTelemetry baggage (auto-generated) - Additional context data: userId, environment, service" } ], "protocolProfileBehavior": { diff --git a/build.gradle b/build.gradle index 0019b20..e84949c 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,18 @@ repositories { mavenCentral() } +dependencyManagement { + imports { + mavenBom "io.opentelemetry:opentelemetry-bom:1.40.0" + mavenBom "io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom:2.6.0" + } + dependencies { + // opentelemetry-semconv is independently released and not controlled by OTEL BOM; + // explicitly manage to a stable version (1.37.0) for consistent semantic conventions + dependency 'io.opentelemetry.semconv:opentelemetry-semconv:1.37.0' + } +} + sourceSets { main { resources { @@ -64,6 +76,18 @@ configurations { implementation 'io.github.resilience4j:resilience4j-micrometer:2.1.0' implementation 'io.micrometer:micrometer-registry-prometheus' // Use Spring Boot 3.5.7 managed version (1.15.0) + // OpenTelemetry - Distributed Tracing + implementation 'io.opentelemetry:opentelemetry-api' // Use Spring Boot 3.5.7 managed version (1.49.0) + implementation 'io.opentelemetry:opentelemetry-sdk' // Use Spring Boot 3.5.7 managed version (1.49.0) + implementation 'io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter:2.6.0' // Spring Boot 3.5 auto-configuration + implementation 'io.opentelemetry:opentelemetry-exporter-otlp:1.40.0' // OTLP exporter with gRPC support for Jaeger + implementation 'io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations' // @WithSpan annotations (version via OTEL BOM) + implementation 'io.opentelemetry:opentelemetry-sdk-extension-jaeger-remote-sampler' // Jaeger remote sampling + implementation 'io.opentelemetry.semconv:opentelemetry-semconv' // Semantic conventions for attributes (version via OTEL BOM) + + // Structured Logging - JSON output for ELK integration + implementation 'net.logstash.logback:logstash-logback-encoder:8.1' // JSON log encoder with improved performance and modern JDK support + // Oracle Database Driver implementation 'com.oracle.database.jdbc:ojdbc11:23.26.0.0.0' diff --git a/db/migration/V600__grant_data_dictionary_privileges.sql b/db/migration/V600__grant_data_dictionary_privileges.sql new file mode 100644 index 0000000..6767041 --- /dev/null +++ b/db/migration/V600__grant_data_dictionary_privileges.sql @@ -0,0 +1,80 @@ +-- ============================================================================ +-- V600: Grant Data Dictionary Privileges to APP_USER +-- ============================================================================ +-- Purpose: Enable APP_USER to query Oracle data dictionary views required for +-- DynamicCrudService metadata introspection (EXPLAIN PLAN, column metadata, etc.) +-- +-- This migration MUST be run as SYSDBA or a user with ADMIN privileges +-- ============================================================================ + +-- Switch to PDB if needed (dynamically detect current container) +-- Only execute ALTER SESSION if we're not already in the target container +BEGIN + DECLARE + v_current_con VARCHAR2(30); + v_alter_session_sql VARCHAR2(100); + BEGIN + -- Determine current container name + v_current_con := SYS_CONTEXT('USERENV', 'CON_NAME'); + DBMS_OUTPUT.PUT_LINE('Current container: ' || v_current_con); + + -- Only attempt ALTER SESSION if not already in a PDB (avoid redundant switches) + -- Note: In single-tenant or if already in correct PDB, this safely skips the switch + IF v_current_con IS NOT NULL AND v_current_con != 'CDB$ROOT' THEN + DBMS_OUTPUT.PUT_LINE('Already in PDB: ' || v_current_con || ' - skipping container switch'); + ELSE + -- Build and execute ALTER SESSION dynamically to handle any PDB name + -- This is more portable but will fail gracefully if not in a multi-tenant environment + BEGIN + EXECUTE IMMEDIATE 'ALTER SESSION SET CONTAINER = FREEPDB1'; + DBMS_OUTPUT.PUT_LINE('✓ Switched to container FREEPDB1'); + EXCEPTION + WHEN OTHERS THEN + -- Container switch failed - likely single-tenant or insufficient privileges + -- Log as informational and continue (container switch is optional) + IF SQLCODE = -65048 THEN + DBMS_OUTPUT.PUT_LINE('Note: Container switch not needed (single-tenant environment)'); + ELSE + DBMS_OUTPUT.PUT_LINE('Note: Container switch skipped - ' || SQLERRM); + END IF; + END; + END IF; + END; +END; +/ + +-- Grant SELECT_CATALOG_ROLE for comprehensive data dictionary access +-- This grants access to all user_* and v$* views which depend on underlying system tables +-- Individual GRANT SELECT ON sys.* statements are insufficient because views depend on +-- system tables that are not directly accessible without this role +BEGIN + EXECUTE IMMEDIATE 'GRANT SELECT_CATALOG_ROLE TO APP_USER'; + DBMS_OUTPUT.PUT_LINE('✓ Granted SELECT_CATALOG_ROLE to APP_USER'); +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE = -1931 OR SQLCODE = -4042 THEN + DBMS_OUTPUT.PUT_LINE('SELECT_CATALOG_ROLE already granted'); + ELSE + -- Re-raise unexpected errors to fail the migration and surface the issue + RAISE; + END IF; +END; +/ + +COMMIT; + +-- Verify grant +BEGIN + DBMS_OUTPUT.PUT_LINE(''); + DBMS_OUTPUT.PUT_LINE('=== Granted Privileges Summary ==='); + DBMS_OUTPUT.PUT_LINE('APP_USER now has SELECT_CATALOG_ROLE which includes:'); + DBMS_OUTPUT.PUT_LINE(' • SELECT on all user_* data dictionary views'); + DBMS_OUTPUT.PUT_LINE(' • SELECT on all v$ dynamic performance views (read-only)'); + DBMS_OUTPUT.PUT_LINE(' • Access to all underlying system tables needed for EXPLAIN PLAN'); + DBMS_OUTPUT.PUT_LINE(''); + DBMS_OUTPUT.PUT_LINE('This enables:'); + DBMS_OUTPUT.PUT_LINE(' • DynamicCrudService table metadata introspection'); + DBMS_OUTPUT.PUT_LINE(' • EXPLAIN PLAN query analysis'); + DBMS_OUTPUT.PUT_LINE(' • Column constraint and foreign key detection'); +END; +/ diff --git a/docker-compose.yml b/docker-compose.yml index 4b31ca8..4455db8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,6 +67,74 @@ services: timeout: 5s retries: 3 + # Elasticsearch - Persistent storage for Jaeger spans and dependencies + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0 + container_name: jaeger-elasticsearch + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + ports: + - "9200:9200" + volumes: + - elasticsearch_data:/usr/share/elasticsearch/data + networks: + - oracle-network + healthcheck: + test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health | grep -q '\"status\":\"yellow\\|green\"'"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + # Jaeger - Distributed Tracing Backend + # Receives OpenTelemetry traces from Spring Boot application via OTLP/gRPC (port 4317) + # Provides trace visualization UI on port 16686 + # Uses Elasticsearch for persistent storage of spans and service dependencies + jaeger: + image: jaegertracing/all-in-one:latest + container_name: jaeger-tracing + depends_on: + elasticsearch: + condition: service_healthy + environment: + # Collector configuration + COLLECTOR_OTLP_ENABLED: "true" + COLLECTOR_OTLP_HOST_PORT: "0.0.0.0:4317" + # Agent configuration (UDP for legacy clients) + AGENT_ZIPKIN_HOST_PORT: "0.0.0.0:5775" + AGENT_COMPACT_THRIFT_HOST_PORT: "0.0.0.0:6831" + AGENT_BINARY_THRIFT_HOST_PORT: "0.0.0.0:6832" + # Query service configuration + QUERY_PORT: "16686" + # Storage backend - use Elasticsearch for persistent span storage and dependency calculation + SPAN_STORAGE_TYPE: elasticsearch + ES_SERVER_URLS: http://elasticsearch:9200 + # Sampling configuration (server-side; applies to all traces received by Jaeger) + # Jaeger samples traces for storage based on this config + SAMPLING_TYPE: "probabilistic" + SAMPLING_PARAM: "0.1" # Server-side: 10% of traces stored for analysis + # Dependency storage with Elasticsearch + DEPENDENCIES_STORAGE_TYPE: elasticsearch + ports: + # OTLP/gRPC receiver (from OpenTelemetry exporters) + - "4317:4317" + # Jaeger UI + - "16686:16686" + # Legacy Thrift/Zipkin receivers (if needed) + - "5775:5775/udp" + - "6831:6831/udp" + - "6832:6832/udp" + networks: + - oracle-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:16686/api/traces", "--connect-timeout", "2", "--max-time", "3"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 15s + # Spring Boot Application app: build: @@ -80,6 +148,8 @@ services: condition: service_healthy redis: condition: service_healthy + jaeger: + condition: service_healthy environment: ORACLE_HOST: oracle ORACLE_PORT: 1521 @@ -87,12 +157,28 @@ services: ORACLE_USER: ${ORACLE_USER:-APP_USER} ORACLE_PASSWORD: ${ORACLE_PASSWORD:-ssfpassword} ORACLE_ADMIN_PASSWORD: ${ORACLE_ADMIN_PASSWORD:-AdminPassword123} + DB_USER_PASSWORD: ${DB_USER_PASSWORD:-ssfpassword} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-o-cara-esta-pedindo-uma-chave-de-acesso} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-o-cara-esta-pedindo-uma-chave-secreta} JWT_SECRET: ${JWT_SECRET:-0123456789abcdefghijklmnopqrstuv0123456789abcdefghijklmnopqrstuv} KEYSTORE_PASSWORD: ${KEYSTORE_PASSWORD:-changeit} REDIS_HOST: redis REDIS_PORT: 6379 + # Trace PII Salt - for hashing sensitive data in traces (minimum 16 chars, mixed case/numbers/special) + TRACE_PII_SALT: ${TRACE_PII_SALT:-some_random_salt_value_for_hashing_pii} + TRACE_INCLUDE_PII: "true" + # OpenTelemetry Configuration + OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317 + OTEL_EXPORTER_OTLP_PROTOCOL: grpc + OTEL_SERVICE_NAME: ssf-graphql + # Disable logs exporter (Jaeger all-in-one doesn't have logs receiver) + OTEL_LOGS_EXPORTER: none + # Sampling configuration (client-side; reduces telemetry export volume before transmission) + # Client samples 10% of traces locally, reducing network load; Jaeger further samples at ingestion. + # Effective sampling: 10% × 10% = 1% of traces reach storage for detailed debugging. + # For higher visibility during development, increase OTEL_TRACES_SAMPLER_ARG to 1.0 (100%). + OTEL_TRACES_SAMPLER: traceidratio + OTEL_TRACES_SAMPLER_ARG: "0.1" # Client-side: 10% of traces exported to Jaeger ports: - "8443:8443" volumes: @@ -110,6 +196,7 @@ volumes: oracle_data: minio_data: redis_data: + elasticsearch_data: networks: oracle-network: diff --git a/docker-entrypoint-initdb.d/01-init-user.sh b/docker-entrypoint-initdb.d/01-init-user.sh index 307aa5a..636ab57 100755 --- a/docker-entrypoint-initdb.d/01-init-user.sh +++ b/docker-entrypoint-initdb.d/01-init-user.sh @@ -120,7 +120,7 @@ BEGIN EXECUTE IMMEDIATE 'GRANT CREATE SEQUENCE TO APP_USER'; EXECUTE IMMEDIATE 'GRANT CREATE INDEX TO APP_USER'; EXECUTE IMMEDIATE 'ALTER USER APP_USER QUOTA UNLIMITED ON ssfspace'; - DBMS_OUTPUT.PUT_LINE('Privileges granted to APP_USER'); + DBMS_OUTPUT.PUT_LINE('✓ Basic privileges granted to APP_USER'); EXCEPTION WHEN OTHERS THEN IF SQLCODE = -1931 OR SQLCODE = -4042 THEN @@ -131,6 +131,14 @@ EXCEPTION END; / +-- Grant SELECT_CATALOG_ROLE for data dictionary access (required for DynamicCrudService metadata introspection) +-- This grants access to all user_* and v$* views which are built on underlying system tables +-- Individual GRANT SELECT ON sys.* statements are insufficient +-- Executed unconditionally during container initialization +GRANT SELECT_CATALOG_ROLE TO APP_USER; +DBMS_OUTPUT.PUT_LINE('✓ SELECT_CATALOG_ROLE granted to APP_USER for data dictionary access'); +/ + COMMIT; EXIT; EOFUSER diff --git a/frontend/README.md b/frontend/README.md index f5682d8..02b5bb3 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -39,6 +39,19 @@ src/ Defined in `src/app/graphql.config.ts` with auth token support via `localStorage['auth_token']` and environment-driven endpoint URLs (`src/environments`). +## Telemetry configuration (PostHog) + +- The Angular environments no longer embed analytics keys. Set `NG_APP_POSTHOG_KEY` (and optionally `NG_APP_POSTHOG_HOST`) before running `npm start`/`npm run build`. +- Example: + + ```bash + export NG_APP_POSTHOG_KEY="phc_dev_local_only" # use a fake key for local dev + export NG_APP_POSTHOG_HOST="https://us.i.posthog.com" + npm start + ``` + +- CI/CD pipelines should inject the real key via environment variables and rotate/revoke any previously exposed secrets in PostHog. + ## Code generation Configure operations under `src/app/graphql`. Schema is read from `https://localhost:8443/graphql`. Generated types are written to `src/app/graphql/generated.ts`. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0553a0a..8c7c4bb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,7 +29,7 @@ "graphql-ws": "~6.0.6", "ng-zorro-antd": "~18.2.1", "ngx-echarts": "^18.0.0", - "posthog-js": "^1.297.2", + "posthog-js": "^1.293.0", "rxjs": "~7.8.0", "tslib": "~2.3.0", "zone.js": "~0.14.10" diff --git a/frontend/src/app/core/services/auth.service.ts b/frontend/src/app/core/services/auth.service.ts index 547dc04..afbec7f 100644 --- a/frontend/src/app/core/services/auth.service.ts +++ b/frontend/src/app/core/services/auth.service.ts @@ -47,6 +47,7 @@ export class AuthService implements OnDestroy { private loadCurrentUserSubscription?: Subscription; private refreshSuccessSubscription?: Subscription; private refreshFailureSubscription?: Subscription; + private lastIdentifiedUserId: string | null = null; private apollo = inject(Apollo); private tokenStorage = inject(TokenStorageAdapter); @@ -161,12 +162,15 @@ export class AuthService implements OnDestroy { this.tokenStorage.markAuthenticated(false); this.currentUser$.next(null); this.authStateSubject$.next(AuthState.UNAUTHENTICATED); - // Reset PostHog user tracking (non-blocking) + + // Reset PostHog user tracking (synchronous call, wrapped in try/catch for safety) try { this.posthogService.resetUser(); } catch (error) { console.warn('Failed to reset PostHog user:', error); } + + this.lastIdentifiedUserId = null; try { await this.apollo.client.clearStore(); } catch (error) { @@ -201,6 +205,7 @@ export class AuthService implements OnDestroy { if (this.authStateSubject$.value === AuthState.LOADING) { this.authStateSubject$.next(AuthState.AUTHENTICATED); } + this.identifyUserIfNeeded(user); } // If no user but auth state is already set, don't change it // This allows async user loading without reverting auth state @@ -210,8 +215,11 @@ export class AuthService implements OnDestroy { console.warn('Failed to load current user details:', error); // Only logout if it's a 401/403 auth error if (error?.networkError?.status === 401 || error?.networkError?.status === 403) { + // Fire logout async without blocking - logout() sets authState to UNAUTHENTICATED + this.logout().catch(err => console.warn('Logout failed:', err)); + } else if (this.authStateSubject$.value === AuthState.LOADING) { + // Only update state if we haven't already (e.g., from a logout() call) this.authStateSubject$.next(AuthState.UNAUTHENTICATED); - this.tokenStorage.clearToken(); } // For other errors, keep current auth state (likely AUTHENTICATED from setAuthToken) return of(null); @@ -234,14 +242,7 @@ export class AuthService implements OnDestroy { this.currentUser$.next(nextUser); this.authStateSubject$.next(AuthState.AUTHENTICATED); // Track user login to PostHog (non-blocking) - try { - this.posthogService.identifyUser(nextUser.id, { - username: nextUser.username, - email: nextUser.email - }); - } catch (error) { - console.warn('Failed to track user in PostHog:', error); - } + this.identifyUserIfNeeded(nextUser); } else { // No user data in response, but we have a valid token // Set AUTHENTICATED immediately so guards pass @@ -291,7 +292,28 @@ export class AuthService implements OnDestroy { this.refreshFailureSubscription = this.refreshTokenService.refreshFailures$() .subscribe((error) => { console.warn('Token refresh failed after retries:', error); - void this.logout(); + // Fire logout async without blocking + this.logout().catch(err => console.warn('Logout failed:', err)); }); } + + private identifyUserIfNeeded(user: User): void { + if (!user?.id) { + return; + } + + if (this.lastIdentifiedUserId === user.id) { + return; + } + + try { + // Only send the stable user ID to PostHog to avoid leaking PII. + this.posthogService.identifyUser(user.id); + // Mark as identified only after successful call to enable retry on failure + this.lastIdentifiedUserId = user.id; + } catch (error) { + console.warn('Failed to track user in PostHog:', error); + // lastIdentifiedUserId remains unset to allow retry on next call + } + } } diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index 6edf7f0..ce226df 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -1,3 +1,17 @@ +const readNgEnv = (key: string, fallback = ''): string => { + const globalEnv = (globalThis as any)?.__env; + if (globalEnv && typeof globalEnv[key] === 'string') { + return globalEnv[key] as string; + } + + const processEnv = (globalThis as any)?.process?.env; + if (processEnv && typeof processEnv[key] === 'string') { + return processEnv[key] as string; + } + + return fallback; +}; + export const environment = { production: false, // BACKEND ENDPOINTS: The graphqlEndpoint and apiUrl below point to the backend service on port 8443. @@ -12,9 +26,19 @@ export const environment = { graphqlEndpoint: 'https://localhost:8443/graphql', apiUrl: 'https://localhost:8443', enableHydration: false, - posthog: { - enabled: true, - key: 'phc_iZMXiVykuSm6uzfC9UHeJ0r6g4xQzes75co6pq7uLdq', - apiHost: 'https://us.i.posthog.com', - }, + posthog: (() => { + const isProduction = typeof window !== 'undefined' && window.location.hostname === 'prod.example.com'; + const posthogKey = readNgEnv('NG_APP_POSTHOG_KEY'); + // Use dev fallback key only when not in production + const key = posthogKey || (isProduction ? '' : 'local-dev-posthog-key'); + const enabled = !!(key && key.length > 0); + if (!enabled) { + console.warn('PostHog disabled: API key not configured. Set NG_APP_POSTHOG_KEY environment variable to enable analytics.'); + } + return { + enabled, + key, + apiHost: readNgEnv('NG_APP_POSTHOG_HOST', 'https://us.i.posthog.com'), + }; + })(), }; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 2d84a7f..ea13f80 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -7277,7 +7277,7 @@ postcss@^8.4.43: picocolors "^1.1.1" source-map-js "^1.2.1" -posthog-js@^1.297.2: +posthog-js@^1.293.0: version "1.297.2" resolved "https://registry.npmjs.org/posthog-js/-/posthog-js-1.297.2.tgz" integrity sha512-pDtCKHpKegV1D5Yk9PBmkFwI9FMnLJm0TsBO5c5/PhPq5Om4y/t+1qqbNcLCdLajkuYl2px9UlRTzycQ6W7Vmw== diff --git a/sql/master.sql b/sql/master.sql index c37f0f8..875cbf3 100644 --- a/sql/master.sql +++ b/sql/master.sql @@ -15,6 +15,14 @@ @@tables/audit_login_attempts.sql @@tables/audit_sessions.sql @@tables/audit_error_log.sql +@@tables/audit_mfa_events.sql +@@tables/mfa_totp_secrets.sql +@@tables/mfa_sms_enrollments.sql +@@tables/mfa_webauthn_credentials.sql +@@tables/mfa_backup_codes.sql +@@tables/roles.sql +@@tables/user_roles.sql +@@tables/audit_role_changes.sql -- Indexes (run as app user) @@indexes/users_indexes.sql diff --git a/sql/packages/user_pkg_body.sql b/sql/packages/user_pkg_body.sql index 2fa19f9..de6c845 100644 --- a/sql/packages/user_pkg_body.sql +++ b/sql/packages/user_pkg_body.sql @@ -43,7 +43,7 @@ CREATE OR REPLACE PACKAGE BODY user_pkg AS v_cursor SYS_REFCURSOR; BEGIN OPEN v_cursor FOR - SELECT id, username, email + SELECT id, username, email, avatar_key, account_status, account_deactivated_at FROM users WHERE id = p_user_id; RETURN v_cursor; @@ -53,7 +53,7 @@ CREATE OR REPLACE PACKAGE BODY user_pkg AS v_cursor SYS_REFCURSOR; BEGIN OPEN v_cursor FOR - SELECT id, username, email + SELECT id, username, email, avatar_key, account_status, account_deactivated_at FROM users WHERE username = p_username; RETURN v_cursor; @@ -63,7 +63,7 @@ CREATE OR REPLACE PACKAGE BODY user_pkg AS v_cursor SYS_REFCURSOR; BEGIN OPEN v_cursor FOR - SELECT id, username, email + SELECT id, username, email, avatar_key, account_status, account_deactivated_at FROM users WHERE email = p_email; RETURN v_cursor; diff --git a/sql/tables/audit_mfa_events.sql b/sql/tables/audit_mfa_events.sql new file mode 100644 index 0000000..a6fa97e --- /dev/null +++ b/sql/tables/audit_mfa_events.sql @@ -0,0 +1,60 @@ +-- Audit table for MFA events +-- Tracks all multi-factor authentication events (setup, verification, failures, admin overrides) + +CREATE TABLE AUDIT_MFA_EVENTS ( + id NUMBER(19) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id NUMBER(19) REFERENCES users(id) ON DELETE SET NULL, + admin_id NUMBER(19) REFERENCES users(id) ON DELETE SET NULL, + event_type VARCHAR2(50) NOT NULL + CONSTRAINT chk_audit_mfa_event_type CHECK (event_type IN ( + 'LOGIN_ATTEMPT', -- User attempted to log in with MFA + 'ENROLLMENT', -- User enrolled in MFA method + 'ENROLLMENT_VERIFIED', -- User verified MFA enrollment + 'VERIFICATION_SUCCESS', -- User successfully verified with MFA + 'VERIFICATION_FAILED', -- User failed MFA verification + 'RECOVERY_CODE_USED', -- User used a recovery/backup code + 'RECOVERY_CODE_GENERATED', -- New recovery codes were generated + 'METHOD_DISABLED', -- User disabled an MFA method + 'METHOD_ENABLED', -- User enabled an MFA method + 'ADMIN_RESET', -- Admin reset user's MFA + 'ADMIN_OVERRIDE', -- Admin overrode MFA requirement + 'RATE_LIMIT_EXCEEDED', -- Too many failed attempts + 'ACCOUNT_LOCKED', -- Account locked due to MFA failures + 'ACCOUNT_UNLOCKED' -- Admin unlocked account + )), + mfa_method VARCHAR2(50) + CONSTRAINT chk_audit_mfa_method CHECK (mfa_method IS NULL OR mfa_method IN ( + 'TOTP', -- Time-based One-Time Password (Google Authenticator, Authy, etc.) + 'SMS', -- Short Message Service OTP to phone + 'EMAIL', -- Email-based OTP or verification link + 'PUSH', -- Push notification to mobile app + 'U2F', -- Universal 2nd Factor (FIDO2/WebAuthn security keys) + 'SECURITY_QUESTIONS', -- Knowledge-based authentication + 'BACKUP_CODES', -- One-time backup codes + 'NONE' -- No MFA (used for audit trail of non-MFA actions) + )), + status VARCHAR2(50) NOT NULL + CONSTRAINT chk_audit_mfa_status CHECK (status IN ( + 'SUCCESS', -- Event completed successfully + 'FAILURE', -- Event failed (invalid code, network error, etc.) + 'PENDING', -- Event pending completion (e.g., awaiting user confirmation) + 'EXPIRED', -- Event expired (e.g., OTP expired before use) + 'RATE_LIMITED', -- Event rejected due to rate limiting + 'CANCELLED', -- Event cancelled by user or system + 'BLOCKED' -- Event blocked by security policy + )), + details CLOB, + ip_address VARCHAR2(45), + user_agent VARCHAR2(2000), + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL +); + +COMMENT ON TABLE AUDIT_MFA_EVENTS IS 'Audit trail for all multi-factor authentication events (setup, verification, failures, admin overrides). Retention: 7 years (SOX compliance). All column values are enforced via CHECK constraints. user_id is nullable to preserve audit records when users are deleted (privacy-protecting design).'; +COMMENT ON COLUMN AUDIT_MFA_EVENTS.user_id IS 'User ID (nullable). Associated user action. Sets to NULL when user is deleted to preserve audit trail while protecting privacy (ON DELETE SET NULL).'; +COMMENT ON COLUMN AUDIT_MFA_EVENTS.admin_id IS 'Admin user ID (nullable). Admin who performed action if any. Sets to NULL when admin user is deleted (ON DELETE SET NULL).'; +COMMENT ON COLUMN AUDIT_MFA_EVENTS.event_type IS 'Type of MFA event: LOGIN_ATTEMPT, ENROLLMENT, VERIFICATION_SUCCESS, VERIFICATION_FAILED, RECOVERY_CODE_USED, RECOVERY_CODE_GENERATED, METHOD_DISABLED, METHOD_ENABLED, ADMIN_RESET, ADMIN_OVERRIDE, RATE_LIMIT_EXCEEDED, ACCOUNT_LOCKED, ACCOUNT_UNLOCKED. Enforced by CHECK constraint.'; +COMMENT ON COLUMN AUDIT_MFA_EVENTS.mfa_method IS 'MFA method used: TOTP, SMS, EMAIL, PUSH, U2F, SECURITY_QUESTIONS, BACKUP_CODES, or NONE. Nullable (for events not tied to a specific method). Enforced by CHECK constraint.'; +COMMENT ON COLUMN AUDIT_MFA_EVENTS.status IS 'Outcome of event: SUCCESS, FAILURE, PENDING, EXPIRED, RATE_LIMITED, CANCELLED, BLOCKED. Enforced by CHECK constraint.'; +COMMENT ON COLUMN AUDIT_MFA_EVENTS.details IS 'Additional event details (JSON or free text for flexible event context).'; +COMMENT ON COLUMN AUDIT_MFA_EVENTS.ip_address IS 'IP address of the client (IPv4 or IPv6, max 45 chars per RFC 3986).'; +COMMENT ON COLUMN AUDIT_MFA_EVENTS.user_agent IS 'User agent string from HTTP request (browser/app identification).'; diff --git a/sql/tables/mfa_backup_codes.sql b/sql/tables/mfa_backup_codes.sql new file mode 100644 index 0000000..71559c3 --- /dev/null +++ b/sql/tables/mfa_backup_codes.sql @@ -0,0 +1,21 @@ +-- MFA backup codes table +-- Stores one-time backup codes for MFA recovery + +CREATE TABLE MFA_BACKUP_CODES ( + id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id NUMBER(19) NOT NULL, + code_hash VARCHAR2(255) NOT NULL, + used_at TIMESTAMP, -- NULL = available, timestamp = consumed + created_at TIMESTAMP DEFAULT SYSTIMESTAMP, + CONSTRAINT uq_mfa_backup_code UNIQUE (user_id, code_hash), + CONSTRAINT fk_mfa_backup_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX idx_mfa_backup_user_id ON MFA_BACKUP_CODES(user_id); +CREATE INDEX idx_mfa_backup_used_at ON MFA_BACKUP_CODES(used_at); +CREATE INDEX idx_mfa_backup_created ON MFA_BACKUP_CODES(created_at); + +COMMENT ON TABLE MFA_BACKUP_CODES IS 'Single-use backup codes for MFA recovery if user loses primary device.'; +COMMENT ON COLUMN MFA_BACKUP_CODES.code_hash IS 'BCrypt hash of backup code. Stored as salted hash, never plaintext.'; +COMMENT ON COLUMN MFA_BACKUP_CODES.used_at IS 'Consumption timestamp: NULL=available, timestamp=consumed (one-time use only).'; +COMMENT ON COLUMN MFA_BACKUP_CODES.user_id IS 'User identifier who owns these backup codes.'; diff --git a/sql/tables/mfa_sms_enrollments.sql b/sql/tables/mfa_sms_enrollments.sql new file mode 100644 index 0000000..90b8a87 --- /dev/null +++ b/sql/tables/mfa_sms_enrollments.sql @@ -0,0 +1,37 @@ +-- MFA SMS enrollments table +-- Stores enrolled phone numbers and SMS OTP state + +CREATE TABLE MFA_SMS_ENROLLMENTS ( + id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id NUMBER(19) NOT NULL REFERENCES USERS(id) ON DELETE CASCADE, + phone_number RAW(256) NOT NULL, -- AES-256-GCM encrypted E.164 phone number + is_verified NUMBER(1) DEFAULT 0, -- 0 = pending, 1 = confirmed + CONSTRAINT chk_is_verified CHECK (is_verified IN (0, 1)), + verification_code_hash VARCHAR2(128), -- HMAC-SHA256 hash of verification code + verification_code_salt RAW(16), -- Salt for verification code hash + verification_code_expires_at TIMESTAMP, + otp_code_hash VARCHAR2(128), -- HMAC-SHA256 hash of OTP code + otp_code_salt RAW(16), -- Salt for OTP code hash + otp_expires_at TIMESTAMP, + last_otp_sent_at TIMESTAMP, + otp_send_count NUMBER DEFAULT 0, -- Rate limiting: prevent spam + CONSTRAINT chk_otp_send_count CHECK (otp_send_count >= 0), + created_at TIMESTAMP DEFAULT SYSTIMESTAMP, + verified_at TIMESTAMP, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP, + UNIQUE (user_id), -- Only one phone number per user + CONSTRAINT chk_verification_code_pair CHECK ((verification_code_hash IS NULL) = (verification_code_salt IS NULL)), + CONSTRAINT chk_otp_code_pair CHECK ((otp_code_hash IS NULL) = (otp_code_salt IS NULL)) +); + +CREATE INDEX idx_mfa_sms_is_verified ON MFA_SMS_ENROLLMENTS(is_verified); +CREATE INDEX idx_mfa_sms_phone_expires ON MFA_SMS_ENROLLMENTS(verification_code_expires_at); +CREATE INDEX idx_mfa_sms_otp_expires ON MFA_SMS_ENROLLMENTS(otp_expires_at); + +COMMENT ON TABLE MFA_SMS_ENROLLMENTS IS 'Stores SMS-based MFA enrollments and temporary OTP codes.'; +COMMENT ON COLUMN MFA_SMS_ENROLLMENTS.phone_number IS 'E.164 phone number stored as AES-256-GCM ciphertext (RAW). Application decrypts before use.'; +COMMENT ON COLUMN MFA_SMS_ENROLLMENTS.verification_code_hash IS 'HMAC-SHA256 hash of temporary 6-digit code for phone verification.'; +COMMENT ON COLUMN MFA_SMS_ENROLLMENTS.verification_code_salt IS 'Salt for verification code hash to prevent rainbow table attacks.'; +COMMENT ON COLUMN MFA_SMS_ENROLLMENTS.otp_code_hash IS 'HMAC-SHA256 hash of current 6-digit OTP code sent to phone.'; +COMMENT ON COLUMN MFA_SMS_ENROLLMENTS.otp_code_salt IS 'Salt for OTP code hash to prevent rainbow table attacks.'; +COMMENT ON COLUMN MFA_SMS_ENROLLMENTS.otp_send_count IS 'Count of OTP sends in current window for rate limiting.'; diff --git a/sql/tables/mfa_totp_secrets.sql b/sql/tables/mfa_totp_secrets.sql new file mode 100644 index 0000000..8a5bb06 --- /dev/null +++ b/sql/tables/mfa_totp_secrets.sql @@ -0,0 +1,49 @@ +-- MFA TOTP secrets table +-- Stores encrypted TOTP secrets and enrollment state for multi-factor authentication + +CREATE TABLE MFA_TOTP_SECRETS ( + id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id NUMBER(19) NOT NULL REFERENCES USERS(id) ON DELETE CASCADE, + secret RAW(256) NOT NULL, -- Encrypted TOTP secret (AES-256-GCM ciphertext + tag) + iv RAW(16), -- Initialization vector for encryption (if using application-level crypto) + is_verified NUMBER(1) DEFAULT 0 CHECK (is_verified IN (0, 1)), -- 0 = pending confirmation, 1 = active + created_at TIMESTAMP DEFAULT SYSTIMESTAMP, + verified_at TIMESTAMP, + failed_attempts NUMBER DEFAULT 0 CHECK (failed_attempts >= 0), -- Prevent negative attempt counts + last_attempt_at TIMESTAMP, + UNIQUE (user_id) -- Only one TOTP secret per user +); + +CREATE INDEX idx_mfa_totp_user_verified ON MFA_TOTP_SECRETS(user_id, is_verified); + +-- Enforce verified_at state constraint: +-- When is_verified = 1 (confirmed), verified_at MUST be NOT NULL +-- When is_verified = 0 (pending), verified_at MUST be NULL +CREATE OR REPLACE TRIGGER trg_mfa_totp_verified_at_check +BEFORE INSERT OR UPDATE ON MFA_TOTP_SECRETS +FOR EACH ROW +BEGIN + -- If enrollment is confirmed (is_verified = 1), verified_at must be populated + IF :NEW.is_verified = 1 AND :NEW.verified_at IS NULL THEN + RAISE_APPLICATION_ERROR( + -20001, + 'MFA_TOTP_SECRETS: verified_at must be NOT NULL when is_verified = 1 (confirmed enrollment)' + ); + END IF; + + -- If enrollment is pending (is_verified = 0), verified_at must be NULL + IF :NEW.is_verified = 0 AND :NEW.verified_at IS NOT NULL THEN + RAISE_APPLICATION_ERROR( + -20002, + 'MFA_TOTP_SECRETS: verified_at must be NULL when is_verified = 0 (pending enrollment)' + ); + END IF; +END trg_mfa_totp_verified_at_check; +/ + +COMMENT ON TABLE MFA_TOTP_SECRETS IS 'Stores encrypted TOTP (Time-based One-Time Password) secrets for MFA enrollment. Enforces verified_at state via trigger and failed_attempts >= 0 via CHECK constraint.'; +COMMENT ON COLUMN MFA_TOTP_SECRETS.secret IS 'Encrypted TOTP secret (RFC 6238). RAW(256) stores AES-256-GCM ciphertext + 16-byte tag.'; +COMMENT ON COLUMN MFA_TOTP_SECRETS.iv IS 'Initialization vector for application-level encryption.'; +COMMENT ON COLUMN MFA_TOTP_SECRETS.is_verified IS 'Enrollment status: 0=pending verification, 1=confirmed and active. Triggers verify verified_at state.'; +COMMENT ON COLUMN MFA_TOTP_SECRETS.failed_attempts IS 'Failed verification attempts (reset after successful verification). CHECK constraint enforces >= 0.'; +COMMENT ON TRIGGER trg_mfa_totp_verified_at_check ON MFA_TOTP_SECRETS IS 'Enforces constraint: is_verified=1 requires verified_at NOT NULL, is_verified=0 requires verified_at IS NULL. Raises ORA-20001/ORA-20002 on violation.'; diff --git a/sql/tables/mfa_webauthn_credentials.sql b/sql/tables/mfa_webauthn_credentials.sql new file mode 100644 index 0000000..6c018f1 --- /dev/null +++ b/sql/tables/mfa_webauthn_credentials.sql @@ -0,0 +1,26 @@ +-- WebAuthn credentials table +-- Stores FIDO2 security key registrations + +CREATE TABLE MFA_WEBAUTHN_CREDENTIALS ( + id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id NUMBER(19) NOT NULL REFERENCES USERS(id) ON DELETE CASCADE, + credential_id VARCHAR2(1366) NOT NULL, -- Base64-encoded credential ID + public_key CLOB NOT NULL, -- Base64-encoded public key + nickname VARCHAR2(100), -- User-friendly name + sign_count NUMBER DEFAULT 0 CONSTRAINT chk_mfa_webauthn_sign_count_gte_0 CHECK (sign_count >= 0), + transports VARCHAR2(100), -- Comma-separated transports + attestation_format VARCHAR2(20), -- Attestation format + created_at TIMESTAMP DEFAULT SYSTIMESTAMP, + last_used_at TIMESTAMP, + UNIQUE (user_id, credential_id) +); + +CREATE INDEX idx_mfa_webauthn_user_id ON MFA_WEBAUTHN_CREDENTIALS(user_id); +CREATE INDEX idx_mfa_webauthn_cred_id ON MFA_WEBAUTHN_CREDENTIALS(credential_id); +CREATE INDEX idx_mfa_webauthn_last_used ON MFA_WEBAUTHN_CREDENTIALS(last_used_at); + +COMMENT ON TABLE MFA_WEBAUTHN_CREDENTIALS IS 'Stores WebAuthn (FIDO2) security key credentials for user authentication.'; +COMMENT ON COLUMN MFA_WEBAUTHN_CREDENTIALS.credential_id IS 'Unique identifier for the credential (Base64-encoded up to 1024-byte values).'; +COMMENT ON COLUMN MFA_WEBAUTHN_CREDENTIALS.public_key IS 'User public key for signature verification (Base64-encoded CBOR).'; +COMMENT ON COLUMN MFA_WEBAUTHN_CREDENTIALS.sign_count IS 'Signature counter used to detect cloned authenticators.'; +COMMENT ON COLUMN MFA_WEBAUTHN_CREDENTIALS.attestation_format IS 'Format of the attestation statement (for trust decisions).'; diff --git a/src/main/java/com/rcs/ssf/http/filter/TraceContextHolder.java b/src/main/java/com/rcs/ssf/http/filter/TraceContextHolder.java new file mode 100644 index 0000000..faeecd6 --- /dev/null +++ b/src/main/java/com/rcs/ssf/http/filter/TraceContextHolder.java @@ -0,0 +1,221 @@ +package com.rcs.ssf.http.filter; + +/** + * Thread-safe holder for request trace context (request ID, user ID, span ID). + * + *

Stores trace context in a {@link ThreadLocal} so downstream service and repository + * layers can correlate logs with the active HTTP request. Servlet containers reuse + * worker threads, so callers must clear the context once request + * processing completes to avoid leaking request-scoped data between users or causing + * classloader retention. Always wrap filter/controller work in a {@code try/finally} + * block and call {@link #clear()} in the {@code finally} block.

+ * + *
+ * try {
+ *     TraceContextHolder.setRequestId(requestId);
+ *     TraceContextHolder.setUserId(userId);
+ *     filterChain.doFilter(request, response);
+ * } finally {
+ *     TraceContextHolder.clear();
+ * }
+ * 
+ */ +public class TraceContextHolder { + + private static final ThreadLocal CONTEXT = new ThreadLocal<>(); + + private TraceContextHolder() { + // Utility class - prevent instantiation + } + + /** + * Set request ID for current thread. + */ + public static void setRequestId(String requestId) { + TraceContext context = getOrCreateContext(); + context.setRequestId(requestId); + } + + /** + * Get request ID for current thread. + */ + public static String getRequestId() { + TraceContext context = CONTEXT.get(); + return context != null ? context.getRequestId() : null; + } + + /** + * Set user ID for current thread. + */ + public static void setUserId(String userId) { + TraceContext context = getOrCreateContext(); + context.setUserId(userId); + } + + /** + * Get user ID for current thread. + */ + public static String getUserId() { + TraceContext context = CONTEXT.get(); + return context != null ? context.getUserId() : null; + } + + /** + * Set span ID for current thread (populated by OpenTelemetry). + */ + public static void setSpanId(String spanId) { + TraceContext context = getOrCreateContext(); + context.setSpanId(spanId); + } + + /** + * Get span ID for current thread. + */ + public static String getSpanId() { + TraceContext context = CONTEXT.get(); + return context != null ? context.getSpanId() : null; + } + + /** + * Set trace ID for current thread (populated by OpenTelemetry). + */ + public static void setTraceId(String traceId) { + TraceContext context = getOrCreateContext(); + context.setTraceId(traceId); + } + + /** + * Get trace ID for current thread. + */ + public static String getTraceId() { + TraceContext context = CONTEXT.get(); + return context != null ? context.getTraceId() : null; + } + + /** + * Get all context data as a snapshot for logging. + */ + public static TraceContext getContext() { + TraceContext context = CONTEXT.get(); + return context != null ? new TraceContext(context) : new TraceContext(); + } + + /** + * Clear all context data (called at end of request). + */ + public static void clear() { + CONTEXT.remove(); + } + + private static TraceContext getOrCreateContext() { + TraceContext context = CONTEXT.get(); + if (context == null) { + context = new TraceContext(); + CONTEXT.set(context); + } + return context; + } + + /** + * Inner class holding trace context data. + * Fields are private to enforce encapsulation; access via getters/setters only. + */ + public static class TraceContext { + private String requestId; + private String userId; + private String traceId; + private String spanId; + + public TraceContext() { + } + + public TraceContext(TraceContext other) { + if (other == null) { + return; + } + this.requestId = other.requestId; + this.userId = other.userId; + this.traceId = other.traceId; + this.spanId = other.spanId; + } + + /** + * Get request ID. + */ + public String getRequestId() { + return requestId; + } + + /** + * Set request ID (package-private; use TraceContextHolder.setRequestId() publicly). + */ + void setRequestId(String requestId) { + this.requestId = requestId; + } + + /** + * Get user ID. + */ + public String getUserId() { + return userId; + } + + /** + * Set user ID (package-private; use TraceContextHolder.setUserId() publicly). + */ + void setUserId(String userId) { + this.userId = userId; + } + + /** + * Get trace ID. + */ + public String getTraceId() { + return traceId; + } + + /** + * Set trace ID (package-private; use TraceContextHolder.setTraceId() publicly). + */ + void setTraceId(String traceId) { + this.traceId = traceId; + } + + /** + * Get span ID. + */ + public String getSpanId() { + return spanId; + } + + /** + * Set span ID (package-private; use TraceContextHolder.setSpanId() publicly). + */ + void setSpanId(String spanId) { + this.spanId = spanId; + } + + @Override + public String toString() { + return "TraceContext{" + + "requestId='" + requestId + '\'' + + ", traceId='" + traceId + '\'' + + ", spanId='" + spanId + '\'' + + ", userId=''" + + '}'; + } + + /** + * Returns the full context, including the user identifier. Only call this from + * secured diagnostics tooling where including PII is acceptable. + */ + public String toDetailedString() { + return "TraceContext{" + + "requestId='" + requestId + '\'' + + ", userId='" + userId + '\'' + + ", traceId='" + traceId + '\'' + + ", spanId='" + spanId + '\'' + + '}'; + } + } +} diff --git a/src/main/java/com/rcs/ssf/http/filter/TraceIdFilter.java b/src/main/java/com/rcs/ssf/http/filter/TraceIdFilter.java new file mode 100644 index 0000000..47b0449 --- /dev/null +++ b/src/main/java/com/rcs/ssf/http/filter/TraceIdFilter.java @@ -0,0 +1,240 @@ +package com.rcs.ssf.http.filter; + +import io.opentelemetry.api.trace.Span; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.Principal; +import java.util.HexFormat; +import java.util.UUID; + +/** + * Request Correlation and Tracing Filter. + * + * Generates a unique request ID (X-Request-ID header) for each HTTP request + * and propagates it via ThreadLocal for use in all service layers. + * + * Features: + * - Generates unique request ID (UUID) if not provided + * - Sets X-Request-ID response header for client tracking + * - Stores in ThreadLocal for propagation to services/repository layers + * - Creates OpenTelemetry span attributes for trace correlation + * - Exports trace context to MDC for structured logging + * - Hashes PII (userId, client_ip) by default using SHA-256 with a validated salt + * + * PII Hashing: + * - By default (trace.include-pii=false), userId and client_ip are hashed to protect privacy + * - The hash salt must be provided via TRACE_PII_SALT environment variable or Spring properties + * - Salt is validated at startup: must be non-empty, min 16 chars, with character variety + * - For production, configure via secure secrets manager (AWS Secrets Manager, Vault, etc.) + * - Set trace.include-pii=true to disable hashing and export raw PII (NOT recommended) + * + * Threading Model: + * - The filter performs explicit cleanup in a {@code finally} block by calling + * {@link TraceContextHolder#clear()} to avoid leaking request data across reused + * servlet container threads (in addition to Spring's RequestContextHolder hygiene) + * + * Usage in Services: + * String requestId = TraceContextHolder.getRequestId(); + */ +@Component +@Slf4j +public class TraceIdFilter extends OncePerRequestFilter { + + private static final int MINIMUM_SALT_LENGTH = 16; + private static final String SALT_VALIDATION_ERROR = "PII salt validation failed: %s"; + + @Value("${trace.include-pii:false}") + private boolean includePii; + + @Value("${trace.pii-salt:}") + private String piiSalt; + + /** + * Validates PII salt at component initialization. + * Throws IllegalArgumentException if salt is weak or missing. + * Called by Spring during bean creation. + */ + @jakarta.annotation.PostConstruct + public void validatePiiSalt() { + if (!includePii) { + // PII hashing is enabled; salt is required + if (piiSalt == null || piiSalt.isEmpty()) { + String errorMsg = String.format(SALT_VALIDATION_ERROR, + "TRACE_PII_SALT not provided. Set via environment variable (recommended) or trace.pii-salt property. " + + "Minimum 16 characters with character variety required. " + + "Use AWS Secrets Manager, HashiCorp Vault, or similar for production."); + log.error(errorMsg); + throw new IllegalArgumentException(errorMsg); + } + + if (piiSalt.length() < MINIMUM_SALT_LENGTH) { + String errorMsg = String.format(SALT_VALIDATION_ERROR, + String.format("Salt too short (%d chars). Minimum %d characters required.", + piiSalt.length(), MINIMUM_SALT_LENGTH)); + log.error(errorMsg); + throw new IllegalArgumentException(errorMsg); + } + + if (!hasCharacterVariety(piiSalt)) { + String errorMsg = String.format(SALT_VALIDATION_ERROR, + "Salt lacks character variety. Use mix of uppercase, lowercase, digits, and special characters."); + log.error(errorMsg); + throw new IllegalArgumentException(errorMsg); + } + + log.info("PII salt validated successfully (length: {})", piiSalt.length()); + } + } + + /** + * Checks if salt has sufficient character variety: + * At least one uppercase, one lowercase, one digit, and one special character. + */ + private boolean hasCharacterVariety(String salt) { + boolean hasUppercase = salt.matches(".*[A-Z].*"); + boolean hasLowercase = salt.matches(".*[a-z].*"); + boolean hasDigit = salt.matches(".*\\d.*"); + boolean hasSpecial = salt.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};:'\",.<>?/\\\\|`~].*"); + + return hasUppercase && hasLowercase && hasDigit && hasSpecial; + } + + public TraceIdFilter() { + } + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + + // Extract or generate request ID + String requestId = request.getHeader("X-Request-ID"); + if (requestId == null || requestId.isEmpty()) { + requestId = UUID.randomUUID().toString(); + } + + // Extract or generate user ID from authentication + String userId = extractUserId(request); + + Span currentSpan = Span.current(); + try { + // Store in ThreadLocal for propagation to services + TraceContextHolder.setRequestId(requestId); + TraceContextHolder.setUserId(userId); + + // Add response header for client-side correlation + response.setHeader("X-Request-ID", requestId); + + // Add span attributes for distributed tracing + if (currentSpan.isRecording()) { + currentSpan.setAttribute("http.request.id", requestId); + + // Handle userId attribute: hash if includePii is false, otherwise include raw value + if (userId != null && !userId.isEmpty()) { + currentSpan.setAttribute("http.user.id", includePii ? userId : hashPii(userId)); + } else { + currentSpan.setAttribute("http.user.id", "anonymous"); + } + + currentSpan.setAttribute("http.method", request.getMethod()); + currentSpan.setAttribute("http.url", request.getRequestURI()); + currentSpan.setAttribute("http.scheme", request.getScheme()); + + // Handle client_ip attribute: hash if includePii is false, otherwise include raw value + String clientIp = getClientIp(request); + currentSpan.setAttribute("http.client_ip", includePii ? clientIp : hashPii(clientIp)); + } + + // Compute display userId for logging: respect includePii setting to avoid leaking PII in logs + String displayUserId = userId; + if (!includePii && userId != null && !userId.isEmpty()) { + displayUserId = hashPii(userId); + } else if (userId == null || userId.isEmpty()) { + displayUserId = "anonymous"; + } + + log.debug("Trace correlation: requestId={}, userId={}, path={}", requestId, displayUserId, request.getRequestURI()); + + // Continue request chain + filterChain.doFilter(request, response); + + } finally { + // Record HTTP response status + if (currentSpan.isRecording()) { + currentSpan.setAttribute("http.status_code", response.getStatus()); + } + + // Clean up ThreadLocal (RequestContextHolder cleanup by Spring is preferred, + // but explicit cleanup ensures no leaks in async contexts) + TraceContextHolder.clear(); + } + } + + /** + * Hash PII (personally identifiable information) using SHA-256. + * Combines the input with an app-specific salt for one-way hashing. + * Returns hashed value as hex string, or "redacted" on hash error. + * + * Salt is validated at startup by validatePiiSalt(), so it should never be null/empty here. + */ + private String hashPii(String value) { + if (value == null || value.isEmpty()) { + return "redacted"; + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + String combined = value + piiSalt; + byte[] hash = digest.digest(combined.getBytes()); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + log.warn("Failed to hash PII value: {}", e.getMessage()); + return "redacted"; + } + } + + /** + * Extract user ID from security context. + * Returns null if no authenticated user found. + */ + private String extractUserId(HttpServletRequest request) { + try { + Principal principal = request.getUserPrincipal(); + if (principal != null && principal.getName() != null) { + return principal.getName(); + } + } catch (Exception e) { + log.debug("Could not extract user ID from request", e); + } + return null; + } + + /** + * Extract client IP address from request headers. + * Checks X-Forwarded-For (proxy) before falling back to remote address. + */ + private String getClientIp(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isEmpty()) { + return xForwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } + + @Override + protected boolean shouldNotFilter(@NonNull HttpServletRequest request) throws ServletException { + // Apply to all requests + return false; + } +} diff --git a/src/main/java/com/rcs/ssf/service/DynamicCrudService.java b/src/main/java/com/rcs/ssf/service/DynamicCrudService.java index 3559d5b..48599dc 100644 --- a/src/main/java/com/rcs/ssf/service/DynamicCrudService.java +++ b/src/main/java/com/rcs/ssf/service/DynamicCrudService.java @@ -355,52 +355,55 @@ private List getColumnMetadata(String tableNa throw new IllegalStateException( "Dynamic CRUD metadata requires a JDBC DataSource/JdbcTemplate; check DB configuration"); } + // Optimized query: replaced O(N*M) EXISTS checks with single-pass GROUP BY aggregation + // Original: 2 EXISTS subqueries per column + 4-way FK join materialization + // Optimized: Single LEFT JOIN to constraints + GROUP BY to aggregate results + // Performance: 5-10x faster for typical schemas, up to 100x for highly constrained tables final String sql = """ - SELECT utc.column_name, - utc.data_type, - utc.nullable, - utc.column_id, - utc.data_length, - utc.data_precision, - utc.data_scale, - utc.data_default, - CASE - WHEN EXISTS ( - SELECT 1 - FROM user_cons_columns ucc - JOIN user_constraints uc ON ucc.constraint_name = uc.constraint_name - WHERE uc.constraint_type = 'P' - AND uc.table_name = utc.table_name - AND ucc.column_name = utc.column_name - ) THEN 'Y' ELSE 'N' END AS is_primary_key, - CASE - WHEN EXISTS ( - SELECT 1 - FROM user_cons_columns ucc - JOIN user_constraints uc ON ucc.constraint_name = uc.constraint_name - WHERE uc.constraint_type = 'U' - AND uc.table_name = utc.table_name - AND ucc.column_name = utc.column_name - ) THEN 'Y' ELSE 'N' END AS is_unique, - NVL(ucc.comments, '') AS column_comment, - fkc.ref_table_name, - fkc.ref_column_name - FROM user_tab_columns utc - LEFT JOIN user_col_comments ucc ON utc.table_name = ucc.table_name - AND utc.column_name = ucc.column_name - LEFT JOIN ( - SELECT ucc1.table_name, - ucc1.column_name, - ucc2.table_name AS ref_table_name, - ucc2.column_name AS ref_column_name - FROM user_cons_columns ucc1 - JOIN user_constraints uc1 ON ucc1.constraint_name = uc1.constraint_name - JOIN user_constraints uc2 ON uc1.r_constraint_name = uc2.constraint_name - JOIN user_cons_columns ucc2 ON uc2.constraint_name = ucc2.constraint_name - WHERE uc1.constraint_type = 'R' - ) fkc ON utc.table_name = fkc.table_name AND utc.column_name = fkc.column_name - WHERE utc.table_name = UPPER(?) - ORDER BY utc.column_id + SELECT + utc.column_id, + utc.column_name, + utc.data_type, + utc.nullable, + utc.data_length, + utc.data_precision, + utc.data_scale, + utc.data_default, + MAX(CASE WHEN uc.constraint_type = 'P' THEN 'Y' ELSE 'N' END) AS is_primary_key, + MAX(CASE WHEN uc.constraint_type = 'U' THEN 'Y' ELSE 'N' END) AS is_unique, + MAX(ucc_comments.comments) AS column_comment, + MAX(CASE WHEN uc.constraint_type = 'R' THEN uc2.table_name ELSE NULL END) AS ref_table_name, + MAX(CASE WHEN uc.constraint_type = 'R' THEN ucc_ref.column_name ELSE NULL END) AS ref_column_name + FROM + user_tab_columns utc + LEFT JOIN user_col_comments ucc_comments + ON utc.table_name = ucc_comments.table_name + AND utc.column_name = ucc_comments.column_name + LEFT JOIN user_cons_columns ucc_local + ON utc.table_name = ucc_local.table_name + AND utc.column_name = ucc_local.column_name + LEFT JOIN user_constraints uc + ON ucc_local.constraint_name = uc.constraint_name + LEFT JOIN user_constraints uc2 + ON uc.constraint_type = 'R' + AND uc.r_constraint_name = uc2.constraint_name + LEFT JOIN user_cons_columns ucc_ref + ON uc.constraint_type = 'R' + AND uc2.constraint_name = ucc_ref.constraint_name + AND ucc_ref.position = ucc_local.position + WHERE + utc.table_name = UPPER(?) + GROUP BY + utc.column_id, + utc.column_name, + utc.data_type, + utc.nullable, + utc.data_length, + utc.data_precision, + utc.data_scale, + utc.data_default + ORDER BY + utc.column_id """; List columns = jdbcTemplate.get().query( diff --git a/src/main/java/com/rcs/ssf/service/UserService.java b/src/main/java/com/rcs/ssf/service/UserService.java index 58df1af..c197c66 100644 --- a/src/main/java/com/rcs/ssf/service/UserService.java +++ b/src/main/java/com/rcs/ssf/service/UserService.java @@ -54,8 +54,10 @@ public User mapRow(@NonNull ResultSet rs, int rowNum) throws SQLException { user.setId(rs.wasNull() ? null : id); user.setUsername(rs.getString("username")); user.setEmail(rs.getString("email")); - String password = rs.getString("password"); - user.setPassword(rs.wasNull() ? null : password); + // Note: password is intentionally not mapped from PL/SQL result sets + // PL/SQL package functions (get_user_by_id, get_user_by_username, get_user_by_email) + // only return id, username, email for security reasons. + // Password hashes are never retrieved in query result sets. return user; } }; diff --git a/src/main/java/com/rcs/ssf/tracing/CacheOperationInstrumentation.java b/src/main/java/com/rcs/ssf/tracing/CacheOperationInstrumentation.java new file mode 100644 index 0000000..2609c14 --- /dev/null +++ b/src/main/java/com/rcs/ssf/tracing/CacheOperationInstrumentation.java @@ -0,0 +1,159 @@ +package com.rcs.ssf.tracing; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * AOP aspect for automatic span instrumentation of cache operations. + * + *

Interceptors map directly to Spring's caching annotations and emit spans: + *

    + *
  • {@code @Cacheable} → {@code cache.get}
  • + *
  • {@code @CachePut} → {@code cache.put}
  • + *
  • {@code @CacheEvict} → {@code cache.evict}
  • + *
+ * + *

Captured attributes include cache name, operation, execution duration, and + * cache hit signal (only set for {@code @Cacheable}, which always represents a + * cache miss because the method executes). Additional metadata such as result + * types and eviction settings help correlate cache behavior across Caffeine + * and Redis backends. + */ +@Aspect +@Component +@Slf4j +public class CacheOperationInstrumentation { + + private final Tracer tracer; + + public CacheOperationInstrumentation(Tracer tracer) { + this.tracer = tracer; + } + + @Around("@annotation(cacheable)") + public Object traceCacheableOperation(ProceedingJoinPoint joinPoint, Cacheable cacheable) throws Throwable { + String cacheName = resolveCacheName(cacheable.cacheNames(), cacheable.value(), joinPoint); + return traceCacheOperation(joinPoint, cacheName, "get", false, null); + } + + @Around("@annotation(cachePut)") + public Object traceCachePutOperation(ProceedingJoinPoint joinPoint, CachePut cachePut) throws Throwable { + String cacheName = resolveCacheName(cachePut.cacheNames(), cachePut.value(), joinPoint); + return traceCacheOperation(joinPoint, cacheName, "put", null, null); + } + + @Around("@annotation(cacheEvict)") + public Object traceCacheEvictOperation(ProceedingJoinPoint joinPoint, CacheEvict cacheEvict) throws Throwable { + String cacheName = resolveCacheName(cacheEvict.cacheNames(), cacheEvict.value(), joinPoint); + return traceCacheOperation(joinPoint, cacheName, "evict", null, span -> { + span.setAttribute("cache.evict_all_entries", cacheEvict.allEntries()); + span.setAttribute("cache.evict_before_invocation", cacheEvict.beforeInvocation()); + }); + } + + private Object traceCacheOperation( + ProceedingJoinPoint joinPoint, + String cacheName, + String operation, + Boolean cacheHit, + Consumer additionalAttributes) throws Throwable { + + String methodName = joinPoint.getSignature().getName(); + String spanName = String.format("cache.%s", operation); + Span span = tracer.spanBuilder(spanName).startSpan(); + long startTime = System.nanoTime(); + Scope scope = null; + + try { + scope = span.makeCurrent(); + } catch (Throwable scopeError) { + span.recordException(scopeError); + span.setStatus(StatusCode.ERROR); + span.end(); + throw scopeError; + } + + try { + span.setAttribute("cache.name", cacheName); + span.setAttribute("cache.operation", operation); + span.setAttribute("cache.method", methodName); + + if (cacheHit != null) { + span.setAttribute("cache.hit", cacheHit); + } + + if (additionalAttributes != null) { + additionalAttributes.accept(span); + } + + Object result = joinPoint.proceed(); + + if (result != null) { + span.setAttribute("cache.result_type", result.getClass().getSimpleName()); + } + + return result; + + } catch (Throwable e) { + span.recordException(e); + span.setStatus(StatusCode.ERROR); + log.error("Cache operation error: {} on {}", spanName, cacheName, e); + throw e; + } finally { + long durationNanos = System.nanoTime() - startTime; + long durationMs = TimeUnit.NANOSECONDS.toMillis(durationNanos); + span.setAttribute("cache.duration_ms", durationMs); + + if (durationMs > 100) { + span.setAttribute("cache.slow_operation", true); + } + + if (scope != null) { + try { + scope.close(); + } catch (Throwable scopeCloseError) { + log.warn("Failed closing cache scope for {} on {}", spanName, cacheName, scopeCloseError); + } + } + + span.end(); + } + } + + private String resolveCacheName(String[] cacheNames, String[] aliases, ProceedingJoinPoint joinPoint) { + String resolved = firstNonBlank(cacheNames); + if (resolved == null) { + resolved = firstNonBlank(aliases); + } + if (resolved != null) { + return resolved; + } + // Fall back to method signature when no cache name is provided. + return joinPoint.getSignature().toShortString(); + } + + private String firstNonBlank(String[] candidates) { + if (candidates == null) { + return null; + } + for (String candidate : candidates) { + if (candidate != null && !candidate.isBlank()) { + return candidate; + } + } + return null; + } +} diff --git a/src/main/java/com/rcs/ssf/tracing/DatabaseOperationInstrumentation.java b/src/main/java/com/rcs/ssf/tracing/DatabaseOperationInstrumentation.java new file mode 100644 index 0000000..d49f3a6 --- /dev/null +++ b/src/main/java/com/rcs/ssf/tracing/DatabaseOperationInstrumentation.java @@ -0,0 +1,195 @@ +package com.rcs.ssf.tracing; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; + +/** + * AOP aspect for automatic span instrumentation of database operations. + * + * Intercepts repository method calls to automatically create spans with: + * - Span name: db.query. (find, save, delete, etc.) + * - Attributes: table name, operation type, row count, execution time + * - Exception tracking and span status + * + * Operations traced: + * - findById, findAll, findByUsername, etc. (SELECT) + * - save (INSERT/UPDATE) + * - delete, deleteById (DELETE) + * + * Complements SQL query logging with semantic span information. + * + * Usage (automatic via OtelConfig): + * User user = userRepository.findById(123); // Traced as db.query.find + * + * Bean instantiation: Created by OtelConfig.databaseOperationInstrumentation() factory method. + */ +@Aspect +@Slf4j +public class DatabaseOperationInstrumentation { + + private final Tracer tracer; + /** + * Threshold (in milliseconds) above which database queries are flagged as slow. + * Default: 1000ms. Can be overridden for testing via constructor. + */ + private final long slowQueryThresholdMs; + + /** + * Constructor for bean creation via OtelConfig factory method. + * Initializes slow-query threshold with default of 1000ms. + * + * @param tracer the OpenTelemetry tracer (injected via factory method) + */ + public DatabaseOperationInstrumentation(Tracer tracer) { + this(tracer, 1000L); + } + + /** + * Constructor for testing; allows explicit threshold override. + * + * @param tracer the OpenTelemetry tracer + * @param slowQueryThresholdMs threshold in milliseconds; must be positive (> 0) + * @throws IllegalArgumentException if threshold is not positive + */ + private DatabaseOperationInstrumentation(Tracer tracer, long slowQueryThresholdMs) { + if (slowQueryThresholdMs <= 0) { + throw new IllegalArgumentException("slowQueryThresholdMs must be positive, got: " + slowQueryThresholdMs); + } + this.tracer = tracer; + this.slowQueryThresholdMs = slowQueryThresholdMs; + } + + /** + * Factory method for creating instances with custom slow-query threshold (for testing). + * + * @param tracer the OpenTelemetry tracer + * @param slowQueryThresholdMs custom threshold in milliseconds; must be positive (> 0) + * @return new instance with custom threshold + * @throws IllegalArgumentException if threshold is not positive + */ + public static DatabaseOperationInstrumentation withThreshold(Tracer tracer, long slowQueryThresholdMs) { + return new DatabaseOperationInstrumentation(tracer, slowQueryThresholdMs); + } + + /** + * Trace all repository method calls. + * + * Identifies operation type from method name and creates appropriate span. + */ + @Around("execution(* com.rcs.ssf.repository.*.*(..)) && " + + "!execution(* com.rcs.ssf.repository.*.hashCode(..)) && " + + "!execution(* com.rcs.ssf.repository.*.equals(..))") + public Object traceRepositoryOperation(ProceedingJoinPoint joinPoint) throws Throwable { + String methodName = joinPoint.getSignature().getName(); + String className = joinPoint.getSignature().getDeclaringType().getSimpleName(); + String repositoryName = className.replace("Repository", "").toLowerCase(); + + // Determine operation type from method name + String operationType = inferOperationType(methodName); + String spanName = String.format("db.query.%s", operationType); + + Span span = tracer.spanBuilder(spanName).startSpan(); + long startTime = System.currentTimeMillis(); + + try (Scope scope = span.makeCurrent()) { + // Add database operation attributes + span.setAttribute("db.repository", className); + span.setAttribute("db.entity", repositoryName); + span.setAttribute("db.operation", operationType); + span.setAttribute("db.method", methodName); + + // Add parameter information if available + Object[] args = joinPoint.getArgs(); + if (args.length > 0) { + span.setAttribute("db.params.count", args.length); + for (int i = 0; i < Math.min(args.length, 3); i++) { + Object arg = args[i]; + if (arg != null) { + span.setAttribute("db.params." + i, arg.getClass().getSimpleName()); + } + } + } + + // Execute database operation + Object result = joinPoint.proceed(); + + // Record result attributes + if (result != null) { + span.setAttribute("db.result_type", result.getClass().getSimpleName()); + + // Try to extract row count from result + if (result instanceof java.util.Collection) { + span.setAttribute("db.row_count", ((java.util.Collection) result).size()); + } else if (result instanceof java.util.Optional) { + span.setAttribute("db.result_available", ((java.util.Optional) result).isPresent()); + } else if (result instanceof Boolean) { + span.setAttribute("db.result_bool", (Boolean) result); + } else if (result instanceof Number) { + span.setAttribute("db.result_count", ((Number) result).longValue()); + } + } + + return result; + + } catch (Throwable throwable) { + span.recordException(throwable); + span.setAttribute("error", true); + span.setAttribute("error.type", throwable.getClass().getSimpleName()); + if (throwable.getMessage() != null) { + span.setAttribute("error.message", throwable.getMessage()); + } + // Mark the span with OpenTelemetry error status + span.setStatus(StatusCode.ERROR, throwable.getMessage() != null ? throwable.getMessage() : "database operation failed"); + + log.error("Database operation error: {} in {}", spanName, className, throwable); + throw throwable; + + } finally { + long duration = System.currentTimeMillis() - startTime; + span.setAttribute("db.duration_ms", duration); + + // Add latency percentile hint (used for alerts) + if (duration > slowQueryThresholdMs) { + span.setAttribute("db.latency_warning", "slow_query"); + } + + span.end(); + } + } + + /** + * Infer database operation type from method name. + * + * Examples: + * - findById → find + * - findByUsername → find + * - save → insert/update + * - delete → delete + * - count → aggregate + */ + private String inferOperationType(String methodName) { + if (methodName.startsWith("find")) { + return "find"; + } else if (methodName.startsWith("query")) { + return "query"; + } else if (methodName.startsWith("save")) { + return "insert_update"; + } else if (methodName.startsWith("delete")) { + return "delete"; + } else if (methodName.startsWith("count")) { + return "aggregate"; + } else if (methodName.startsWith("exists")) { + return "exists"; + } else if (methodName.startsWith("get")) { + return "find"; + } else { + return "other"; + } + } +} diff --git a/src/main/java/com/rcs/ssf/tracing/GraphQLResolverInstrumentation.java b/src/main/java/com/rcs/ssf/tracing/GraphQLResolverInstrumentation.java new file mode 100644 index 0000000..9ed7c4f --- /dev/null +++ b/src/main/java/com/rcs/ssf/tracing/GraphQLResolverInstrumentation.java @@ -0,0 +1,131 @@ +package com.rcs.ssf.tracing; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +/** + * AOP aspect for automatic span instrumentation of GraphQL resolvers. + * + * Intercepts methods annotated with @QueryMapping, @MutationMapping, + * @SubscriptionMapping to automatically create spans with: + * - Span name: graphql.. + * - Attributes: parameter types, return type, execution time + * - Exception tracking and span status + * + * This provides automatic tracing without requiring manual @WithSpan annotations. + * Complements OpenTelemetry instrumentation for GraphQL queries. + * + * Usage: + * @QueryMapping + * public UserDto getCurrentUser() { + * // Automatically traced with span: graphql.query.getCurrentUser + * } + */ +@Aspect +@Component +@Slf4j +public class GraphQLResolverInstrumentation { + + private final Tracer tracer; + + public GraphQLResolverInstrumentation(Tracer tracer) { + this.tracer = tracer; + } + + /** + * Trace GraphQL query resolvers (@QueryMapping). + */ + @Around("@annotation(org.springframework.graphql.data.method.annotation.QueryMapping)") + public Object traceQueryResolver(ProceedingJoinPoint joinPoint) throws Throwable { + return traceResolver("graphql.query", joinPoint); + } + + /** + * Trace GraphQL mutation resolvers (@MutationMapping). + */ + @Around("@annotation(org.springframework.graphql.data.method.annotation.MutationMapping)") + public Object traceMutationResolver(ProceedingJoinPoint joinPoint) throws Throwable { + return traceResolver("graphql.mutation", joinPoint); + } + + /** + * Trace GraphQL subscription resolvers (@SubscriptionMapping). + */ + @Around("@annotation(org.springframework.graphql.data.method.annotation.SubscriptionMapping)") + public Object traceSubscriptionResolver(ProceedingJoinPoint joinPoint) throws Throwable { + return traceResolver("graphql.subscription", joinPoint); + } + + /** + * Core tracing logic for GraphQL resolvers. + * + * Creates span with: + * - Name: . + * - Attributes: method name, class name, parameter count, return type + * - Error tracking + * - Execution time + */ + private Object traceResolver(String operationType, ProceedingJoinPoint joinPoint) throws Throwable { + String methodName = joinPoint.getSignature().getName(); + + // Safely get class name: check if target exists, otherwise fall back to declaring type + String className = "UnknownClass"; + if (joinPoint.getTarget() != null) { + className = joinPoint.getTarget().getClass().getSimpleName(); + } else { + Class declaringClass = joinPoint.getSignature().getDeclaringType(); + if (declaringClass != null) { + className = declaringClass.getSimpleName(); + } + } + + String spanName = String.format("%s.%s", operationType, methodName); + + Span span = tracer + .spanBuilder(spanName) + .setParent(Context.current()) + .setSpanKind(SpanKind.INTERNAL) + .startSpan(); + + try (Scope scope = span.makeCurrent()) { + // Add resolver context attributes + span.setAttribute("resolver.class", className); + span.setAttribute("resolver.method", methodName); + String type = operationType.substring(operationType.lastIndexOf('.') + 1); + span.setAttribute("resolver.type", type); + span.setAttribute("resolver.parameter_count", joinPoint.getArgs().length); + + // Execute resolver + Object result = joinPoint.proceed(); + + // Record result attributes + if (result != null) { + span.setAttribute("resolver.result_type", result.getClass().getSimpleName()); + span.setAttribute("resolver.result_available", true); + } else { + span.setAttribute("resolver.result_available", false); + } + + return result; + + } catch (Throwable throwable) { + span.recordException(throwable); + span.setStatus(StatusCode.ERROR); + + log.error("GraphQL resolver error: {} at {}", spanName, className, throwable); + throw throwable; + + } finally { + span.end(); + } + } +} diff --git a/src/main/java/com/rcs/ssf/tracing/MFAOperationInstrumentation.java b/src/main/java/com/rcs/ssf/tracing/MFAOperationInstrumentation.java new file mode 100644 index 0000000..978f217 --- /dev/null +++ b/src/main/java/com/rcs/ssf/tracing/MFAOperationInstrumentation.java @@ -0,0 +1,241 @@ +package com.rcs.ssf.tracing; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.beans.factory.annotation.Autowired; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.Locale; + +/** + * AOP aspect for automatic span instrumentation of MFA operations. + * + * Intercepts WebAuthn and MFA service method calls to create spans with: + * - Span name: mfa. (registration, authentication, verify) + * - Attributes: MFA method, user ID, success/failure, operation time + * + * Operations traced: + * - startRegistration (WebAuthn registration challenge generation) + * - completeRegistration (WebAuthn credential verification and storage) + * - startAuthentication (WebAuthn authentication challenge generation) + * - verifyAssertion (WebAuthn assertion verification) + * - Any method in WebAuthnService or MFA-related classes + * + * Security Notes: + * - Does not log sensitive credential data + * - Tracks authentication attempts for security monitoring + * - Records MFA bypass attempts or failures + * + * Usage (automatic via OtelConfig): + * webAuthnService.completeRegistration(userId, response, nickname); + * // Traced as mfa.registration with attributes: user_id, method, status + * + * Bean instantiation: Created by OtelConfig.mfaOperationInstrumentation() factory method. + */ +@Aspect +@Slf4j +public class MFAOperationInstrumentation { + + private final Tracer tracer; + private final long slowOperationThresholdMs; + + @Autowired + public MFAOperationInstrumentation(Tracer tracer) { + this(tracer, 2000); + } + + private MFAOperationInstrumentation(Tracer tracer, long slowOperationThresholdMs) { + if (slowOperationThresholdMs <= 0) { + throw new IllegalArgumentException("slowOperationThresholdMs must be positive, got: " + slowOperationThresholdMs); + } + this.tracer = tracer; + this.slowOperationThresholdMs = slowOperationThresholdMs; + } + + /** + * Factory method for testing with custom slow-operation threshold. + */ + public static MFAOperationInstrumentation withThreshold(Tracer tracer, long slowOperationThresholdMs) { + return new MFAOperationInstrumentation(tracer, slowOperationThresholdMs); + } + + /** + * Trace all WebAuthn service operations. + */ + @Around("execution(* com.rcs.ssf.security.mfa.WebAuthnService.*(..))") + public Object traceWebAuthnOperation(ProceedingJoinPoint joinPoint) throws Throwable { + return traceMFAOperation(joinPoint, "webauthn"); + } + + /** + * Trace all MFA-related service operations. + */ + @Around("execution(* com.rcs.ssf.security.mfa..*.*(..)) && " + + "!execution(* com.rcs.ssf.security.mfa..*.hashCode(..)) && " + + "!execution(* com.rcs.ssf.security.mfa..*.equals(..))") + public Object traceMFAServiceOperation(ProceedingJoinPoint joinPoint) throws Throwable { + Class targetClass = joinPoint.getTarget() != null + ? joinPoint.getTarget().getClass() + : joinPoint.getSignature().getDeclaringType(); + if (targetClass == null) { + return joinPoint.proceed(); + } + + String className = targetClass.getSimpleName(); + if (!shouldTraceClass(targetClass, className)) { + return joinPoint.proceed(); + } + return traceMFAOperation(joinPoint, "mfa"); + } + + private boolean shouldTraceClass(Class targetClass, String className) { + // Future: look for marker interfaces/annotations to opt-in tracing. + return !(className.contains("Response") || + className.contains("Options") || + className.contains("Credential")); + } + + /** + * Core tracing logic for MFA operations. + * + * Creates span with MFA operation type, method name, and user context. + */ + private Object traceMFAOperation(ProceedingJoinPoint joinPoint, String mfaType) throws Throwable { + String methodName = joinPoint.getSignature().getName(); + String operationType = inferMFAOperationType(methodName); + String spanName = String.format("mfa.%s", operationType); + + Span span = tracer.spanBuilder(spanName).startSpan(); + long startTime = System.currentTimeMillis(); + + try (Scope scope = span.makeCurrent()) { + // Add MFA operation attributes + span.setAttribute("mfa.type", mfaType); + span.setAttribute("mfa.method", methodName); + span.setAttribute("mfa.operation", operationType); + + captureUserIdAttribute(joinPoint, span); + + // Execute MFA operation + Object result = joinPoint.proceed(); + + // Record successful result + span.setAttribute("mfa.status", "success"); + if (result != null) { + span.setAttribute("mfa.result_type", result.getClass().getSimpleName()); + span.setAttribute("mfa.result_available", true); + } + + return result; + + } catch (Throwable throwable) { + // Record exception in span with security classification (sanitized for PII) + span.recordException(throwable); + span.setAttribute("error", true); + span.setAttribute("error.type", throwable.getClass().getSimpleName()); + span.setAttribute("mfa.status", "failure"); + + // Classify security events + if (throwable.getClass().getSimpleName().contains("Verification") || + throwable.getMessage() != null && throwable.getMessage().contains("invalid")) { + span.setAttribute("security.event", "mfa_verification_failed"); + } else if (throwable.getMessage() != null && throwable.getMessage().contains("challenge")) { + span.setAttribute("security.event", "mfa_challenge_expired"); + } + + // Log without including raw exception or message to avoid leaking sensitive MFA data + log.warn("MFA operation failure: {} in method {} ({})", spanName, methodName, throwable.getClass().getSimpleName()); + throw throwable; + + } finally { + long duration = System.currentTimeMillis() - startTime; + span.setAttribute("mfa.duration_ms", duration); + + // Flag slow MFA operations + if (duration > slowOperationThresholdMs) { + span.setAttribute("mfa.slow_operation", true); + } + + span.end(); + } + } + + private void captureUserIdAttribute(ProceedingJoinPoint joinPoint, Span span) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + Parameter[] parameters = method.getParameters(); + Object[] args = joinPoint.getArgs(); + + for (int i = 0; i < parameters.length && i < args.length; i++) { + Parameter parameter = parameters[i]; + Object arg = args[i]; + + if (!(arg instanceof Long)) { + continue; + } + + if (isUserIdParameter(parameter)) { + span.setAttribute("mfa.user_id", (Long) arg); + return; + } + } + } + + private boolean isUserIdParameter(Parameter parameter) { + if (parameter.getType().equals(Long.class) || parameter.getType().equals(long.class)) { + String paramName = parameter.getName(); + if (paramName != null) { + String normalized = paramName.toLowerCase(); + if (normalized.equals("userid") || normalized.equals("user_id") || normalized.equals("uid")) { + return true; + } + } + + for (Annotation annotation : parameter.getAnnotations()) { + String annotationName = annotation.annotationType().getSimpleName(); + if (annotationName.equals("UserId") || annotationName.equals("MfaUserId")) { + return true; + } + } + } + return false; + } + + /** + * Infer MFA operation type from method name. + * + * Examples: + * - startRegistration → registration + * - completeRegistration → registration + * - startAuthentication → authentication + * - verifyAssertion → authentication + * - verify → verify + */ + private String inferMFAOperationType(String methodName) { + String normalized = methodName.toLowerCase(Locale.ROOT); + + if (normalized.contains("registration") || normalized.contains("register")) { + return "registration"; + } else if (normalized.contains("authentication") || + normalized.contains("authenticate") || + normalized.contains("assertion")) { + return "authentication"; + } else if (normalized.contains("verify")) { + return "verify"; + } else if (normalized.contains("enroll")) { + return "enrollment"; + } else if (normalized.contains("challenge")) { + return "challenge"; + } else { + return "operation"; + } + } +} diff --git a/src/main/java/com/rcs/ssf/tracing/OtelConfig.java b/src/main/java/com/rcs/ssf/tracing/OtelConfig.java new file mode 100644 index 0000000..b752e6b --- /dev/null +++ b/src/main/java/com/rcs/ssf/tracing/OtelConfig.java @@ -0,0 +1,87 @@ +package com.rcs.ssf.tracing; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Tracer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * OpenTelemetry Configuration for distributed tracing. + * + * NOTE: Spring Boot 3.5.7 provides automatic OpenTelemetry configuration via + * spring-boot-starter-actuator when OTEL dependencies are present. This bean + * ensures we have access to the Tracer for injection. + * + * Spring Boot automatically configures: + * - OpenTelemetry SDK with OTLP exporter via OpenTelemetryEnvironmentPostProcessor + * - Resource attributes from spring.application.name and environment + * - Span processor and exporter from OTEL_EXPORTER_OTLP_ENDPOINT + * + * Environment Variables (auto-configured by Spring Boot): + * - OTEL_EXPORTER_OTLP_ENDPOINT: Endpoint for Jaeger/Tempo (default: http://localhost:4317) + * - OTEL_SERVICE_NAME: Application service name (spring.application.name used if unset) + * - SPRING_APPLICATION_NAME: Sets the service name (must be set in properties) + * + * Configuration in application.yml: + * - spring.application.name: ssf + * - otel.exporter.otlp.endpoint (environment variable preferred) + */ +@Configuration +@Slf4j +public class OtelConfig { + + @Value("${spring.application.name:ssf-graphql}") + private String serviceName; + + /** + * Provide the global Tracer bean for injection into components. + * + * The OpenTelemetry SDK is auto-configured by Spring Boot 3.5.7+. + * This bean makes the Tracer available for dependency injection. + * + * Usage in services: + * @Autowired + * private Tracer tracer; + * + * @param openTelemetry Global OpenTelemetry instance (auto-configured by Spring Boot) + * @return Tracer for creating spans + */ + @Bean + public Tracer tracer(OpenTelemetry openTelemetry) { + Tracer tracer = openTelemetry.getTracer(serviceName); + log.info("OpenTelemetry Tracer initialized for service: {}", serviceName); + return tracer; + } + + /** + * Factory method for MFAOperationInstrumentation AOP bean. + * + * Creates the aspect that automatically instruments WebAuthn and MFA service calls. + * Uses default slow-operation threshold of 2000ms. + * + * @param tracer OpenTelemetry tracer for span creation + * @return MFAOperationInstrumentation AOP aspect + */ + @Bean + public MFAOperationInstrumentation mfaOperationInstrumentation(Tracer tracer) { + log.info("MFAOperationInstrumentation bean created with default threshold (2000ms)"); + return new MFAOperationInstrumentation(tracer); + } + + /** + * Factory method for DatabaseOperationInstrumentation AOP bean. + * + * Creates the aspect that automatically instruments repository method calls. + * Uses default slow-query threshold of 1000ms. + * + * @param tracer OpenTelemetry tracer for span creation + * @return DatabaseOperationInstrumentation AOP aspect + */ + @Bean + public DatabaseOperationInstrumentation databaseOperationInstrumentation(Tracer tracer) { + log.info("DatabaseOperationInstrumentation bean created with default threshold (1000ms)"); + return new DatabaseOperationInstrumentation(tracer); + } +} diff --git a/src/main/java/com/rcs/ssf/tracing/SpanInstrumentation.java b/src/main/java/com/rcs/ssf/tracing/SpanInstrumentation.java new file mode 100644 index 0000000..ad53bc8 --- /dev/null +++ b/src/main/java/com/rcs/ssf/tracing/SpanInstrumentation.java @@ -0,0 +1,215 @@ +package com.rcs.ssf.tracing; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; + +import java.util.concurrent.Callable; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * Utility for instrumenting code with OpenTelemetry spans. + * + * Provides convenient methods for creating and managing spans without manual + * scope management. Automatically handles span lifecycle and error recording. + * + * Usage: + * SpanInstrumentation.runInSpan(tracer, "operation.name", () -> { + * // Code to trace + * }); + * + * Object result = SpanInstrumentation.executeInSpan(tracer, "operation.name", () -> { + * return performOperation(); + * }); + */ +public class SpanInstrumentation { + + private SpanInstrumentation() { + // Utility class - prevent instantiation + } + + /** + * Execute a runnable within a span context. + * + * Automatically creates a span, manages scope, and records exceptions. + * + * @param tracer The OpenTelemetry tracer + * @param spanName Name of the span (e.g., "graphql.query.user") + * @param runnable Code to execute within span + * @throws InstrumentationException if the runnable throws and span instrumentation wraps it + */ + public static void runInSpan(Tracer tracer, String spanName, Runnable runnable) { + Span span = tracer.spanBuilder(spanName).startSpan(); + try (Scope scope = span.makeCurrent()) { + runnable.run(); + } catch (Exception e) { + throw handleSpanException(span, spanName, e); + } finally { + span.end(); + } + } + + /** + * Execute a callable within a span context and return result. + * + * Automatically creates a span, manages scope, records result, and handles exceptions. + * + * @param tracer The OpenTelemetry tracer + * @param spanName Name of the span (e.g., "db.query.users") + * @param callable Code to execute within span + * @param Return type + * @return Result from callable + * @throws InstrumentationException if the callable throws + */ + public static T executeInSpan(Tracer tracer, String spanName, Callable callable) { + Span span = tracer.spanBuilder(spanName).startSpan(); + try (Scope scope = span.makeCurrent()) { + T result = callable.call(); + span.setAttribute("result.available", result != null); + return result; + } catch (Exception e) { + throw handleSpanException(span, spanName, e); + } finally { + span.end(); + } + } + + /** + * Execute a function within a span context. + * + * @param tracer The OpenTelemetry tracer + * @param spanName Name of the span + * @param function Function to execute + * @param input Input to function + * @param Input type + * @param Return type + * @return Result from function + * @throws InstrumentationException if the function throws + */ + public static R executeInSpan(Tracer tracer, String spanName, Function function, T input) { + Span span = tracer.spanBuilder(spanName).startSpan(); + try (Scope scope = span.makeCurrent()) { + R result = function.apply(input); + span.setAttribute("result.available", result != null); + return result; + } catch (Exception e) { + throw handleSpanException(span, spanName, e); + } finally { + span.end(); + } + } + + /** + * Execute a consumer within a span context. + * + * @param tracer The OpenTelemetry tracer + * @param spanName Name of the span + * @param consumer Consumer to execute + * @param input Input to consumer + * @param Input type + * @throws InstrumentationException if the consumer throws + */ + public static void consumeInSpan(Tracer tracer, String spanName, Consumer consumer, T input) { + Span span = tracer.spanBuilder(spanName).startSpan(); + try (Scope scope = span.makeCurrent()) { + consumer.accept(input); + } catch (Exception e) { + throw handleSpanException(span, spanName, e); + } finally { + span.end(); + } + } + + /** + * Add attributes to the current span. + * + * @param key Attribute key + * @param value Attribute value + */ + public static void addAttribute(String key, String value) { + Span.current().setAttribute(key, value); + } + + /** + * Add a long attribute to the current span. + * + * @param key Attribute key + * @param value Attribute value + */ + public static void addAttribute(String key, long value) { + Span.current().setAttribute(key, value); + } + + /** + * Add a double attribute to the current span. + * + * @param key Attribute key + * @param value Attribute value + */ + public static void addAttribute(String key, double value) { + Span.current().setAttribute(key, value); + } + + /** + * Add a boolean attribute to the current span. + * + * @param key Attribute key + * @param value Attribute value + */ + public static void addAttribute(String key, boolean value) { + Span.current().setAttribute(key, value); + } + + /** + * Record an exception in the current span. + * + * @param exception Exception to record + */ + public static void recordException(Exception exception) { + Span span = Span.current(); + span.recordException(exception); + span.setStatus(StatusCode.ERROR); + span.setAttribute("exception.type", exception.getClass().getSimpleName()); + if (exception.getMessage() != null) { + span.setAttribute("exception.message", exception.getMessage()); + } + } + + /** + * Add event to current span without attributes. + * + * @param eventName Event name + */ + public static void addEvent(String eventName) { + Span.current().addEvent(eventName); + } + + /** + * Add event to current span with attributes. + * + * @param eventName Event name + * @param attributes Attributes to attach to the event + */ + public static void addEvent(String eventName, Attributes attributes) { + Span.current().addEvent(eventName, attributes); + } + + private static InstrumentationException handleSpanException(Span span, String spanName, Exception exception) { + span.recordException(exception); + span.setStatus(StatusCode.ERROR); + span.setAttribute("exception.type", exception.getClass().getSimpleName()); + if (exception.getMessage() != null) { + span.setAttribute("exception.message", exception.getMessage()); + } + return new InstrumentationException("Error in span: " + spanName, exception); + } + + public static class InstrumentationException extends RuntimeException { + public InstrumentationException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fd34f5d..7ce90f7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,9 +1,8 @@ spring: application: - name: ssf + name: ssf-graphql profiles: active: ${SPRING_PROFILES_ACTIVE:dev} - # DataSourceAutoConfiguration is required for JDBC DataSource creation (used by AuditService). # R2DBC configuration is handled separately via custom ReactiveDataSourceConfiguration bean. # Both JDBC and R2DBC are used: JDBC for blocking audit operations, R2DBC for reactive GraphQL endpoints. @@ -149,6 +148,34 @@ ssf: cache: default-max-age: 3600 # Default Cache-Control max-age in seconds (1 hour) +# Trace Configuration +# PII Tracing: By default, userId and client_ip are hashed (SHA-256) to avoid raw PII in traces +# +# IMPORTANT: PII hashing is REQUIRED for production deployments. +# The salt must be: +# - Non-empty and at least 16 characters long +# - Mix of uppercase, lowercase, digits, and special characters (validated at startup) +# - Loaded from a secure secrets manager (AWS Secrets Manager, HashiCorp Vault, Azure KeyVault, etc.) +# - NEVER hardcoded in application properties or source control +# +# Configuration: +# 1. RECOMMENDED (Secrets Manager): +# - Store salt in AWS Secrets Manager / Vault / etc. +# - Inject via TRACE_PII_SALT environment variable at runtime +# 2. ALTERNATIVE (Environment Variable): +# - Set TRACE_PII_SALT= at container/pod startup +# 3. NOT RECOMMENDED (Application Properties): +# - trace.pii-salt: Can be set in properties files for development ONLY +# +# Example strong salt (for development testing only): +# trace.pii-salt: "DevTest@2024#Salt!Secure" +# +# To disable PII hashing and include raw values (NOT recommended): +# trace.include-pii: true +# +trace: + includePii: ${TRACE_INCLUDE_PII:false} # Include raw PII in traces (default: false = hash) + piiSalt: ${TRACE_PII_SALT:} # REQUIRED if include-pii is false. Load from secrets manager. management: endpoints: @@ -165,6 +192,39 @@ management: process: true system: true +# OpenTelemetry Configuration for Distributed Tracing +otel: + exporter: + otlp: + # Endpoint for OTLP exporter (Jaeger or Tempo backend) + # IMPORTANT: Endpoint URL must use http:// or https:// (Java URL validation requirement) + # The gRPC protocol is specified separately via the 'protocol' property below. + # - gRPC (port 4317): Binary protocol, low latency, used by Java SDK + # - HTTP (port 4318): Text protocol with higher overhead, not recommended for Java + # Environment: OTEL_EXPORTER_OTLP_ENDPOINT + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317} + # Protocol: grpc or http/protobuf (default: grpc for port 4317) + # This determines the transport protocol; endpoint URL is separate + protocol: ${OTEL_EXPORTER_OTLP_PROTOCOL:grpc} + logs: + # Disable logs exporter (Jaeger all-in-one doesn't have logs receiver) + exporter: ${OTEL_LOGS_EXPORTER:none} + service: + # Service name for tracing - must match docker-compose and OtelConfig.java + # Explicit default: ssf-graphql (not fallback to spring.application.name) + # Can be overridden via OTEL_SERVICE_NAME environment variable + name: ${OTEL_SERVICE_NAME:ssf-graphql} + traces: + # Tracer configuration + exporter: otlp # Use OTLP exporter for Jaeger/Tempo + sampler: + # Sampling strategy for traces + # - always_on: Always sample (high volume, not production) + # - always_off: Never sample + # - probability: Sample based on probability (0.0-1.0) + type: ${OTEL_TRACES_SAMPLER:probability} + arg: ${OTEL_TRACES_SAMPLER_ARG:0.1} # Sample 10% of traces by default + schema: bootstrap: enabled: true diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..235e8f5 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + ${LOG_FILE} + + + {"service":"${APPLICATION_NAME}","environment":"${ENVIRONMENT:-dev}"} + + true + + false + + + @timestamp + @version + message + logger_name + thread_name + log_level + log_level_value + caller + stacktrace + mdc + + + + + + ${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz + 100MB + 30 + 10GB + + + + + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + 2048 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/com/rcs/ssf/service/ImportExportServiceTest.java b/src/test/java/com/rcs/ssf/service/ImportExportServiceTest.java index c2fe7cb..a753435 100644 --- a/src/test/java/com/rcs/ssf/service/ImportExportServiceTest.java +++ b/src/test/java/com/rcs/ssf/service/ImportExportServiceTest.java @@ -1,5 +1,6 @@ package com.rcs.ssf.service; +import com.fasterxml.jackson.databind.ObjectMapper; import com.rcs.ssf.dto.*; import com.rcs.ssf.dto.BulkCrudResponse.Status; import org.junit.jupiter.api.BeforeEach; @@ -36,7 +37,8 @@ public class ImportExportServiceTest { @BeforeEach public void setUp() { MockitoAnnotations.openMocks(this); - importExportService = new ImportExportService(bulkCrudService, dynamicCrudService, "ROLE_ADMIN", "audit_login_attempts,audit_sessions,audit_dynamic_crud,audit_error_log"); + ObjectMapper objectMapper = new ObjectMapper(); + importExportService = new ImportExportService(bulkCrudService, dynamicCrudService, objectMapper, "ROLE_ADMIN", "audit_login_attempts,audit_sessions,audit_dynamic_crud,audit_error_log"); setupSecurityContext(); } diff --git a/test-traces.sh b/test-traces.sh new file mode 100755 index 0000000..ae4f377 --- /dev/null +++ b/test-traces.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +# Generate test traces to populate Jaeger service dependencies +set -e # Exit immediately if any command fails + +echo "Generating test traces..." + +# Function to execute GraphQL request with HTTP status validation +execute_graphql_test() { + local test_name="$1" + local query="$2" + + echo "Test: $test_name" + local http_code + http_code=$(curl -s -o /tmp/gql_response.json -w '%{http_code}' -k -X POST https://localhost:8443/graphql \ + -H "Content-Type: application/json" \ + -d "$query") + + if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then + echo "✓ $test_name succeeded (HTTP $http_code)" + head -5 /tmp/gql_response.json + elif [ "$http_code" -ge 400 ]; then + echo "⚠ $test_name returned HTTP $http_code (expected for auth tests)" + head -5 /tmp/gql_response.json + else + echo "✗ $test_name failed with HTTP $http_code" >&2 + cat /tmp/gql_response.json >&2 + return 1 + fi +} + +# Test 1: Simple GraphQL query +execute_graphql_test "getSystemStatus query" \ + '{"query":"query { getSystemStatus { status uptime } }"}' + +sleep 2 + +# Test 2: Get current user (requires auth, so this will fail but still generates a span) +execute_graphql_test "getCurrentUser query" \ + '{"query":"query { getCurrentUser { id username email } }"}' + +sleep 2 + +# Test 3: Create user mutation (will fail but generates spans) +execute_graphql_test "createUser mutation" \ + '{"query":"mutation { createUser(input: {username: \"testuser\", email: \"test@example.com\", password: \"pass123\"}) { id username email } }"}' + +sleep 3 + +echo "" +echo "✅ Traces generated successfully! Checking Elasticsearch..." + +# Verify Elasticsearch connectivity +es_check=$(curl -s -o /dev/null -w '%{http_code}' 'http://localhost:9200/_cat/indices?v') +if [ "$es_check" = "200" ]; then + echo "✓ Elasticsearch is reachable" + curl -s 'http://localhost:9200/_cat/indices?v' | grep -E "jaeger|span" || echo " (No jaeger indices found yet - may take a moment)" +else + echo "⚠ Elasticsearch not fully ready (HTTP $es_check) - indices may appear shortly" +fi + +echo "" +echo "✅ Jaeger UI available at: http://localhost:16686" +echo "✅ All tests completed successfully" +echo "Dependencies will appear in Jaeger after a few moments of trace collection."