feat(jira): admin account replaces per-user tokens for all Jira/Tempo ops
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
- Single admin Jira account stored in system_configs (jira.admin_email, jira.admin_api_token) - Admin Tempo token in system_configs (tempo.admin_token) - get_admin_jira_client() + has_admin_jira_configured() replace per-user auth in all lifecycle hooks - lookup_user_jira_account_id() auto-discovers each user's Atlassian accountId by email on login - auto_log_test_worklog() now uses admin Tempo token and logs both red+blue team time - Settings > Jira: admin credentials fields + test buttons moved to admin Jira tab - Profile section simplified: jira_account_id shown read-only (auto-detected)
This commit is contained in:
@@ -172,6 +172,14 @@ def login(
|
||||
# Call uow.commit()
|
||||
uow.commit()
|
||||
|
||||
# Auto-discover the user's Jira account ID on every login (non-fatal)
|
||||
try:
|
||||
from app.services.jira_service import lookup_user_jira_account_id
|
||||
if lookup_user_jira_account_id(db, user):
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Call response.set_cookie()
|
||||
response.set_cookie(
|
||||
# Keyword argument: key
|
||||
|
||||
@@ -237,14 +237,14 @@ def _attach_evidence_to_jira(
|
||||
) -> None:
|
||||
"""Attach uploaded evidence to the linked Jira ticket (non-fatal)."""
|
||||
try:
|
||||
from app.services.jira_service import get_test_jira_key, get_user_jira_client, has_jira_configured
|
||||
if not has_jira_configured(actor, db):
|
||||
from app.services.jira_service import get_test_jira_key, get_admin_jira_client, has_admin_jira_configured
|
||||
if not has_admin_jira_configured(db):
|
||||
return
|
||||
issue_key = get_test_jira_key(db, test_id)
|
||||
if not issue_key:
|
||||
return
|
||||
import io
|
||||
jira = get_user_jira_client(actor, db)
|
||||
jira = get_admin_jira_client(db)
|
||||
buf = io.BytesIO(content)
|
||||
buf.name = file_name # requests uses .name as the multipart filename
|
||||
jira.add_attachment_object(issue_key, buf)
|
||||
|
||||
@@ -254,8 +254,9 @@ class JiraConfigOut(BaseModel):
|
||||
url: str
|
||||
project_key: str
|
||||
parent_ticket: str
|
||||
parent_ticket_standalone: str # parent for tests not in a campaign
|
||||
# Credentials are never returned
|
||||
parent_ticket_standalone: str
|
||||
admin_email_set: bool # whether admin email is configured (value never returned)
|
||||
tempo_admin_token_set: bool # whether admin Tempo token is configured
|
||||
|
||||
|
||||
class JiraConfigUpdate(BaseModel):
|
||||
@@ -264,6 +265,9 @@ class JiraConfigUpdate(BaseModel):
|
||||
project_key: Optional[str] = None
|
||||
parent_ticket: Optional[str] = None
|
||||
parent_ticket_standalone: Optional[str] = None
|
||||
admin_email: Optional[str] = None
|
||||
admin_api_token: Optional[str] = None
|
||||
tempo_admin_token: Optional[str] = None
|
||||
|
||||
|
||||
_JIRA_KEYS = {
|
||||
@@ -272,6 +276,9 @@ _JIRA_KEYS = {
|
||||
"project_key": "jira.project_key",
|
||||
"parent_ticket": "jira.parent_ticket",
|
||||
"parent_ticket_standalone": "jira.parent_ticket_standalone",
|
||||
"admin_email": "jira.admin_email",
|
||||
"admin_api_token": "jira.admin_api_token",
|
||||
"tempo_admin_token": "tempo.admin_token",
|
||||
}
|
||||
|
||||
|
||||
@@ -282,11 +289,12 @@ def get_jira_config(
|
||||
):
|
||||
"""Return current Jira configuration (merged DB + env).
|
||||
|
||||
**Requires** the ``admin`` role. Credentials are never returned.
|
||||
**Requires** the ``admin`` role. Credential values are never returned.
|
||||
"""
|
||||
from app.services.jira_service import (
|
||||
get_jira_url, get_jira_project_key, is_jira_enabled,
|
||||
get_jira_parent_ticket, get_jira_parent_ticket_standalone,
|
||||
get_admin_jira_email, _read_system_config,
|
||||
)
|
||||
|
||||
return JiraConfigOut(
|
||||
@@ -295,6 +303,8 @@ def get_jira_config(
|
||||
project_key=get_jira_project_key(db) or "",
|
||||
parent_ticket=get_jira_parent_ticket(db) or "",
|
||||
parent_ticket_standalone=get_jira_parent_ticket_standalone(db) or "",
|
||||
admin_email_set=bool(get_admin_jira_email(db)),
|
||||
tempo_admin_token_set=bool(_read_system_config(db, "tempo.admin_token")),
|
||||
)
|
||||
|
||||
|
||||
@@ -311,6 +321,7 @@ def update_jira_config(
|
||||
from app.services.jira_service import (
|
||||
upsert_jira_config, get_jira_url, get_jira_project_key, is_jira_enabled,
|
||||
get_jira_parent_ticket, get_jira_parent_ticket_standalone,
|
||||
get_admin_jira_email, _read_system_config,
|
||||
)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
@@ -326,6 +337,8 @@ def update_jira_config(
|
||||
project_key=get_jira_project_key(db) or "",
|
||||
parent_ticket=get_jira_parent_ticket(db) or "",
|
||||
parent_ticket_standalone=get_jira_parent_ticket_standalone(db) or "",
|
||||
admin_email_set=bool(get_admin_jira_email(db)),
|
||||
tempo_admin_token_set=bool(_read_system_config(db, "tempo.admin_token")),
|
||||
)
|
||||
|
||||
|
||||
@@ -334,70 +347,67 @@ def test_jira_connection(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Test the Jira connection using the current user's credentials.
|
||||
|
||||
Requires the admin to have a personal Jira API token configured in their
|
||||
profile settings.
|
||||
"""Test the Jira connection using the admin account credentials.
|
||||
|
||||
Always returns HTTP 200 with a ``status`` field so Cloudflare never
|
||||
replaces the response with its own error page.
|
||||
"""
|
||||
from app.services.jira_service import get_user_jira_client, get_jira_url, _effective_jira_email
|
||||
from app.services.jira_service import (
|
||||
get_admin_jira_client, get_jira_url, has_admin_jira_configured,
|
||||
get_admin_jira_email,
|
||||
)
|
||||
|
||||
jira_url = get_jira_url(db)
|
||||
if not jira_url:
|
||||
return {"status": "error", "message": "Jira URL is not configured. Set it in System Settings → Jira Configuration.", "jira_url": ""}
|
||||
return {"status": "error", "message": "Jira URL is not configured.", "jira_url": ""}
|
||||
|
||||
auth_email = _effective_jira_email(current_user)
|
||||
if not has_admin_jira_configured(db):
|
||||
return {
|
||||
"status": "error",
|
||||
"message": (
|
||||
"Admin Jira credentials not configured. "
|
||||
"Set the Admin Email and Admin API Token in the fields above."
|
||||
),
|
||||
"jira_url": jira_url,
|
||||
}
|
||||
|
||||
admin_email = get_admin_jira_email(db)
|
||||
|
||||
try:
|
||||
jira = get_user_jira_client(current_user, db)
|
||||
# 10-second timeout so we never block Cloudflare into a 524
|
||||
jira = get_admin_jira_client(db)
|
||||
try:
|
||||
jira._session.timeout = 10 # type: ignore[attr-defined]
|
||||
except Exception: # nosec B110
|
||||
pass
|
||||
myself = jira.myself()
|
||||
logger.info("Jira myself() response keys: %s", list(myself.keys()) if isinstance(myself, dict) else type(myself))
|
||||
# Use displayName → emailAddress → name → the auth email as fallback
|
||||
connected_as = (
|
||||
(myself.get("displayName") if isinstance(myself, dict) else None)
|
||||
or (myself.get("emailAddress") if isinstance(myself, dict) else None)
|
||||
or (myself.get("name") if isinstance(myself, dict) else None)
|
||||
or auth_email
|
||||
or admin_email
|
||||
or "authenticated"
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"connected_as": connected_as,
|
||||
"jira_url": jira_url,
|
||||
}
|
||||
return {"status": "ok", "connected_as": connected_as, "jira_url": jira_url}
|
||||
except Exception as exc:
|
||||
err = str(exc)
|
||||
# Always return HTTP 200 with status="error" so Cloudflare never
|
||||
# intercepts the response and the frontend always sees our message.
|
||||
if "Expecting value" in err or "line 1 column 1" in err:
|
||||
msg = (
|
||||
"Jira returned a non-JSON response. "
|
||||
"Verify the URL (e.g. https://company.atlassian.net), "
|
||||
"email and API token."
|
||||
"admin email and API token."
|
||||
)
|
||||
elif "401" in err or "Unauthorized" in err:
|
||||
msg = (
|
||||
"Authentication failed (401). "
|
||||
f"Check that the Atlassian email ({auth_email or 'not set'}) "
|
||||
"and API token are correct. The token must be an Atlassian API token "
|
||||
"(not your account password)."
|
||||
f"Check that the admin email ({admin_email or 'not set'}) "
|
||||
"and API token are correct."
|
||||
)
|
||||
elif "403" in err or "Forbidden" in err:
|
||||
msg = "Access denied (403). The token may not have permission for this Jira project."
|
||||
msg = "Access denied (403). The admin token may not have permission for this Jira project."
|
||||
elif "timed out" in err.lower() or "timeout" in err.lower():
|
||||
msg = "Connection timed out. Check that the Jira URL is reachable from the server."
|
||||
elif "not configured" in err.lower():
|
||||
msg = err
|
||||
else:
|
||||
msg = f"Jira connection failed: {err}"
|
||||
logger.warning("Jira test connection failed: %s", err)
|
||||
logger.warning("Admin Jira test connection failed: %s", err)
|
||||
return {"status": "error", "message": msg, "jira_url": jira_url}
|
||||
|
||||
|
||||
@@ -409,49 +419,32 @@ def test_jira_connection(
|
||||
@router.post("/tempo-test")
|
||||
def test_tempo_connection(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Test the current user's personal Tempo connection.
|
||||
"""Test the admin Tempo token connectivity.
|
||||
|
||||
Uses the Tempo API token stored in the user's profile (not a global token).
|
||||
Always returns HTTP 200 with a ``status`` field so Cloudflare never
|
||||
intercepts the response.
|
||||
"""
|
||||
from app.services.tempo_service import get_admin_tempo_token, get_admin_tempo_client
|
||||
|
||||
tempo_token = getattr(current_user, "tempo_api_token", None)
|
||||
if not tempo_token:
|
||||
admin_token = get_admin_tempo_token(db)
|
||||
if not admin_token:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": (
|
||||
"No Tempo API token configured. "
|
||||
"Add it in Settings → Profile → Tempo Integration."
|
||||
),
|
||||
}
|
||||
|
||||
jira_account_id = getattr(current_user, "jira_account_id", None)
|
||||
if not jira_account_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": (
|
||||
"No Jira Account ID configured. "
|
||||
"Set it in Settings → Profile → Jira Integration → Account ID."
|
||||
"Admin Tempo token not configured. "
|
||||
"Set it in System Settings → Jira Configuration → Admin Tempo Token."
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
from tempoapiclient import client_v4 as tempo_client
|
||||
tempo = tempo_client.Tempo(auth_token=tempo_token)
|
||||
# search_worklogs by authorId is the correct v4 method; use a tight
|
||||
# date range so we fetch almost nothing but still verify connectivity.
|
||||
worklogs = tempo.search_worklogs(
|
||||
dateFrom="2024-01-01",
|
||||
dateTo="2024-01-02",
|
||||
authorIds=[jira_account_id],
|
||||
)
|
||||
tempo = get_admin_tempo_client(db)
|
||||
worklogs = tempo.search_worklogs(dateFrom="2024-01-01", dateTo="2024-01-02")
|
||||
count = len(worklogs) if isinstance(worklogs, list) else "n/a"
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"Tempo connected successfully. Account ID: {jira_account_id}",
|
||||
"message": "Admin Tempo token connected successfully.",
|
||||
"worklogs_found": count,
|
||||
}
|
||||
except Exception as exc:
|
||||
@@ -459,23 +452,14 @@ def test_tempo_connection(
|
||||
if "401" in err or "Unauthorized" in err:
|
||||
msg = (
|
||||
"Authentication failed (401). "
|
||||
"Check your Tempo API token — obtain it at "
|
||||
"Check the admin Tempo API token — obtain it at "
|
||||
"Jira → Apps → Tempo → Settings → API Integration."
|
||||
)
|
||||
elif "403" in err or "Forbidden" in err:
|
||||
msg = "Access denied (403). The Tempo token lacks the required permissions."
|
||||
elif "404" in err or "not found" in err.lower():
|
||||
msg = (
|
||||
"Account ID not found (404). "
|
||||
f"The value '{jira_account_id}' may be wrong — see the instructions "
|
||||
"below to find your correct Atlassian Account ID."
|
||||
)
|
||||
msg = "Access denied (403). The admin Tempo token lacks the required permissions."
|
||||
else:
|
||||
msg = f"Tempo connection failed: {err}"
|
||||
logger.warning(
|
||||
"Tempo test connection failed for user %s (account_id=%s): %s",
|
||||
current_user.username, jira_account_id, err,
|
||||
)
|
||||
logger.warning("Admin Tempo test connection failed: %s", err)
|
||||
return {"status": "error", "message": msg}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user