Skip to content

Feat(x2a): collectArtifacts with HMAC authz#2403

Merged
elai-shalev merged 3 commits intoredhat-developer:mainfrom
eloycoto:flpath-3341
Mar 4, 2026
Merged

Feat(x2a): collectArtifacts with HMAC authz#2403
elai-shalev merged 3 commits intoredhat-developer:mainfrom
eloycoto:flpath-3341

Conversation

@eloycoto
Copy link
Copy Markdown
Contributor

@eloycoto eloycoto commented Feb 26, 2026

User description

  • Add a new auth system using HMAC fingerprints.
  • Create SignatureValidator utility class for secure signature operations
  • Update job tokens to 256-bit
  • Take care of time-replay attacks(3 hours)
  • Added test for all corner cases.
  • Update openapi specs.

I tested with this script:


set -e

BASE_URL="${X2A_BASE_URL:-http://localhost:7007/api/x2a}"
AUTH_URL="${AUTH_URL:-http://localhost:7007/api/auth}"
PROJECT_ID="$1"
PHASE="${2:-init}"
DB_PATH="${X2A_DB_PATH:-./packages/backend/x2a.sqlite}"

if ! command -v jq &> /dev/null; then
    echo "Error: jq is required. Install with: sudo dnf install jq"
    exit 1
fi

if ! command -v sqlite3 &> /dev/null; then
    echo "Error: sqlite3 is required. Install with: sudo dnf install sqlite"
    exit 1
fi

echo ""
echo "Creating job..."
echo "==============="

echo "Authenticating with guest token..."

AUTH_RESPONSE=$(curl -s -X GET "$AUTH_URL/guest/refresh" \
  -H "Content-Type: application/json" 2>&1)

TOKEN=$(echo "$AUTH_RESPONSE" | jq -r '.backstageIdentity.token // empty')

if [ -z "$TOKEN" ]; then
    echo "✗ Failed to get authentication token"
    echo "Response: $AUTH_RESPONSE"
    echo ""
    echo "Is the backend running at $AUTH_URL?"
    exit 1
fi

echo "✓ Authenticated"

if [ -z "$PROJECT_ID" ]; then
    echo "Creating new project..."

    PROJECT_RESPONSE=$(curl -s -X POST "$BASE_URL/projects" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer $TOKEN" \
      -d '{
        "name": "Test Project '"$(date +%s)"'",
        "description": "Test project",
        "abbreviation": "TP'"$(date +%s | tail -c 4)"'",
        "sourceRepoUrl": "https://github.com/test/source",
        "targetRepoUrl": "https://github.com/test/target",
        "sourceRepoBranch": "main",
        "targetRepoBranch": "main"
      }')

    PROJECT_ID=$(echo "$PROJECT_RESPONSE" | jq -r '.id // empty')

    if [ -z "$PROJECT_ID" ]; then
        echo "✗ Failed to create project"
        echo "Response: $PROJECT_RESPONSE"
        exit 1
    fi

    echo "✓ Project created: $PROJECT_ID"
fi

echo "Creating $PHASE job..."

if [ "$PHASE" != "init" ]; then
    echo "Creating module for $PHASE phase..."

    MODULE_RESPONSE=$(curl -s -X POST "$BASE_URL/projects/$PROJECT_ID/modules" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer $TOKEN" \
      -d '{
        "name": "Test Module",
        "sourcePath": "/cookbooks/test"
      }')

    MODULE_ID=$(echo "$MODULE_RESPONSE" | jq -r '.id // empty')

    if [ -z "$MODULE_ID" ]; then
        echo "✗ Failed to create module"
        echo "Response: $MODULE_RESPONSE"
        exit 1
    fi

    echo "✓ Module created: $MODULE_ID"

    # Module-level job
    JOB_RESPONSE=$(curl -s -X POST "$BASE_URL/projects/$PROJECT_ID/modules/$MODULE_ID/run" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer $TOKEN" \
      -d '{
        "phase": "'"$PHASE"'",
        "sourceRepoAuth": {"token": "test-token"},
        "targetRepoAuth": {"token": "test-token"}
      }')
else
    # Project-level init job (no phase parameter needed)
    JOB_RESPONSE=$(curl -s -X POST "$BASE_URL/projects/$PROJECT_ID/run" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer $TOKEN" \
      -d '{
        "sourceRepoAuth": {"token": "test-token"},
        "targetRepoAuth": {"token": "test-token"}
      }')
fi

JOB_ID=$(echo "$JOB_RESPONSE" | jq -r '.jobId // empty')

if [ -z "$JOB_ID" ] || [ "$JOB_ID" = "null" ]; then
    echo "✗ Failed to create job"
    echo "Response: $JOB_RESPONSE"
    exit 1
fi

echo "✓ Job created: $JOB_ID"

echo "Fetching callback token from database..."

if [ ! -f "$DB_PATH" ]; then
    echo "✗ Database file not found at: $DB_PATH"
    echo ""
    echo "Make sure the backend is configured to use a file-based SQLite database."
    echo "Check app-config.yaml: backend.database.connection should be a file path."
    exit 1
fi

CALLBACK_TOKEN=$(sqlite3 "$DB_PATH" "SELECT callback_token FROM jobs WHERE id = '$JOB_ID';" 2>&1)

if [ -z "$CALLBACK_TOKEN" ]; then
    echo "✗ Failed to retrieve callback token"
    echo ""
    echo "Query the database manually:"
    echo "  sqlite3 $DB_PATH \"SELECT id, phase, callback_token FROM jobs WHERE id = '$JOB_ID';\""
    exit 1
fi

echo "✓ Token retrieved"
echo ""
echo "=================================="
echo "Job Created Successfully!"
echo "=================================="
echo ""
echo "Project ID:     $PROJECT_ID"
echo "Job ID:         $JOB_ID"
echo "Phase:          $PHASE"
echo "Callback Token: $CALLBACK_TOKEN"
echo ""
echo "Test with UV (X2Ansible convertor CLI):"
echo "----------------------------------------"
echo "uv run app.py report \\"
echo "    --url \"http://localhost:7007/api/x2a/projects/$PROJECT_ID/collectArtifacts?phase=$PHASE\" \\"
echo "    --job-id \"$JOB_ID\" \\"
echo "    --callback-token \"$CALLBACK_TOKEN\" \\"
echo "    --artifacts \"migration_plan:https://storage.example/plan.md\""
echo ""

echo "" | sed "s/JOB_ID/$JOB_ID/g; s/CALLBACK_TOKEN/$CALLBACK_TOKEN/g; s/PROJECT_ID/$PROJECT_ID/g; s/PHASE/$PHASE/g"

Fix FLPATH-3341

Hey, I just made a Pull Request!

✔️ Checklist

  • A changeset describing the change and affected packages. (more info)
  • Added or Updated documentation
  • Tests for new functionality and regression tests for bug fixes
  • Screenshots attached (for UI changes)

PR Type

Enhancement, Tests


Description

  • Implement HMAC-SHA256 signature validation for collectArtifacts endpoint

  • Add SignatureValidator utility class for secure signature operations

  • Upgrade callback tokens to 256-bit cryptographic strength

  • Add replay attack prevention with 3-hour job age validation

  • Refactor request handling with dedicated validation classes

  • Update OpenAPI specs with security scheme documentation


Diagram Walkthrough

flowchart LR
  A["Job Creation"] -->|"Generate 256-bit token"| B["callbackToken"]
  B -->|"Stored in Job"| C["Job Record"]
  D["X2Ansible Job"] -->|"Sign with token"| E["SignatureValidator"]
  E -->|"HMAC-SHA256"| F["X-Callback-Signature"]
  F -->|"Include in request"| G["collectArtifacts Endpoint"]
  G -->|"Validate signature"| H["AuthenticationHandler"]
  H -->|"Check job age"| I["Replay Prevention"]
  I -->|"3 hour window"| J["Accept/Reject"]
Loading

File Walkthrough

Relevant files
Enhancement
5 files
SignatureValidator.ts
Create HMAC-SHA256 signature validator utility class         
+66/-0   
collectArtifacts.ts
Implement HMAC signature validation and request handling 
+277/-81
projects.ts
Upgrade callback token generation to 256-bit                         
+3/-2     
modules.ts
Upgrade callback token generation to 256-bit                         
+3/-2     
index.ts
Restructure router to bypass OpenAPI parser for raw body access
+16/-6   
Tests
2 files
SignatureValidator.test.ts
Add comprehensive tests for signature validation                 
+218/-0 
collectArtifacts.test.ts
Add signature validation test cases and update existing tests
+344/-45
Documentation
5 files
openapi.yaml
Document HMAC security scheme and replay attack prevention
+29/-3   
router.ts
Update generated OpenAPI spec with security definitions   
+26/-2   
Api.server.ts
Add X-Callback-Signature header to server API type             
+4/-1     
Api.client.ts
Add X-Callback-Signature header to client API type             
+6/-1     
report.api.md
Update API report with signature header definition             
+3/-0     

@rhdh-gh-app
Copy link
Copy Markdown

rhdh-gh-app Bot commented Feb 26, 2026

Missing Changesets

The following package(s) are changed by this PR but do not have a changeset:

  • @red-hat-developer-hub/backstage-plugin-x2a-backend
  • @red-hat-developer-hub/backstage-plugin-x2a-common

See CONTRIBUTING.md for more information about how to add changesets.

Changed Packages

Package Name Package Path Changeset Bump Current Version
@red-hat-developer-hub/backstage-plugin-x2a-backend workspaces/x2a/plugins/x2a-backend none v1.0.1
@red-hat-developer-hub/backstage-plugin-x2a-common workspaces/x2a/plugins/x2a-common none v1.0.1

@rhdh-qodo-merge
Copy link
Copy Markdown

rhdh-qodo-merge Bot commented Feb 26, 2026

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
Sensitive log exposure

Description: The new logging added for collectArtifacts includes logger.debug(collectArtifacts parsed
body: ${JSON.stringify(parsedBody)}) and logger.debug(Request body:
${JSON.stringify(requestBody)}) which can leak sensitive information into logs if the
callback payload (e.g., artifacts, telemetry, or errorDetails) ever contains credentials,
internal URLs, or other secrets.
collectArtifacts.ts [269-197]

Referred Code
const parsedBody = JSON.parse(rawBody.toString('utf-8'));
logger.debug(
Ticket Compliance
🎫 No ticket provided
  • Create ticket/issue
Codebase Duplication Compliance
🟢
No codebase code duplication found New Components Detected (Top 5):
- it: should return 401 when X-Callback-Signature header is missing
- it: should return 401 when signature is invalid
- it: should return 200 when signature is valid
- it: should return 401 when signature is empty string
- it: should return 401 when request body is tampered after signing
Custom Compliance
🟢
Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

🔴
Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status: 🏷️
Unhandled JSON parse: The handler calls JSON.parse on the raw body without converting parse failures into a
user-actionable InputError, potentially resulting in a 500 for malformed JSON rather than
a controlled 4xx response.

Referred Code
const parsedBody = JSON.parse(rawBody.toString('utf-8'));
logger.debug(
  `collectArtifacts parsed body: ${JSON.stringify(parsedBody)}`,
);

const validatedRequest =
  requestValidator.validateRequestBody(parsedBody);
requestValidator.validateStatusRequirements(validatedRequest);

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status: 🏷️
Sensitive data logging: The route logs the entire parsed request body (and logs signature prefixes on auth
failure), which can expose sensitive fields (e.g., telemetry contents, artifacts, commit
identifiers) and is not safe for production logs.

Referred Code
  private logAuthFailure(
    jobId: string,
    reason: string,
    rawBody: Buffer,
    signature?: string,
  ): void {
    const bodyHash = this.computeBodyHash(rawBody);
    this.logger.warn(
      `Auth failed for job ${jobId}: ${reason} (sig: ${signature?.substring(0, 8) || 'none'}, bodyHash: ${bodyHash}, bodyLen: ${rawBody.length})`,
    );
  }
}

class RequestValidator {
  constructor(private readonly logger: RouterDeps['logger']) {}

  validatePhaseParams(
    phase: MigrationPhase,
    moduleId: string | undefined,
  ): void {
    if (phase === 'init' && moduleId) {


 ... (clipped 21 lines)

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status: 🏷️
Phase not validated: The phase query parameter is cast to MigrationPhase and used before being validated
against an allowlist, so invalid phases may flow into logic unless guaranteed by upstream
routing/OpenAPI enforcement.

Referred Code
const { projectId } = req.params;
const moduleId = req.query.moduleId as string | undefined;
const phase = req.query.phase as MigrationPhase;
const rawBody = req.body as Buffer;

logger.info(
  `collectArtifacts: projectId=${projectId}, moduleId=${moduleId}, phase=${phase}`,
);

requestValidator.validatePhaseParams(phase, moduleId);

Learn more about managing compliance generic rules or creating your own custom rules

  • Update
Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

@rhdh-qodo-merge
Copy link
Copy Markdown

rhdh-qodo-merge Bot commented Feb 26, 2026

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Security
Prevent unhandled exceptions from invalid signatures

Wrap the Buffer.from(providedSignature, 'hex') call in a try...catch block to
gracefully handle invalid hex characters and return false instead of throwing an
error.

workspaces/x2a/plugins/x2a-backend/src/router/utils/SignatureValidator.ts [53-64]

-if (expectedSignature.length !== providedSignature.length) {
+const expectedBuffer = Buffer.from(expectedSignature, 'hex');
+let providedBuffer: Buffer;
+
+// The provided signature must be a valid hex string of the same length,
+// otherwise the conversion to a buffer will fail.
+try {
+  providedBuffer = Buffer.from(providedSignature, 'hex');
+} catch {
   return false;
 }
 
-const expectedBuffer = Buffer.from(expectedSignature, 'hex');
-const providedBuffer = Buffer.from(providedSignature, 'hex');
-
 if (expectedBuffer.length !== providedBuffer.length) {
+  // This check is redundant if the string length check is done before,
+  // but it's a good safeguard before calling timingSafeEqual.
   return false;
 }
 
 return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
  • Apply / Chat
Suggestion importance[1-10]: 8

__

Why: This is a valid security improvement that prevents an unhandled exception from a malformed signature, correctly handling it as an authentication failure rather than a server error.

Medium
General
Optimize logging by avoiding redundant JSON operations

Optimize logging by converting the raw body buffer to a string once for both
logging and parsing, avoiding a redundant JSON.stringify operation.

workspaces/x2a/plugins/x2a-backend/src/router/collectArtifacts.ts [269-272]

-const parsedBody = JSON.parse(rawBody.toString('utf-8'));
-logger.debug(
-  `collectArtifacts parsed body: ${JSON.stringify(parsedBody)}`,
-);
+const bodyString = rawBody.toString('utf-8');
+logger.debug(`collectArtifacts raw body: ${bodyString}`);
+const parsedBody = JSON.parse(bodyString);
  • Apply / Chat
Suggestion importance[1-10]: 3

__

Why: The suggestion correctly identifies a small inefficiency and proposes a cleaner way to log the request body, improving code quality and performance slightly.

Low
  • Update

@mareklibra
Copy link
Copy Markdown
Member

Let's discuss that offline first

@elai-shalev
Copy link
Copy Markdown
Contributor

Let's discuss that offline first

sure, just saw the discussion on slack

Copy link
Copy Markdown
Contributor

@elai-shalev elai-shalev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall looks good, some comments

Comment on lines +302 to +303
// Validate job age (3 hours = 10800 seconds) to prevent replay attacks
authHandler.validateJobAge(jobWithToken, 10800);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe lets make it configurable from the app config instead of hardcoding. the default value could still stay 10800

Comment on lines +301 to +302
// Use 256-bit token to match HMAC-SHA256 strength
const callbackToken = crypto.randomBytes(32).toString('hex');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can move this to common.ts as it is the same in modules.ts
not a must

Comment on lines +125 to +129
const bodyHash = this.computeBodyHash(rawBody);
this.logger.info(
`Signature validated for job ${jobId} (bodyHash: ${bodyHash})`,
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a full SHA-256 hash of the raw body is computed purely for an info log. With the 10MB body limit, this is a non-trivial cost on every request. The body length (rawBody.length) or a prefix of the already-validated signature would serve the same debugging purpose without the extra crypto work.

const logs = await fetchJobLogs(
kubeService,
logger,
job.k8sJobName as string,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to cast 'as string' as the fetchJobLogs can handle null values gracefully

@@ -16,7 +16,12 @@

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe lets add a test for replay attack prevention (job age validation)
the test suite covers signature validation but has no test for the 3-hour job-age window. A test with a startedAt set to 4 hours ago should return 401.

- Add a new auth system using HMAC fingerprints.
- Create SignatureValidator utility class for secure signature operations
- Update job tokens to 256-bit
- Take care of time-replay attacks(3 hours)
- Added test for all corner cases.
- Update openapi specs.

I tested with this script:

```

set -e

BASE_URL="${X2A_BASE_URL:-http://localhost:7007/api/x2a}"
AUTH_URL="${AUTH_URL:-http://localhost:7007/api/auth}"
PROJECT_ID="$1"
PHASE="${2:-init}"
DB_PATH="${X2A_DB_PATH:-./packages/backend/x2a.sqlite}"

if ! command -v jq &> /dev/null; then
    echo "Error: jq is required. Install with: sudo dnf install jq"
    exit 1
fi

if ! command -v sqlite3 &> /dev/null; then
    echo "Error: sqlite3 is required. Install with: sudo dnf install sqlite"
    exit 1
fi

echo ""
echo "Creating job..."
echo "==============="

echo "Authenticating with guest token..."

AUTH_RESPONSE=$(curl -s -X GET "$AUTH_URL/guest/refresh" \
  -H "Content-Type: application/json" 2>&1)

TOKEN=$(echo "$AUTH_RESPONSE" | jq -r '.backstageIdentity.token // empty')

if [ -z "$TOKEN" ]; then
    echo "✗ Failed to get authentication token"
    echo "Response: $AUTH_RESPONSE"
    echo ""
    echo "Is the backend running at $AUTH_URL?"
    exit 1
fi

echo "✓ Authenticated"

if [ -z "$PROJECT_ID" ]; then
    echo "Creating new project..."

    PROJECT_RESPONSE=$(curl -s -X POST "$BASE_URL/projects" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer $TOKEN" \
      -d '{
        "name": "Test Project '"$(date +%s)"'",
        "description": "Test project",
        "abbreviation": "TP'"$(date +%s | tail -c 4)"'",
        "sourceRepoUrl": "https://github.com/test/source",
        "targetRepoUrl": "https://github.com/test/target",
        "sourceRepoBranch": "main",
        "targetRepoBranch": "main"
      }')

    PROJECT_ID=$(echo "$PROJECT_RESPONSE" | jq -r '.id // empty')

    if [ -z "$PROJECT_ID" ]; then
        echo "✗ Failed to create project"
        echo "Response: $PROJECT_RESPONSE"
        exit 1
    fi

    echo "✓ Project created: $PROJECT_ID"
fi

echo "Creating $PHASE job..."

if [ "$PHASE" != "init" ]; then
    echo "Creating module for $PHASE phase..."

    MODULE_RESPONSE=$(curl -s -X POST "$BASE_URL/projects/$PROJECT_ID/modules" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer $TOKEN" \
      -d '{
        "name": "Test Module",
        "sourcePath": "/cookbooks/test"
      }')

    MODULE_ID=$(echo "$MODULE_RESPONSE" | jq -r '.id // empty')

    if [ -z "$MODULE_ID" ]; then
        echo "✗ Failed to create module"
        echo "Response: $MODULE_RESPONSE"
        exit 1
    fi

    echo "✓ Module created: $MODULE_ID"

    # Module-level job
    JOB_RESPONSE=$(curl -s -X POST "$BASE_URL/projects/$PROJECT_ID/modules/$MODULE_ID/run" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer $TOKEN" \
      -d '{
        "phase": "'"$PHASE"'",
        "sourceRepoAuth": {"token": "test-token"},
        "targetRepoAuth": {"token": "test-token"}
      }')
else
    # Project-level init job (no phase parameter needed)
    JOB_RESPONSE=$(curl -s -X POST "$BASE_URL/projects/$PROJECT_ID/run" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer $TOKEN" \
      -d '{
        "sourceRepoAuth": {"token": "test-token"},
        "targetRepoAuth": {"token": "test-token"}
      }')
fi

JOB_ID=$(echo "$JOB_RESPONSE" | jq -r '.jobId // empty')

if [ -z "$JOB_ID" ] || [ "$JOB_ID" = "null" ]; then
    echo "✗ Failed to create job"
    echo "Response: $JOB_RESPONSE"
    exit 1
fi

echo "✓ Job created: $JOB_ID"

echo "Fetching callback token from database..."

if [ ! -f "$DB_PATH" ]; then
    echo "✗ Database file not found at: $DB_PATH"
    echo ""
    echo "Make sure the backend is configured to use a file-based SQLite database."
    echo "Check app-config.yaml: backend.database.connection should be a file path."
    exit 1
fi

CALLBACK_TOKEN=$(sqlite3 "$DB_PATH" "SELECT callback_token FROM jobs WHERE id = '$JOB_ID';" 2>&1)

if [ -z "$CALLBACK_TOKEN" ]; then
    echo "✗ Failed to retrieve callback token"
    echo ""
    echo "Query the database manually:"
    echo "  sqlite3 $DB_PATH \"SELECT id, phase, callback_token FROM jobs WHERE id = '$JOB_ID';\""
    exit 1
fi

echo "✓ Token retrieved"
echo ""
echo "=================================="
echo "Job Created Successfully!"
echo "=================================="
echo ""
echo "Project ID:     $PROJECT_ID"
echo "Job ID:         $JOB_ID"
echo "Phase:          $PHASE"
echo "Callback Token: $CALLBACK_TOKEN"
echo ""
echo "Test with UV (X2Ansible convertor CLI):"
echo "----------------------------------------"
echo "uv run app.py report \\"
echo "    --url \"http://localhost:7007/api/x2a/projects/$PROJECT_ID/collectArtifacts?phase=$PHASE\" \\"
echo "    --job-id \"$JOB_ID\" \\"
echo "    --callback-token \"$CALLBACK_TOKEN\" \\"
echo "    --artifacts \"migration_plan:https://storage.example/plan.md\""
echo ""

echo "" | sed "s/JOB_ID/$JOB_ID/g; s/CALLBACK_TOKEN/$CALLBACK_TOKEN/g; s/PROJECT_ID/$PROJECT_ID/g; s/PHASE/$PHASE/g"
```

Fix FLPATH-3341

Signed-off-by: Eloy Coto <eloy.coto@acalustra.com>
Signed-off-by: Eloy Coto <eloy.coto@acalustra.com>
Copy link
Copy Markdown
Contributor

@elai-shalev elai-shalev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the changes marked TODO here: https://github.com/redhat-developer/rhdh-plugins/blob/main/workspaces/x2a/plugins/x2a-backend/templates/x2a-job-script.sh#L14-L19
should be part of this PR? The job would fail without them?

@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Mar 4, 2026

@elai-shalev elai-shalev merged commit 80abfeb into redhat-developer:main Mar 4, 2026
9 checks passed
rohitratannagar pushed a commit to rohitratannagar/rhdh-plugins that referenced this pull request Mar 12, 2026
* Feat(x2a): collectArtifacts with HMAC authz

- Add a new auth system using HMAC fingerprints.
- Create SignatureValidator utility class for secure signature operations
- Update job tokens to 256-bit
- Take care of time-replay attacks(3 hours)
- Added test for all corner cases.
- Update openapi specs.

I tested with this script:

```

set -e

BASE_URL="${X2A_BASE_URL:-http://localhost:7007/api/x2a}"
AUTH_URL="${AUTH_URL:-http://localhost:7007/api/auth}"
PROJECT_ID="$1"
PHASE="${2:-init}"
DB_PATH="${X2A_DB_PATH:-./packages/backend/x2a.sqlite}"

if ! command -v jq &> /dev/null; then
    echo "Error: jq is required. Install with: sudo dnf install jq"
    exit 1
fi

if ! command -v sqlite3 &> /dev/null; then
    echo "Error: sqlite3 is required. Install with: sudo dnf install sqlite"
    exit 1
fi

echo ""
echo "Creating job..."
echo "==============="

echo "Authenticating with guest token..."

AUTH_RESPONSE=$(curl -s -X GET "$AUTH_URL/guest/refresh" \
  -H "Content-Type: application/json" 2>&1)

TOKEN=$(echo "$AUTH_RESPONSE" | jq -r '.backstageIdentity.token // empty')

if [ -z "$TOKEN" ]; then
    echo "✗ Failed to get authentication token"
    echo "Response: $AUTH_RESPONSE"
    echo ""
    echo "Is the backend running at $AUTH_URL?"
    exit 1
fi

echo "✓ Authenticated"

if [ -z "$PROJECT_ID" ]; then
    echo "Creating new project..."

    PROJECT_RESPONSE=$(curl -s -X POST "$BASE_URL/projects" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer $TOKEN" \
      -d '{
        "name": "Test Project '"$(date +%s)"'",
        "description": "Test project",
        "abbreviation": "TP'"$(date +%s | tail -c 4)"'",
        "sourceRepoUrl": "https://github.com/test/source",
        "targetRepoUrl": "https://github.com/test/target",
        "sourceRepoBranch": "main",
        "targetRepoBranch": "main"
      }')

    PROJECT_ID=$(echo "$PROJECT_RESPONSE" | jq -r '.id // empty')

    if [ -z "$PROJECT_ID" ]; then
        echo "✗ Failed to create project"
        echo "Response: $PROJECT_RESPONSE"
        exit 1
    fi

    echo "✓ Project created: $PROJECT_ID"
fi

echo "Creating $PHASE job..."

if [ "$PHASE" != "init" ]; then
    echo "Creating module for $PHASE phase..."

    MODULE_RESPONSE=$(curl -s -X POST "$BASE_URL/projects/$PROJECT_ID/modules" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer $TOKEN" \
      -d '{
        "name": "Test Module",
        "sourcePath": "/cookbooks/test"
      }')

    MODULE_ID=$(echo "$MODULE_RESPONSE" | jq -r '.id // empty')

    if [ -z "$MODULE_ID" ]; then
        echo "✗ Failed to create module"
        echo "Response: $MODULE_RESPONSE"
        exit 1
    fi

    echo "✓ Module created: $MODULE_ID"

    # Module-level job
    JOB_RESPONSE=$(curl -s -X POST "$BASE_URL/projects/$PROJECT_ID/modules/$MODULE_ID/run" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer $TOKEN" \
      -d '{
        "phase": "'"$PHASE"'",
        "sourceRepoAuth": {"token": "test-token"},
        "targetRepoAuth": {"token": "test-token"}
      }')
else
    # Project-level init job (no phase parameter needed)
    JOB_RESPONSE=$(curl -s -X POST "$BASE_URL/projects/$PROJECT_ID/run" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer $TOKEN" \
      -d '{
        "sourceRepoAuth": {"token": "test-token"},
        "targetRepoAuth": {"token": "test-token"}
      }')
fi

JOB_ID=$(echo "$JOB_RESPONSE" | jq -r '.jobId // empty')

if [ -z "$JOB_ID" ] || [ "$JOB_ID" = "null" ]; then
    echo "✗ Failed to create job"
    echo "Response: $JOB_RESPONSE"
    exit 1
fi

echo "✓ Job created: $JOB_ID"

echo "Fetching callback token from database..."

if [ ! -f "$DB_PATH" ]; then
    echo "✗ Database file not found at: $DB_PATH"
    echo ""
    echo "Make sure the backend is configured to use a file-based SQLite database."
    echo "Check app-config.yaml: backend.database.connection should be a file path."
    exit 1
fi

CALLBACK_TOKEN=$(sqlite3 "$DB_PATH" "SELECT callback_token FROM jobs WHERE id = '$JOB_ID';" 2>&1)

if [ -z "$CALLBACK_TOKEN" ]; then
    echo "✗ Failed to retrieve callback token"
    echo ""
    echo "Query the database manually:"
    echo "  sqlite3 $DB_PATH \"SELECT id, phase, callback_token FROM jobs WHERE id = '$JOB_ID';\""
    exit 1
fi

echo "✓ Token retrieved"
echo ""
echo "=================================="
echo "Job Created Successfully!"
echo "=================================="
echo ""
echo "Project ID:     $PROJECT_ID"
echo "Job ID:         $JOB_ID"
echo "Phase:          $PHASE"
echo "Callback Token: $CALLBACK_TOKEN"
echo ""
echo "Test with UV (X2Ansible convertor CLI):"
echo "----------------------------------------"
echo "uv run app.py report \\"
echo "    --url \"http://localhost:7007/api/x2a/projects/$PROJECT_ID/collectArtifacts?phase=$PHASE\" \\"
echo "    --job-id \"$JOB_ID\" \\"
echo "    --callback-token \"$CALLBACK_TOKEN\" \\"
echo "    --artifacts \"migration_plan:https://storage.example/plan.md\""
echo ""

echo "" | sed "s/JOB_ID/$JOB_ID/g; s/CALLBACK_TOKEN/$CALLBACK_TOKEN/g; s/PROJECT_ID/$PROJECT_ID/g; s/PHASE/$PHASE/g"
```

Fix FLPATH-3341

Signed-off-by: Eloy Coto <eloy.coto@acalustra.com>

* fix(x2a): fix PR comments

Signed-off-by: Eloy Coto <eloy.coto@acalustra.com>

* Add callback token on x2a-report

---------

Signed-off-by: Eloy Coto <eloy.coto@acalustra.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants