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:
@@ -45,17 +45,50 @@ from app.models.user import User
|
||||
# Assign logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Only red team execution time goes to Tempo.
|
||||
# Blue team evaluation time is tracked internally (worklogs table) for SLA
|
||||
# purposes but is NOT forwarded to Tempo — blue team has no Jira access.
|
||||
_TEMPO_ACTIVITY_TYPES = {"red_team_execution"}
|
||||
|
||||
|
||||
def has_tempo_configured(user) -> bool:
|
||||
"""Return True if *user* has a personal Tempo API token stored."""
|
||||
"""Return True if *user* has a personal Tempo API token stored (legacy)."""
|
||||
return bool(getattr(user, "tempo_api_token", None))
|
||||
|
||||
|
||||
def get_admin_tempo_token(db=None) -> Optional[str]:
|
||||
"""Return the admin Tempo API token from system_configs or env-var fallback."""
|
||||
if db is not None:
|
||||
try:
|
||||
from app.models.system_config import SystemConfig
|
||||
row = db.query(SystemConfig).filter(
|
||||
SystemConfig.key == "tempo.admin_token"
|
||||
).first()
|
||||
if row and row.value:
|
||||
return row.value
|
||||
except Exception: # nosec B110
|
||||
pass
|
||||
return getattr(settings, "TEMPO_API_TOKEN", None) or None
|
||||
|
||||
|
||||
def has_admin_tempo_configured(db=None) -> bool:
|
||||
"""Return True if the admin Tempo token is configured."""
|
||||
return bool(get_admin_tempo_token(db))
|
||||
|
||||
|
||||
def get_admin_tempo_client(db=None):
|
||||
"""Return a Tempo API v4 client authenticated with the admin token."""
|
||||
token = get_admin_tempo_token(db)
|
||||
if not token:
|
||||
raise InvalidOperationError(
|
||||
"Admin Tempo token not configured. "
|
||||
"Set it in System Settings → Jira Configuration."
|
||||
)
|
||||
try:
|
||||
from tempoapiclient import client_v4 as tempo_client
|
||||
base_url = _get_tempo_base_url(db)
|
||||
return tempo_client.Tempo(auth_token=token, base_url=base_url)
|
||||
except ImportError:
|
||||
raise InvalidOperationError(
|
||||
"tempo-api-python-client is not installed. "
|
||||
"Run: pip install tempo-api-python-client"
|
||||
)
|
||||
|
||||
|
||||
_TEMPO_DEFAULT_BASE_URL = "https://api.tempo.io/4"
|
||||
_TEMPO_EU_BASE_URL = "https://api.eu.tempo.io/4"
|
||||
|
||||
@@ -166,93 +199,56 @@ def get_tempo_client():
|
||||
)
|
||||
|
||||
|
||||
# Define function auto_log_test_worklog
|
||||
def auto_log_test_worklog(
|
||||
# Entry: db
|
||||
db: Session,
|
||||
# Entry: test
|
||||
test: Test,
|
||||
# Entry: user
|
||||
user: User,
|
||||
# Entry: activity_type
|
||||
activity_type: str,
|
||||
duration_seconds: Optional[int] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Log time to Tempo for the given test if conditions are met.
|
||||
|
||||
``duration_seconds``, when provided, is used as-is so the Tempo entry
|
||||
matches the Aegis worklog exactly. When omitted, the duration is computed
|
||||
from the test's phase start timestamp to ``updated_at`` (or now).
|
||||
Uses the admin Tempo token (preferred) so regular users need no personal
|
||||
token. Falls back to the user's personal token when no admin token is set.
|
||||
Both ``red_team_execution`` and ``blue_team_evaluation`` are logged.
|
||||
|
||||
Only ``red_team_execution`` activities are forwarded to Tempo.
|
||||
``blue_team_evaluation`` is tracked internally but not sent.
|
||||
``duration_seconds``, when provided, is used as-is. When omitted, the
|
||||
duration is computed from the test's phase start timestamp.
|
||||
|
||||
Returns the Tempo worklog response dict, or ``None`` if skipped.
|
||||
Completely non-fatal — errors are logged and swallowed.
|
||||
"""
|
||||
# Only whitelisted activity types go to Tempo
|
||||
if activity_type not in _TEMPO_ACTIVITY_TYPES:
|
||||
logger.debug(
|
||||
"Skipping Tempo sync for activity_type=%s (not in whitelist)", activity_type
|
||||
)
|
||||
# Only recognised activity types
|
||||
if activity_type not in ("red_team_execution", "blue_team_evaluation"):
|
||||
logger.debug("Skipping Tempo sync for activity_type=%s", activity_type)
|
||||
return None
|
||||
|
||||
# Global kill-switch
|
||||
if not settings.TEMPO_ENABLED:
|
||||
# Return None
|
||||
return None
|
||||
|
||||
# Compute duration from test timestamps when not supplied by the caller
|
||||
if duration_seconds is None:
|
||||
from datetime import datetime as _dt
|
||||
started = getattr(test, "red_started_at", None)
|
||||
if activity_type == "blue_team_evaluation":
|
||||
started = getattr(test, "blue_work_started_at", None) or getattr(test, "blue_started_at", None)
|
||||
else:
|
||||
started = getattr(test, "red_started_at", None)
|
||||
if started is None:
|
||||
logger.debug("No red_started_at on test %s; skipping Tempo worklog", test.id)
|
||||
logger.debug("No start timestamp on test %s for %s; skipping Tempo worklog", test.id, activity_type)
|
||||
return None
|
||||
ended = getattr(test, "updated_at", None) or _dt.utcnow()
|
||||
duration_seconds = max(int((ended - started).total_seconds()), 0)
|
||||
|
||||
if duration_seconds <= 0:
|
||||
logger.debug(
|
||||
"Skipping Tempo sync for test %s: duration=%ds", test.id, duration_seconds
|
||||
)
|
||||
logger.debug("Skipping Tempo sync for test %s: duration=%ds", test.id, duration_seconds)
|
||||
return None
|
||||
|
||||
# Tempo requires whole minutes. Always round UP to the nearest minute
|
||||
# and enforce a minimum of 60 seconds (1 minute).
|
||||
# 2 seconds → 60 s (1 min, minimum)
|
||||
# 3 min 20 s (200s) → 240 s (4 min, ceiling)
|
||||
# 5 min 0 s (300s) → 300 s (5 min, exact)
|
||||
# Round UP to the nearest whole minute; enforce ≥ 60 s
|
||||
import math
|
||||
duration_seconds = max(60, math.ceil(duration_seconds / 60) * 60)
|
||||
|
||||
# Per-user token required
|
||||
if not has_tempo_configured(user):
|
||||
logger.debug(
|
||||
"User %s has no Tempo token; skipping worklog for test %s",
|
||||
getattr(user, "username", user), test.id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Need a Jira link with a numeric issue ID
|
||||
link = (
|
||||
db.query(JiraLink)
|
||||
# Chain .filter() call
|
||||
.filter(
|
||||
JiraLink.entity_id == test.id,
|
||||
JiraLink.entity_type == JiraLinkEntityType.test,
|
||||
)
|
||||
# Chain .first() call
|
||||
.first()
|
||||
)
|
||||
|
||||
# Check: not link or not link.jira_issue_id
|
||||
if not link or not link.jira_issue_id:
|
||||
# Log debug: "No Jira link for test %s, skipping Tempo worklog"
|
||||
logger.debug("No Jira link for test %s, skipping Tempo worklog", test.id)
|
||||
# Return None
|
||||
return None
|
||||
|
||||
# user.jira_account_id is required for Tempo worklog attribution
|
||||
jira_account_id = (getattr(user, "jira_account_id", "") or "").strip()
|
||||
if not jira_account_id:
|
||||
logger.debug(
|
||||
@@ -261,37 +257,64 @@ def auto_log_test_worklog(
|
||||
)
|
||||
return None
|
||||
|
||||
# Attempt the following; catch errors below
|
||||
try:
|
||||
# Use the phase start timestamp as the worklog date so it matches when
|
||||
# work actually happened (not the submission timestamp).
|
||||
if activity_type == "blue_team_evaluation":
|
||||
work_date = (
|
||||
(test.blue_work_started_at or test.blue_started_at or test.created_at)
|
||||
.strftime("%Y-%m-%d")
|
||||
)
|
||||
description = f"[Aegis] Blue Team evaluation: {test.name}"
|
||||
else:
|
||||
work_date = (
|
||||
(test.red_started_at or getattr(test, "updated_at", None) or test.created_at)
|
||||
.strftime("%Y-%m-%d")
|
||||
)
|
||||
description = f"[Aegis] Red Team execution: {test.name}"
|
||||
result = log_worklog(
|
||||
user=user,
|
||||
jira_issue_id=int(link.jira_issue_id),
|
||||
author_account_id=jira_account_id,
|
||||
date=work_date,
|
||||
time_spent_seconds=duration_seconds,
|
||||
description=description,
|
||||
db=db,
|
||||
# Need a Jira link with a numeric issue ID
|
||||
link = (
|
||||
db.query(JiraLink)
|
||||
.filter(
|
||||
JiraLink.entity_id == test.id,
|
||||
JiraLink.entity_type == JiraLinkEntityType.test,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not link or not link.jira_issue_id:
|
||||
logger.debug("No Jira link for test %s, skipping Tempo worklog", test.id)
|
||||
return None
|
||||
|
||||
# Use the phase start timestamp for the worklog date
|
||||
if activity_type == "blue_team_evaluation":
|
||||
work_date = (
|
||||
(test.blue_work_started_at or test.blue_started_at or test.created_at)
|
||||
.strftime("%Y-%m-%d")
|
||||
)
|
||||
description = f"[Aegis] Blue Team evaluation: {test.name}"
|
||||
else:
|
||||
work_date = (
|
||||
(test.red_started_at or getattr(test, "updated_at", None) or test.created_at)
|
||||
.strftime("%Y-%m-%d")
|
||||
)
|
||||
description = f"[Aegis] Red Team execution: {test.name}"
|
||||
|
||||
try:
|
||||
# Admin token preferred; fall back to user's personal token for legacy setups
|
||||
admin_token = get_admin_tempo_token(db)
|
||||
if admin_token:
|
||||
from tempoapiclient import client_v4 as tempo_client
|
||||
base_url = _get_tempo_base_url(db)
|
||||
tempo = tempo_client.Tempo(auth_token=admin_token, base_url=base_url)
|
||||
elif has_tempo_configured(user):
|
||||
tempo = get_user_tempo_client(user, db=db)
|
||||
else:
|
||||
logger.debug(
|
||||
"No Tempo credentials available; skipping worklog for test %s", test.id
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
result = tempo.create_worklog(
|
||||
accountId=jira_account_id,
|
||||
issueId=int(link.jira_issue_id),
|
||||
dateFrom=work_date,
|
||||
timeSpentSeconds=duration_seconds,
|
||||
description=description,
|
||||
)
|
||||
except BaseException as exc:
|
||||
raise RuntimeError(f"Tempo API error: {exc}") from exc
|
||||
|
||||
logger.info(
|
||||
"Tempo worklog created for test %s by user %s: %ds on %s",
|
||||
test.id, getattr(user, "username", user), duration_seconds, work_date,
|
||||
)
|
||||
return result
|
||||
# Handle Exception
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Tempo worklog failed for test %s (user %s): %s",
|
||||
|
||||
Reference in New Issue
Block a user