Skip to content

feat: support for oidc credential /test endpoint#16370

Open
fincamd wants to merge 14 commits intoansible:develfrom
fincamd:AAP-67727-oidc-vault-creds-testing
Open

feat: support for oidc credential /test endpoint#16370
fincamd wants to merge 14 commits intoansible:develfrom
fincamd:AAP-67727-oidc-vault-creds-testing

Conversation

@fincamd
Copy link
Copy Markdown
Contributor

@fincamd fincamd commented Mar 25, 2026

SUMMARY

This PR addresses the credential test functionality for existing and new credentials when connecting to OIDC secret providers.

ISSUE TYPE
  • New or Enhanced Feature
COMPONENT NAME
  • API
STEPS TO REPRODUCE AND EXTRA INFO

Summary by CodeRabbit

  • New Features

    • Workload-identity token support for credential testing, including job-template–based token injection and returned token details on success.
  • API Changes

    • API responses now omit credential input fields marked internal.
    • Credential test endpoints return decoded token details on success and return clear errors when a job template is missing or access is denied.
  • Bug Fixes

    • Prevented in-place mutation of credential input data during serialization.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Added a workload-identity JWT helper and refactored token retrieval to use it; injected generated workload-identity JWTs into external credential-test endpoints when feature-flagged (with job template loading and permission checks); and filtered out internal credential input fields during serialization.

Changes

Cohort / File(s) Summary
Credential field filtering
awx/api/serializers.py
CredentialTypeSerializer.to_representation now deep-copies inputs, reads fields from value['inputs'], and removes any inputs.fields entries where internal is truthy, preventing in-place mutation and excluding internal fields from API output.
Credential test endpoints (OIDC workload identity)
awx/api/views/__init__.py
Added _get_workload_identity_token and updated CredentialExternalTest.post and CredentialTypeExternalTest.post to detect internal workload_identity_token inputs when feature enabled; require backend_kwargs.job_template_id; load and permission-check the JobTemplate; inject generated token into backend_kwargs; return decoded JWT payload in success response; added 400 and PermissionDenied error paths.
Workload identity helper & usage
awx/main/utils/workload_identity.py, awx/main/tasks/jobs.py
New retrieve_workload_identity_jwt_with_claims(claims, audience, scope, workload_ttl_seconds) implemented and used by retrieve_workload_identity_jwt() in tasks to delegate token generation with externally-built claims.

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant Handler as TestEndpoint
    participant Schema as CredentialSchema
    participant JT as JobTemplateSvc
    participant Auth as PermissionCheck
    participant Claims as ClaimsBuilder
    participant JWTSvc as WorkloadJWTSvc
    participant Decoder as JWTDecoder

    Client->>Handler: POST /credential/test (backend_kwargs incl. job_template_id)
    Handler->>Schema: Inspect credential inputs for internal workload_identity_token
    Schema-->>Handler: indicates workload_identity_token present
    Handler->>JT: Load JobTemplate by id
    JT-->>Handler: JobTemplate instance
    Handler->>Auth: user.can_access(job_template, "read")
    Auth-->>Handler: permission result
    Handler->>Claims: Build claims from JobTemplate/org/project
    Claims-->>Handler: claims dict
    Handler->>JWTSvc: retrieve_workload_identity_jwt_with_claims(claims, aud, scope)
    JWTSvc-->>Handler: workload_identity_token
    Handler->>Decoder: Decode token (RS256, verification disabled)
    Decoder-->>Handler: sent_jwt_payload
    Handler->>Client: 202 Accepted + {"details": {"sent_jwt_payload": ...}}
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: support for oidc credential /test endpoint' directly and specifically describes the main feature addition across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

)
backend_kwargs['workload_identity_token'] = workload_identity_token
from jwt import decode as _jwt_decode
response_body['sent_jwt_payload'] = _jwt_decode(workload_identity_token, algorithms=["RS256"], options={"verify_signature": False})
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.

In the conversation we had around the PoC we were thinking about nesting sent_jwt_payload under something more generic:

{
    "details": {
         "sent_jwt_payload": "..."
    }
}

This pattern is followed some lines above, but not here. Was this intentional?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Got it, done in 8154edc

return Response({'detail': _('Job template ID is required.')}, status=status.HTTP_400_BAD_REQUEST)
job_template = models.JobTemplate.objects.get(id=int())
if not request.user.can_access(models.JobTemplate, 'read', job_template):
raise PermissionDenied(_(f'You do not have access to job template with id: {job_template.id}.'))
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.

What happens in this case? Do we get a 500 HTTP error? Or something else? Shouldn't we be returning another HTTP_400_BAD_REQUEST here, instead of raising an exception?

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.

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.

@fincamd feel free to resolve this conversation if we get a 403 when the user tries to use a Job Template it does not have access to

Comment on lines +2945 to +2955
# If workload identity token field exists, add job_template to metadata
if found_wit_field:
metadata = value['inputs'].get('metadata', [])
metadata.append({
"id": "job_template_id",
"label": "ID of a Job Template",
"type": "string",
"help_text": "Job template ID to use when generating a token."
})
value['inputs']['metadata'] = metadata

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 talked about making this a dynamic input in the UI instead of making it a dynamic field in the backend.

Both solutions are ugly, but my personal take is that the UI workaround is less ugly. Please take a look that the drafted PR linked in AAP-64624 (the corresponding UI change). Thoughts?

Copy link
Copy Markdown
Contributor

@dleehr dleehr Mar 25, 2026

Choose a reason for hiding this comment

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

Agreed that it's less ugly for the UI to special-case the inclusion of job_template_id when performing a test. This just came up on Slack and I said the same thing 😄 . So I think we can delete 2945-2955

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, done in c498199

Copy link
Copy Markdown
Contributor

@dleehr dleehr left a comment

Choose a reason for hiding this comment

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

Kudos to @matoval for testing this with the UI. Some early feedback:

  1. Both CredentialExternalTest and CredentialTypeExternalTest views need to implement the new behavior. Right now it's just happening in CredentialExternalTest
  2. Calling the API with a job_template_id fails because the plugin's backend function doesn't expect this argument. Matthew suggested to .pop() the job template id instead of .get(). I don't remember running into this problem in my PoC 🤔, but we obviously don't want it to fail.
  3. The test operation re-uses retrieve_workload_identity_jwt function from jobs.py to get a JWT, but that's intended for jobs not templates and isn't working. We'll need to do something different there - we can't use that function as-is with job templates, it's meant for jobs.

raise PermissionDenied(_(f'You do not have access to job template with id: {job_template.id}.'))

# Get a Workload Identity Token
workload_identity_token = retrieve_workload_identity_jwt(
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.

Credit to @matoval but this function expects a job, not a job template.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 2dc3263 and 5c02a7e. Used a reduced set of claims and refactored part of the code to be reusable

@fincamd fincamd force-pushed the AAP-67727-oidc-vault-creds-testing branch from 5b0693e to 8154edc Compare March 30, 2026 08:47
@fincamd fincamd marked this pull request as ready for review March 30, 2026 15:44
@fincamd fincamd requested review from PabloHiro and dleehr March 30, 2026 15:44
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
awx/api/views/__init__.py (2)

167-183: Consider defensive null checks for related objects.

The function directly accesses job_template.organization.name and job_template.project.name. While these fields are typically required on JobTemplate, adding null checks (similar to getattr_dne pattern used in populate_claims_for_workload) would make this more resilient to edge cases.

♻️ Optional defensive approach
 def _get_workload_identity_token(job_template: models.JobTemplate, jwt_aud: str) -> str:
-    claims = {
-        AutomationControllerJobScope.CLAIM_ORGANIZATION_NAME: job_template.organization.name,
-        AutomationControllerJobScope.CLAIM_ORGANIZATION_ID: job_template.organization.id,
-        AutomationControllerJobScope.CLAIM_PROJECT_NAME: job_template.project.name,
-        AutomationControllerJobScope.CLAIM_PROJECT_ID: job_template.project.id,
-        AutomationControllerJobScope.CLAIM_JOB_TEMPLATE_NAME: job_template.name,
-        AutomationControllerJobScope.CLAIM_JOB_TEMPLATE_ID: job_template.id,
-        AutomationControllerJobScope.CLAIM_PLAYBOOK_NAME: job_template.playbook,
-    }
+    claims = {
+        AutomationControllerJobScope.CLAIM_JOB_TEMPLATE_NAME: job_template.name,
+        AutomationControllerJobScope.CLAIM_JOB_TEMPLATE_ID: job_template.id,
+        AutomationControllerJobScope.CLAIM_PLAYBOOK_NAME: job_template.playbook,
+    }
+    if job_template.organization:
+        claims[AutomationControllerJobScope.CLAIM_ORGANIZATION_NAME] = job_template.organization.name
+        claims[AutomationControllerJobScope.CLAIM_ORGANIZATION_ID] = job_template.organization.id
+    if job_template.project:
+        claims[AutomationControllerJobScope.CLAIM_PROJECT_NAME] = job_template.project.name
+        claims[AutomationControllerJobScope.CLAIM_PROJECT_ID] = job_template.project.id
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@awx/api/views/__init__.py` around lines 167 - 183, The function
_get_workload_identity_token accesses nested attributes on job_template
(job_template.organization.name, .id, job_template.project.name, .id, etc.)
without null checks; make these accesses defensive by using the existing
getattr_dne helper (as used in populate_claims_for_workload) or explicit
None-safe lookups to obtain organization name/id, project name/id, playbook, and
job_template fields before building claims, and fall back to empty strings or
safe defaults so retrieve_workload_identity_jwt_with_claims always receives
defined claim values.

1644-1665: Consider extracting duplicated OIDC handling logic.

The OIDC workload identity token logic is nearly identical between CredentialExternalTest and CredentialTypeExternalTest. This duplication could lead to maintenance burden if changes are needed later.

♻️ Suggested helper extraction
def _handle_oidc_workload_identity(request, fields, backend_kwargs):
    """
    Handle OIDC workload identity token generation for credential test endpoints.
    Returns (response_body_update, error_response) tuple.
    """
    if not flag_enabled('FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED'):
        return {}, None
    
    for field in fields:
        if field.get('internal') and field.get('id') == 'workload_identity_token':
            job_template_id = backend_kwargs.get('job_template_id')
            if job_template_id is None:
                return None, Response({'detail': _('Job template ID is required.')}, status=status.HTTP_400_BAD_REQUEST)
            try:
                job_template = models.JobTemplate.objects.get(id=int(job_template_id))
            except models.JobTemplate.DoesNotExist:
                return None, Response({'detail': _('Job template not found.')}, status=status.HTTP_400_BAD_REQUEST)
            if not request.user.can_access(models.JobTemplate, 'read', job_template):
                raise PermissionDenied(_('You do not have access to job template with id: %(id)s.') % {'id': job_template.id})
            
            backend_kwargs['workload_identity_token'] = _get_workload_identity_token(job_template, backend_kwargs.get('jwt_aud'))
            from jwt import decode as _jwt_decode
            return {
                'details': {
                    'sent_jwt_payload': _jwt_decode(backend_kwargs['workload_identity_token'], algorithms=["RS256"], options={"verify_signature": False}),
                }
            }, None
    return {}, None

Also applies to: 1729-1750

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@awx/api/views/__init__.py` around lines 1644 - 1665, Extract the duplicated
OIDC workload identity token logic from CredentialExternalTest and
CredentialTypeExternalTest into a single helper (e.g.
_handle_oidc_workload_identity) that takes (request, fields, backend_kwargs) and
returns (response_body_update, error_response). Inside the helper check
flag_enabled('FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED'), iterate fields for an
internal id == 'workload_identity_token', validate job_template_id exists in
backend_kwargs, catch models.JobTemplate.DoesNotExist and return a 400 Response,
verify request.user.can_access(models.JobTemplate, 'read', job_template) (raise
PermissionDenied if not), set backend_kwargs['workload_identity_token'] using
_get_workload_identity_token(job_template, backend_kwargs.get('jwt_aud')) and
return the decoded sent_jwt_payload for the response_body; update both callers
(CredentialExternalTest and CredentialTypeExternalTest) to call the helper and
short-circuit on error_response, merging response_body_update into their
response_body.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@awx/api/serializers.py`:
- Around line 2936-2942: The code assumes value['inputs'] is a dict and calls
.get on it, which breaks for inputs=None or skips deepcopy for inputs={} ;
update the logic around value['inputs'] so you first normalize/guard it (e.g.,
if not isinstance(value.get('inputs'), dict): value['inputs'] = {}), then
perform copy.deepcopy on that dict to avoid mutating the original, and finally
compute fields = value['inputs'].get('fields', []) before filtering internal
fields with value['inputs']['fields'] = [f for f in fields if not
f.get('internal')]; ensure the normalization and deepcopy happen before any .get
calls on value['inputs'].

In `@awx/api/views/__init__.py`:
- Around line 1741-1743: Wrap the JobTemplate lookup in a try/except to catch
models.JobTemplate.DoesNotExist and raise an appropriate 404 (e.g., NotFound)
with a translated message that includes the id, and avoid using an f-string
inside the translation call; after retrieving job_template via
models.JobTemplate.objects.get(id=int(job_template_id)) handle the exception and
when checking access use request.user.can_access(...) and, if denied, call
PermissionDenied with a translation string created either by formatting outside
the _() call or by using a translation placeholder (e.g., _('You do not have
access to job template with id: %(id)s.') % {'id': job_template.id}) so the
translation pattern is correct.
- Around line 1656-1658: Wrap the JobTemplate lookup
(models.JobTemplate.objects.get(id=int(job_template_id))) in a try/except that
catches models.JobTemplate.DoesNotExist (or ObjectDoesNotExist) and raise a
BadRequest (400) with a clear message using the raw job_template_id; after a
successful get, keep the permission check but change the translation pattern to
use a translatable string with interpolation (e.g. _('You do not have access to
job template with id: %(id)s.') % {'id': job_template.id}) so the message is
translated correctly; refer to the job_template lookup and the permission check
around job_template = models.JobTemplate.objects.get(...) and
request.user.can_access(...).

---

Nitpick comments:
In `@awx/api/views/__init__.py`:
- Around line 167-183: The function _get_workload_identity_token accesses nested
attributes on job_template (job_template.organization.name, .id,
job_template.project.name, .id, etc.) without null checks; make these accesses
defensive by using the existing getattr_dne helper (as used in
populate_claims_for_workload) or explicit None-safe lookups to obtain
organization name/id, project name/id, playbook, and job_template fields before
building claims, and fall back to empty strings or safe defaults so
retrieve_workload_identity_jwt_with_claims always receives defined claim values.
- Around line 1644-1665: Extract the duplicated OIDC workload identity token
logic from CredentialExternalTest and CredentialTypeExternalTest into a single
helper (e.g. _handle_oidc_workload_identity) that takes (request, fields,
backend_kwargs) and returns (response_body_update, error_response). Inside the
helper check flag_enabled('FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED'), iterate
fields for an internal id == 'workload_identity_token', validate job_template_id
exists in backend_kwargs, catch models.JobTemplate.DoesNotExist and return a 400
Response, verify request.user.can_access(models.JobTemplate, 'read',
job_template) (raise PermissionDenied if not), set
backend_kwargs['workload_identity_token'] using
_get_workload_identity_token(job_template, backend_kwargs.get('jwt_aud')) and
return the decoded sent_jwt_payload for the response_body; update both callers
(CredentialExternalTest and CredentialTypeExternalTest) to call the helper and
short-circuit on error_response, merging response_body_update into their
response_body.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 897d0054-153e-4d13-92a6-b2578217a10a

📥 Commits

Reviewing files that changed from the base of the PR and between cd7f6f6 and e0cc026.

📒 Files selected for processing (3)
  • awx/api/serializers.py
  • awx/api/views/__init__.py
  • awx/main/tasks/jobs.py

Copy link
Copy Markdown
Contributor

@PabloHiro PabloHiro left a comment

Choose a reason for hiding this comment

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

LGTM, please address CI checks 🙏

@fincamd fincamd force-pushed the AAP-67727-oidc-vault-creds-testing branch from e0cc026 to 8926db5 Compare March 31, 2026 12:02
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
awx/api/views/__init__.py (1)

1653-1658: ⚠️ Potential issue | 🟠 Major

Validate job_template_id before dereferencing it.

int(job_template_id) and JobTemplate.objects.get() can both fail here, and this happens before the backend call is guarded, so malformed or unknown IDs currently surface as 500s in both /test endpoints. This block is duplicated too, so a small helper would keep the two paths aligned.

Suggested fix pattern
                     if job_template_id is None:
                         return Response({'detail': _('Job template ID is required.')}, status=status.HTTP_400_BAD_REQUEST)
-                    job_template = models.JobTemplate.objects.get(id=int(job_template_id))
+                    try:
+                        job_template = models.JobTemplate.objects.get(id=int(job_template_id))
+                    except (TypeError, ValueError, models.JobTemplate.DoesNotExist):
+                        return Response({'detail': _('Invalid job template ID.')}, status=status.HTTP_400_BAD_REQUEST)
                     if not request.user.can_access(models.JobTemplate, 'read', job_template):
                         raise PermissionDenied(...)

Also applies to: 1738-1743

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@awx/api/views/__init__.py` around lines 1653 - 1658, The code dereferences
job_template_id and calls models.JobTemplate.objects.get without
validating/handling bad input, causing 500s; create a small helper (e.g.,
validate_or_fetch_job_template or _get_job_template_from_kwargs) used in both
places to: extract job_template_id from backend_kwargs, attempt to coerce to int
(catch ValueError and return a 400 Response with a clear message), attempt to
fetch models.JobTemplate (catch models.JobTemplate.DoesNotExist and return a 404
Response), and then perform the existing permission check using
request.user.can_access (raise PermissionDenied as before); replace the
duplicated blocks at the current location and at the other occurrence (around
lines 1738-1743) with calls to this helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@awx/api/views/__init__.py`:
- Around line 1653-1658: The code dereferences job_template_id and calls
models.JobTemplate.objects.get without validating/handling bad input, causing
500s; create a small helper (e.g., validate_or_fetch_job_template or
_get_job_template_from_kwargs) used in both places to: extract job_template_id
from backend_kwargs, attempt to coerce to int (catch ValueError and return a 400
Response with a clear message), attempt to fetch models.JobTemplate (catch
models.JobTemplate.DoesNotExist and return a 404 Response), and then perform the
existing permission check using request.user.can_access (raise PermissionDenied
as before); replace the duplicated blocks at the current location and at the
other occurrence (around lines 1738-1743) with calls to this helper.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7d2270ea-9c12-4d5b-b9ec-b0f443e84efe

📥 Commits

Reviewing files that changed from the base of the PR and between e0cc026 and 8926db5.

📒 Files selected for processing (3)
  • awx/api/serializers.py
  • awx/api/views/__init__.py
  • awx/main/tasks/jobs.py
✅ Files skipped from review due to trivial changes (1)
  • awx/api/serializers.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • awx/main/tasks/jobs.py

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
awx/api/views/__init__.py (2)

1657-1662: ⚠️ Potential issue | 🟠 Major

Handle bad job_template_id inputs in both OIDC test paths.

int(job_template_id) and JobTemplate.objects.get() still turn malformed or unknown IDs into 500s here, and _(f"...") won't translate correctly. Catch TypeError/ValueError/DoesNotExist once in a shared helper, then use placeholder interpolation for the permission message in both endpoints.

Example patch for one branch
-                    job_template = models.JobTemplate.objects.get(id=int(job_template_id))
+                    try:
+                        job_template = models.JobTemplate.objects.get(id=int(job_template_id))
+                    except (TypeError, ValueError, models.JobTemplate.DoesNotExist):
+                        return Response({'detail': _('Job template not found.')}, status=status.HTTP_400_BAD_REQUEST)
                     if not request.user.can_access(models.JobTemplate, 'read', job_template):
-                        raise PermissionDenied(_(f'You do not have access to job template with id: {job_template.id}.'))
+                        raise PermissionDenied(_('You do not have access to job template with id: %(id)s.') % {'id': job_template.id})

Verification: confirm both duplicated branches still use the raw lookup and f-string translation path.

#!/bin/bash
rg -n -C2 "JobTemplate\.objects\.get\(id=int\(job_template_id\)\)|PermissionDenied\(_\(f'You do not have access to job template with id:" awx/api/views/__init__.py

Also applies to: 1745-1750


167-182: ⚠️ Potential issue | 🟠 Major

Don't let workload-identity setup failures escape as 500s.

retrieve_workload_identity_jwt_with_claims() in awx/main/tasks/jobs.py:161-177 explicitly raises RuntimeError when the workload identity client is not configured. Because _get_workload_identity_token() and the JWT decode both run before either endpoint's existing try, /test still returns a 500 instead of a controlled API error.

Also applies to: 1664-1668, 1752-1756

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@awx/api/views/__init__.py` around lines 167 - 182, The workload-identity
client failure currently raises a RuntimeError from
retrieve_workload_identity_jwt_with_claims and bubbles out as a 500; catch
RuntimeError in _get_workload_identity_token (and any places that call it before
their existing try blocks) and convert it into a controlled API-level exception
(e.g., raise rest_framework.exceptions.APIException or ValidationError with a
clear message) so endpoints like the /test path return a proper API error
instead of a 500; specifically wrap the call to
retrieve_workload_identity_jwt_with_claims in _get_workload_identity_token and
re-raise a DRF exception, and also add similar try/except around any pre-try JWT
decode callers to ensure consistent API error handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@awx/api/views/__init__.py`:
- Around line 167-182: The workload-identity client failure currently raises a
RuntimeError from retrieve_workload_identity_jwt_with_claims and bubbles out as
a 500; catch RuntimeError in _get_workload_identity_token (and any places that
call it before their existing try blocks) and convert it into a controlled
API-level exception (e.g., raise rest_framework.exceptions.APIException or
ValidationError with a clear message) so endpoints like the /test path return a
proper API error instead of a 500; specifically wrap the call to
retrieve_workload_identity_jwt_with_claims in _get_workload_identity_token and
re-raise a DRF exception, and also add similar try/except around any pre-try JWT
decode callers to ensure consistent API error handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 15b93346-9f9b-4fe7-bb91-e57a3cbfde8a

📥 Commits

Reviewing files that changed from the base of the PR and between 8926db5 and 1201711.

📒 Files selected for processing (1)
  • awx/api/views/__init__.py

Copy link
Copy Markdown
Contributor

@dleehr dleehr left a comment

Choose a reason for hiding this comment

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

Requesting changes primarily because there are no tests.

I left a fair bit of other feedback too. Please explore how to refactor the implementation that is duplicated across two views, address the error handling feedback and other items. The feedback from coderabbit looks sound too.

Also, please clarify what the behavior is with get vs pop. Does one fail?

Comment on lines +63 to +64
from awx.main.tasks.jobs import AutomationControllerJobScope, retrieve_workload_identity_jwt_with_claims
from awx.main.tasks.system import flag_enabled, send_notifications, update_inventory_computed_fields
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.

I find these imports from awx.main.tasks.jobs are a bit confusing

First, AutomationControllerJobScope is defined in DAB, why do we import it from awx.main.tasks.jobs?

Second, I do like how you refactored retrieve_workload_identity_jwt_with_claims to support both this test operation and job execution 👍. But maybe that function should be in some common awx module rather than awx.main.tasks.jobs. This one's not critical but please have a look.

Copy link
Copy Markdown
Contributor Author

@fincamd fincamd Apr 1, 2026

Choose a reason for hiding this comment

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

Modified that import so it draws from dab, not jobs.py (I must have had Vscode autoimport, I never checked back) Done here: 86c0364

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Second part, moving that helper function to a separate shared file: 149773a

raise PermissionDenied(_(f'You do not have access to job template with id: {job_template.id}.'))

backend_kwargs['workload_identity_token'] = _get_workload_identity_token(job_template, backend_kwargs.get('jwt_aud'))
from jwt import decode as _jwt_decode
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.

Can we move this import to the top of the file? I don't think there's a need for it to be nested.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Addressed.

Comment on lines +1736 to +1758

# Add extra test functionality for OIDC-enabled credential types
response_body = {}
if flag_enabled('FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED'):
# Get a Workload Identity Token if credential contains an internal 'workload_identity_token' field
fields = obj.inputs.get('fields', [])
for field in fields:
if field.get('internal') and field.get('id') == 'workload_identity_token':
# Make sure that the requesting user has access to the job template
job_template_id = backend_kwargs.get('job_template_id')
if job_template_id is None:
return Response({'detail': _('Job template ID is required.')}, status=status.HTTP_400_BAD_REQUEST)
job_template = models.JobTemplate.objects.get(id=int(job_template_id))
if not request.user.can_access(models.JobTemplate, 'read', job_template):
raise PermissionDenied(_(f'You do not have access to job template with id: {job_template.id}.'))

backend_kwargs['workload_identity_token'] = _get_workload_identity_token(job_template, backend_kwargs.get('jwt_aud'))
from jwt import decode as _jwt_decode

response_body['details'] = {
'sent_jwt_payload': _jwt_decode(backend_kwargs['workload_identity_token'], algorithms=["RS256"], options={"verify_signature": False}),
}

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.

This code is duplicated in both view implementations. Can we refactor this so we don't have two copies of the same thing?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Refactored into helper function.

return Response({'detail': _('Job template ID is required.')}, status=status.HTTP_400_BAD_REQUEST)
job_template = models.JobTemplate.objects.get(id=int(job_template_id))
if not request.user.can_access(models.JobTemplate, 'read', job_template):
raise PermissionDenied(_(f'You do not have access to job template with id: {job_template.id}.'))
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.

This use of _() (translation lookup) is invalid - we need to look up the format string without interpolating the id variable first:

  # Wrong — string interpolated before translation lookup                                                                                            
  raise PermissionDenied(_(f'You do not have access to job template with id: {job_template.id}.'))                                                   
                                                                                                                                                     
  # Correct                                                                                                                                          
  raise PermissionDenied(_('You do not have access to job template with id: %(id)s.') % {'id': job_template.id})                                     

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.

The bot called this out too. Please have a look.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Updated.

for field in fields:
if field.get('internal') and field.get('id') == 'workload_identity_token':
# Make sure that the requesting user has access to the job template
job_template_id = backend_kwargs.get('job_template_id')
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.

I relayed some earlier feedback from @matoval that if we leave the job_template_id in the backend_kwargs dict when we invoke the plugin's backend() function, it fails.

He recommended using .pop() instead of .get(), since pop will remove the value from the dictionary.

What was the resolution on this feedback? Did you test it and find this was not necessary?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Updated to .pop()

job_template_id = backend_kwargs.get('job_template_id')
if job_template_id is None:
return Response({'detail': _('Job template ID is required.')}, status=status.HTTP_400_BAD_REQUEST)
job_template = models.JobTemplate.objects.get(id=int(job_template_id))
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.

This has a couple more failure modes - how are we handling those?

  • Since we're not using a serializer to validate the incoming data, the incoming job_template_id might not be an integer, and we may get ValueError when calling int() on it. That should return 400 bad request
  • If the ID references a template that does not exist, that should either return 400 bad request or 404 not found, but I assume it returns a 500 right now.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Updated to add error handling.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
awx/api/views/__init__.py (1)

1746-1753: ⚠️ Potential issue | 🟠 Major

Same issues as CredentialExternalTest: unhandled exceptions and invalid translation pattern.

This code block has the same issues flagged for CredentialExternalTest:

  • Line 1749: Missing ValueError/TypeError handling for int() conversion
  • Line 1749: Missing DoesNotExist handling for JobTemplate.objects.get()
  • Line 1751: f-string inside _() breaks translations
  • Line 1753: RuntimeError from _get_workload_identity_token() not handled
  • Line 1746: Should use .pop() instead of .get()

Apply the same fixes as proposed for CredentialExternalTest.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@awx/api/views/__init__.py` around lines 1746 - 1753, Replace
backend_kwargs.get('job_template_id') with backend_kwargs.pop('job_template_id',
None) and validate it; wrap int(job_template_id) in try/except catching
ValueError/TypeError and return a 400 Response on bad int conversion. Wrap
models.JobTemplate.objects.get(id=...) in try/except catching
models.JobTemplate.DoesNotExist and return a 404/400 Response as appropriate.
Change the translation call to avoid f-strings, e.g. raise
PermissionDenied(_('You do not have access to job template with id: %s.') %
job_template.id). Finally, call _get_workload_identity_token(job_template,
backend_kwargs.get('jwt_aud')) inside a try/except that catches RuntimeError and
returns a 400/500 Response with the error message, and only set
backend_kwargs['workload_identity_token'] when it succeeds.
🧹 Nitpick comments (2)
awx/api/views/__init__.py (2)

1666-1666: Move import to top of file.

The jwt import should be at the top of the file with other imports, not nested inside the function. This was flagged in a previous review.

♻️ Proposed refactor

Add this import near the top of the file with other imports:

from jwt import decode as jwt_decode

Then update line 1669 to use jwt_decode directly:

-                    from jwt import decode as _jwt_decode
-
                     response_body['details'] = {
-                        'sent_jwt_payload': _jwt_decode(backend_kwargs['workload_identity_token'], algorithms=["RS256"], options={"verify_signature": False}),
+                        'sent_jwt_payload': jwt_decode(backend_kwargs['workload_identity_token'], algorithms=["RS256"], options={"verify_signature": False}),
                     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@awx/api/views/__init__.py` at line 1666, Move the nested import "from jwt
import decode as _jwt_decode" out of the function and place "from jwt import
decode as jwt_decode" with the module's other imports at the top of the file,
then update all local usages of _jwt_decode to jwt_decode (e.g., the call that
currently references _jwt_decode) so the decode function is imported once at
module load time and used consistently.

1740-1758: Refactor to eliminate code duplication.

This OIDC workload identity block (lines 1740-1758) is nearly identical to the one in CredentialExternalTest.post (lines 1652-1670). Consider extracting this into a shared helper function to avoid maintaining duplicate logic.

♻️ Proposed refactor

Extract to a helper function at module level:

def _prepare_workload_identity_for_test(request, backend_kwargs, fields):
    """
    Prepare workload identity token for credential test endpoints.
    
    Returns (response_body_details, error_response) where error_response is None on success.
    """
    if not flag_enabled('FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED'):
        return None, None
        
    for field in fields:
        if field.get('internal') and field.get('id') == 'workload_identity_token':
            job_template_id = backend_kwargs.pop('job_template_id', None)
            if job_template_id is None:
                return None, Response({'detail': _('Job template ID is required.')}, status=status.HTTP_400_BAD_REQUEST)
            
            try:
                job_template = models.JobTemplate.objects.get(id=int(job_template_id))
            except (ValueError, TypeError):
                return None, Response({'detail': _('Job template ID must be a valid integer.')}, status=status.HTTP_400_BAD_REQUEST)
            except models.JobTemplate.DoesNotExist:
                return None, Response({'detail': _('Job template not found.')}, status=status.HTTP_400_BAD_REQUEST)
                
            if not request.user.can_access(models.JobTemplate, 'read', job_template):
                raise PermissionDenied(_('You do not have access to job template with id: %(id)s.') % {'id': job_template.id})
            
            try:
                backend_kwargs['workload_identity_token'] = _get_workload_identity_token(job_template, backend_kwargs.get('jwt_aud'))
            except RuntimeError as exc:
                return None, Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
            
            return {
                'sent_jwt_payload': jwt_decode(backend_kwargs['workload_identity_token'], algorithms=["RS256"], options={"verify_signature": False}),
            }, None
    
    return None, None

Then call from both views:

details, error_response = _prepare_workload_identity_for_test(request, backend_kwargs, fields)
if error_response:
    return error_response
if details:
    response_body['details'] = details
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@awx/api/views/__init__.py` around lines 1740 - 1758, The OIDC workload
identity handling is duplicated; extract it into a shared module-level helper
(e.g., _prepare_workload_identity_for_test) that takes (request, backend_kwargs,
fields) and returns (details_dict, error_response) where error_response is a DRF
Response on validation/failure or None on success. Implement the helper to check
flag_enabled('FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED'), find the internal field
with id 'workload_identity_token', validate and coerce job_template_id (handle
missing/invalid int and JobTemplate.DoesNotExist), enforce
request.user.can_access(models.JobTemplate, 'read', job_template) (raise
PermissionDenied if unauthorized), call
_get_workload_identity_token(job_template, backend_kwargs.get('jwt_aud')) (catch
and convert runtime errors to a 400 Response), populate
backend_kwargs['workload_identity_token'], build sent_jwt_payload using
jwt.decode/options as before, and return (details, None); then call this helper
from both the current view block and CredentialExternalTest.post, returning
error_response if present and setting response_body['details'] when details is
returned.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@awx/api/views/__init__.py`:
- Around line 1658-1665: Validate and remove job_template_id from backend_kwargs
and handle all exceptions: attempt to convert
backend_kwargs.pop('job_template_id') to int inside a try/except and return a
Response with status 400 if ValueError; wrap
models.JobTemplate.objects.get(id=...) in try/except to catch
models.JobTemplate.DoesNotExist and return a 404 Response; replace the f-string
translation with a translation-safe call like _('You do not have access to job
template with id: %(id)s.') % {'id': job_template.id} when raising
PermissionDenied; call _get_workload_identity_token(job_template,
backend_kwargs.get('jwt_aud')) inside a try/except that catches RuntimeError and
returns a 400 Response with the error message; ensure you reference
job_template_id, backend_kwargs.pop, models.JobTemplate.objects.get,
request.user.can_access, _get_workload_identity_token, Response, and
PermissionDenied when making these changes.

---

Duplicate comments:
In `@awx/api/views/__init__.py`:
- Around line 1746-1753: Replace backend_kwargs.get('job_template_id') with
backend_kwargs.pop('job_template_id', None) and validate it; wrap
int(job_template_id) in try/except catching ValueError/TypeError and return a
400 Response on bad int conversion. Wrap models.JobTemplate.objects.get(id=...)
in try/except catching models.JobTemplate.DoesNotExist and return a 404/400
Response as appropriate. Change the translation call to avoid f-strings, e.g.
raise PermissionDenied(_('You do not have access to job template with id: %s.')
% job_template.id). Finally, call _get_workload_identity_token(job_template,
backend_kwargs.get('jwt_aud')) inside a try/except that catches RuntimeError and
returns a 400/500 Response with the error message, and only set
backend_kwargs['workload_identity_token'] when it succeeds.

---

Nitpick comments:
In `@awx/api/views/__init__.py`:
- Line 1666: Move the nested import "from jwt import decode as _jwt_decode" out
of the function and place "from jwt import decode as jwt_decode" with the
module's other imports at the top of the file, then update all local usages of
_jwt_decode to jwt_decode (e.g., the call that currently references _jwt_decode)
so the decode function is imported once at module load time and used
consistently.
- Around line 1740-1758: The OIDC workload identity handling is duplicated;
extract it into a shared module-level helper (e.g.,
_prepare_workload_identity_for_test) that takes (request, backend_kwargs,
fields) and returns (details_dict, error_response) where error_response is a DRF
Response on validation/failure or None on success. Implement the helper to check
flag_enabled('FEATURE_OIDC_WORKLOAD_IDENTITY_ENABLED'), find the internal field
with id 'workload_identity_token', validate and coerce job_template_id (handle
missing/invalid int and JobTemplate.DoesNotExist), enforce
request.user.can_access(models.JobTemplate, 'read', job_template) (raise
PermissionDenied if unauthorized), call
_get_workload_identity_token(job_template, backend_kwargs.get('jwt_aud')) (catch
and convert runtime errors to a 400 Response), populate
backend_kwargs['workload_identity_token'], build sent_jwt_payload using
jwt.decode/options as before, and return (details, None); then call this helper
from both the current view block and CredentialExternalTest.post, returning
error_response if present and setting response_body['details'] when details is
returned.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4f3230ae-5bd9-46d2-acef-e2d28721f82c

📥 Commits

Reviewing files that changed from the base of the PR and between 1201711 and 149773a.

📒 Files selected for processing (3)
  • awx/api/views/__init__.py
  • awx/main/tasks/jobs.py
  • awx/main/utils/workload_identity.py

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
awx/api/views/__init__.py (1)

1746-1789: ⚠️ Potential issue | 🔴 Critical

Same critical issues as CredentialExternalTest: KeyError and unhandled exceptions.

This code block has identical issues:

  1. Lines 1757, 1768, 1780, 1788: response_body['details']['error_message'] will raise KeyError.
  2. Line 1755: Use .pop() instead of .get().
  3. Line 1759: Missing exception handling for ValueError and DoesNotExist.
  4. Line 1761: f-string inside _() breaks translations.

As noted in past reviews, this logic is duplicated between both endpoints. Consider extracting the OIDC workload identity handling into a shared helper function to avoid maintaining two copies.

🐛 Proposed fix (same pattern as above)
-                    job_template_id = backend_kwargs.get('job_template_id')
+                    job_template_id = backend_kwargs.pop('job_template_id', None)
                     if job_template_id is None:
-                        response_body['details']['error_message'] = _('Job template ID is required.')
-                        return Response(response_body, status=status.HTTP_400_BAD_REQUEST)
-                    job_template = models.JobTemplate.objects.get(id=int(job_template_id))
+                        return Response({'details': {'error_message': _('Job template ID is required.')}}, status=status.HTTP_400_BAD_REQUEST)
+                    try:
+                        job_template = models.JobTemplate.objects.get(id=int(job_template_id))
+                    except (ValueError, TypeError):
+                        return Response({'details': {'error_message': _('Job template ID must be a valid integer.')}}, status=status.HTTP_400_BAD_REQUEST)
+                    except models.JobTemplate.DoesNotExist:
+                        return Response({'details': {'error_message': _('Job template not found.')}}, status=status.HTTP_400_BAD_REQUEST)
                     if not request.user.can_access(models.JobTemplate, 'read', job_template):
-                        raise PermissionDenied(_(f'You do not have access to job template with id: {job_template.id}.'))
+                        raise PermissionDenied(_('You do not have access to job template with id: %(id)s.') % {'id': job_template.id})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@awx/api/views/__init__.py` around lines 1746 - 1789, Initialize
response_body['details'] before writing into it and replace
backend_kwargs.get('job_template_id') with backend_kwargs.pop('job_template_id',
None); validate and convert the popped value inside a try/except catching
ValueError and models.JobTemplate.DoesNotExist and return a 400 with a clear
error message; when checking permission use a translation-safe string (e.g.
_('You do not have access to job template with id: %(id)s.') % {'id':
job_template.id}) instead of an f-string inside _(); wrap the
models.JobTemplate.objects.get(...) call and int conversion in the same
try/except and handle RuntimeError from _get_workload_identity_token as before;
finally consider extracting the OIDC handling into a helper (e.g.
_handle_workload_identity_token that uses _get_workload_identity_token and
_jwt_decode) and call it from both places to remove duplication and centralize
error handling involving response_body, backend_kwargs, and
request.user.can_access.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@awx/api/views/__init__.py`:
- Around line 1650-1699: response_body is used before 'details' exists and
backend_kwargs wrongly retains job_template_id and lacks error handling;
initialize response_body['details'] = {} early, replace
backend_kwargs.get('job_template_id') with backend_kwargs.pop('job_template_id',
None) when extracting job_template_id, add try/except around converting/getting
JobTemplate to catch ValueError and models.JobTemplate.DoesNotExist and set
response_body['details']['error_message'] accordingly, avoid using an f-string
inside _() for the permission message (use string concatenation or format with
_()), and ensure every except block (including the RuntimeError path from
_get_workload_identity_token and the generic Exception handler) writes into
response_body['details']['error_message'] only after confirming
response_body['details'] exists so the endpoint cannot raise KeyError before
returning.

---

Duplicate comments:
In `@awx/api/views/__init__.py`:
- Around line 1746-1789: Initialize response_body['details'] before writing into
it and replace backend_kwargs.get('job_template_id') with
backend_kwargs.pop('job_template_id', None); validate and convert the popped
value inside a try/except catching ValueError and
models.JobTemplate.DoesNotExist and return a 400 with a clear error message;
when checking permission use a translation-safe string (e.g. _('You do not have
access to job template with id: %(id)s.') % {'id': job_template.id}) instead of
an f-string inside _(); wrap the models.JobTemplate.objects.get(...) call and
int conversion in the same try/except and handle RuntimeError from
_get_workload_identity_token as before; finally consider extracting the OIDC
handling into a helper (e.g. _handle_workload_identity_token that uses
_get_workload_identity_token and _jwt_decode) and call it from both places to
remove duplication and centralize error handling involving response_body,
backend_kwargs, and request.user.can_access.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a53fb747-69a8-4e0a-b763-5628bdb6887a

📥 Commits

Reviewing files that changed from the base of the PR and between 149773a and 2b23b27.

📒 Files selected for processing (1)
  • awx/api/views/__init__.py

  - Fix KeyError when building error responses (8 occurrences)                                    - Use .pop() to prevent passing job_template_id/jwt_aud to backend
  - Add exception handling for ValueError and JobTemplate.DoesNotExist             - Fix Django translation pattern (use % formatting instead of f-strings)
  - Refactor duplicated OIDC logic into _handle_oidc_credential_test()
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.

4 participants