From 18695197e8ab34f184579c75b5ef83654335f94f Mon Sep 17 00:00:00 2001 From: kitos Date: Wed, 24 Jun 2026 09:15:35 +0200 Subject: [PATCH] feat(jira): admin account replaces per-user tokens for all Jira/Tempo ops - 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) --- backend/app/routers/auth.py | 8 + backend/app/routers/evidence.py | 6 +- backend/app/routers/system.py | 124 +++--- backend/app/services/jira_service.py | 118 ++++- backend/app/services/tempo_service.py | 193 +++++---- frontend/src/api/settings.ts | 5 + frontend/src/pages/SettingsPage.tsx | 597 +++++++++++--------------- 7 files changed, 530 insertions(+), 521 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 4dd8391..8b4c37c 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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 diff --git a/backend/app/routers/evidence.py b/backend/app/routers/evidence.py index eb54181..00b020e 100644 --- a/backend/app/routers/evidence.py +++ b/backend/app/routers/evidence.py @@ -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) diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index 16aa1f4..9113d7e 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -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} diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index 1eafd24..f423250 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -188,10 +188,110 @@ def get_user_jira_client(user: User, db: Session): def has_jira_configured(user: User, db: Session) -> bool: - """Return True if *user* has everything needed to call Jira.""" + """Return True if *user* has everything needed to call Jira (legacy per-user check).""" return bool(get_jira_url(db) and _effective_jira_email(user) and user.jira_api_token) +# --------------------------------------------------------------------------- +# Admin Jira client (single account for all lifecycle hooks) +# --------------------------------------------------------------------------- + + +def get_admin_jira_email(db: Session) -> Optional[str]: + """Return the admin Jira email from system_configs.""" + return _read_system_config(db, "jira.admin_email") or None + + +def get_admin_jira_api_token(db: Session) -> Optional[str]: + """Return the admin Jira API token from system_configs.""" + return _read_system_config(db, "jira.admin_api_token") or None + + +def has_admin_jira_configured(db: Session) -> bool: + """Return True if the global Jira admin account is fully configured.""" + return bool( + is_jira_enabled(db) + and get_jira_url(db) + and get_admin_jira_email(db) + and get_admin_jira_api_token(db) + ) + + +def get_admin_jira_client(db: Session): + """Return a Jira client authenticated with the global admin credentials. + + All Aegis-to-Jira operations (issue creation, comments, transitions) + go through this single account. Users do not need personal Jira tokens. + """ + jira_url = get_jira_url(db) + if not jira_url: + raise InvalidOperationError("Jira URL is not configured.") + + admin_email = get_admin_jira_email(db) + admin_token = get_admin_jira_api_token(db) + if not admin_email or not admin_token: + raise InvalidOperationError( + "Admin Jira credentials not configured. " + "Set them in System Settings → Jira Configuration → Admin Account." + ) + + from atlassian import Jira + + return Jira( + url=jira_url.rstrip("/"), + username=admin_email, + password=admin_token, + cloud=True, + ) + + +def lookup_user_jira_account_id(db: Session, user: User) -> bool: + """Lookup *user*'s Atlassian account ID by email using the admin Jira client. + + Updates ``user.jira_account_id`` in-place when found or changed. + Returns ``True`` when the value was updated, ``False`` otherwise. + Non-fatal — all errors are logged at DEBUG level and swallowed. + """ + if not has_admin_jira_configured(db): + return False + + email = getattr(user, "email", None) + if not email: + return False + + try: + jira = get_admin_jira_client(db) + results = jira.user_find_by_user_string(query=email, maxResults=10) + + account_id: Optional[str] = None + for u in results or []: + if isinstance(u, dict) and u.get("emailAddress", "").lower() == email.lower(): + account_id = u.get("accountId") + break + + if not account_id: + logger.debug("No Jira user found for email %s", email) + return False + + current = getattr(user, "jira_account_id", None) + if account_id != current: + user.jira_account_id = account_id + db.flush() + logger.info( + "Auto-updated jira_account_id for %s: %s", user.username, account_id + ) + return True + + return False + except Exception as exc: + logger.debug( + "Could not lookup Jira account_id for %s: %s", + getattr(user, "username", "?"), + exc, + ) + return False + + # --------------------------------------------------------------------------- # Ticket content builders (inspired by the pentest-to-Jira script) # --------------------------------------------------------------------------- @@ -403,7 +503,7 @@ def auto_create_campaign_issue( Called once right after a campaign is committed to the database. The created ticket is stored as a JiraLink with entity_type=campaign. """ - if not has_jira_configured(actor, db): + if not has_admin_jira_configured(db): return None project_key = get_jira_project_key(db) @@ -417,7 +517,7 @@ def auto_create_campaign_issue( parent_ticket = get_jira_parent_ticket(db) try: - jira = get_user_jira_client(actor, db) + jira = get_admin_jira_client(db) fields: dict = { "project": {"key": project_key}, @@ -487,7 +587,7 @@ def auto_create_test_issue( instead of the system-configured parent (e.g. OFS-9107). Use this to nest test tickets under a campaign ticket. """ - if not has_jira_configured(actor, db): + if not has_admin_jira_configured(db): return None project_key = get_jira_project_key(db) @@ -502,7 +602,7 @@ def auto_create_test_issue( mitre_id = technique.mitre_id if technique else "N/A" try: - jira = get_user_jira_client(actor, db) + jira = get_admin_jira_client(db) # All tests — whether inside a campaign or standalone — are created # as Task. Campaign tests use the campaign Jira key as parent @@ -574,7 +674,7 @@ def push_test_event( Completely non-fatal — any Jira error is logged and swallowed so it never blocks the test workflow. """ - if not has_jira_configured(actor, db): + if not has_admin_jira_configured(db): return link = ( @@ -589,7 +689,7 @@ def push_test_event( return try: - jira = get_user_jira_client(actor, db) + jira = get_admin_jira_client(db) comment = _build_state_comment(test, new_state, actor, extra) jira.issue_add_comment(link.jira_issue_key, comment) @@ -650,7 +750,7 @@ def push_hold_event( Non-fatal — any Jira error is logged and swallowed. """ - if not has_jira_configured(actor, db): + if not has_admin_jira_configured(db): return link = ( @@ -665,7 +765,7 @@ def push_hold_event( return try: - jira = get_user_jira_client(actor, db) + jira = get_admin_jira_client(db) ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") if resuming: diff --git a/backend/app/services/tempo_service.py b/backend/app/services/tempo_service.py index 552bebb..244de8d 100644 --- a/backend/app/services/tempo_service.py +++ b/backend/app/services/tempo_service.py @@ -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", diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index aa5d18d..5a376d2 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -163,6 +163,8 @@ export interface JiraConfigOut { project_key: string; parent_ticket: string; parent_ticket_standalone: string; + admin_email_set: boolean; + tempo_admin_token_set: boolean; } export interface JiraConfigUpdate { @@ -171,6 +173,9 @@ export interface JiraConfigUpdate { project_key?: string; parent_ticket?: string; parent_ticket_standalone?: string; + admin_email?: string; + admin_api_token?: string; + tempo_admin_token?: string; } export async function getJiraConfig(): Promise { diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index e9378d3..7ed07cc 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -826,51 +826,102 @@ function NotificationSection() { // --------------------------------------------------------------------------- function ProfileSection() { - const qc = useQueryClient(); - const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null); - const { user } = useAuth(); - // Blue team roles have no Jira/Tempo access — hide those settings for them - const showJiraTempoSettings = !["blue_lead", "blue_tech"].includes(user?.role ?? ""); - const { data: me, isLoading } = useQuery({ queryKey: ["me-prefs"], queryFn: getMe, }); - const [jiraAccountId, setJiraAccountId] = useState(""); - const [jiraEmail, setJiraEmail] = useState(""); - const [jiraApiToken, setJiraApiToken] = useState(""); - const [showToken, setShowToken] = useState(false); - const [tempoApiToken, setTempoApiToken] = useState(""); + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Username

+

{me?.username}

+
+
+

Role

+

{me?.role?.replace("_", " ")}

+
+
+

Email

+

{me?.email ?? "—"}

+
+
+

Last Login

+

+ {me?.last_login + ? new Date(me.last_login).toLocaleString("en-US", { + timeZone: "UTC", + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + " UTC" + : "—"} +

+
+
+ +
+

Jira / Tempo

+

+ Your Atlassian account ID is detected automatically on login using the admin Jira account. No personal tokens required. +

+
+ Account ID: + {me?.jira_account_id ? ( + + {me.jira_account_id} + + ) : ( + + Not detected yet — log out and back in after admin configures Jira + + )} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Jira Admin Config Section (admin only) +// --------------------------------------------------------------------------- + +function JiraConfigSection() { + const qc = useQueryClient(); + const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null); + const [showAdminToken, setShowAdminToken] = useState(false); const [showTempoToken, setShowTempoToken] = useState(false); - const [tempoTestResult, setTempoTestResult] = useState(null); - const [tempoTestError, setTempoTestError] = useState(null); const [jiraTestResult, setJiraTestResult] = useState<{ connectedAs: string; url: string } | null>(null); const [jiraTestError, setJiraTestError] = useState(null); - const [dirty, setDirty] = useState(false); + const [tempoTestResult, setTempoTestResult] = useState(null); + const [tempoTestError, setTempoTestError] = useState(null); - // Initialise editable fields from server on first successful load - useEffect(() => { - if (me) { - setJiraAccountId(me.jira_account_id ?? ""); - setJiraEmail(me.jira_email ?? ""); - // Never pre-fill the token — we only know whether it is set, not its value - } - // Only run when `me` transitions from undefined → data (i.e., first load) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [!!me]); + const { data: cfg, isLoading } = useQuery({ + queryKey: ["jira-config"], + queryFn: getJiraConfig, + }); + + const [form, setForm] = useState({}); const saveMut = useMutation({ - mutationFn: updateMyPreferences, - onSuccess: (updatedMe) => { - // Update cache immediately with the response — no extra round-trip needed - qc.setQueryData(["me-prefs"], updatedMe); - setDirty(false); - setJiraApiToken(""); // clear token fields after save — they're persisted - setTempoApiToken(""); - setToast({ msg: "Profile settings saved", type: "success" }); + mutationFn: updateJiraConfig, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["jira-config"] }); + setForm({}); + setToast({ msg: "Jira configuration saved", type: "success" }); }, - onError: () => setToast({ msg: "Failed to save", type: "error" }), + onError: () => setToast({ msg: "Failed to save Jira configuration", type: "error" }), }); const jiraTestMut = useMutation({ @@ -907,322 +958,6 @@ function ProfileSection() { }, }); - const handleSave = () => { - const payload: Parameters[0] = { - jira_account_id: jiraAccountId || null, - jira_email: jiraEmail || null, - }; - // Only send tokens when the user has typed something new - // (empty field = "keep current token unchanged") - if (jiraApiToken.trim() !== "") { - payload.jira_api_token = jiraApiToken.trim(); - } - if (tempoApiToken.trim() !== "") { - payload.tempo_api_token = tempoApiToken.trim(); - } - saveMut.mutate(payload); - }; - - if (isLoading) { - return ( -
- -
- ); - } - - return ( - <> - {toast && ( - setToast(null)} /> - )} -
-
-
-

Username

-

{me?.username}

-
-
-

Role

-

{me?.role?.replace("_", " ")}

-
-
-

Email

-

{me?.email ?? "—"}

-
-
-

Last Login

-

- {me?.last_login - ? new Date(me.last_login).toLocaleString("en-US", { - timeZone: "UTC", - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }) + " UTC" - : "—"} -

-
-
- - {showJiraTempoSettings &&
-

Jira Integration (personal settings)

-

- Configure your personal Atlassian credentials for Jira integration. -

- -
- {/* Jira email */} -
- - { setJiraEmail(e.target.value); setDirty(true); }} - placeholder={me?.email ?? "your@company.com"} - className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" - /> -

- Email used to authenticate with Atlassian. - {me?.email && !me?.jira_email && ( - Currently using Aegis email: {me.email} - )} -

-
- - {/* API Token */} -
- -
- { - setJiraApiToken(e.target.value); - setDirty(true); - }} - placeholder={me?.jira_token_set ? "Leave blank to keep current token" : "Paste your Atlassian API token here"} - className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 pr-10 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" - /> - -
-
- {me?.jira_token_set ? ( - - - Token configured - - ) : ( - - - Not configured - - )} - - Create token at id.atlassian.com → - -
-
- - {/* Account ID */} -
- - { - setJiraAccountId(e.target.value); - setDirty(true); - }} - placeholder="e.g. 5abcdef1234567890abcdef1" - className="w-full max-w-sm rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" - /> -

- Your Atlassian account ID. Found in your Jira profile URL. -

-
-
- - {/* Test Jira connection */} -
- - {jiraTestResult && ( -
- - Connected as: {jiraTestResult.connectedAs} -
- )} - {jiraTestError && ( -
- - {jiraTestError} -
- )} -
-
} - - {/* ── Tempo Integration ─────────────────────────────────── */} - {showJiraTempoSettings &&
-

Tempo Integration (personal settings)

-

- Your personal Tempo API token logs work time on Jira tickets automatically. -

- -
- {/* Tempo API Token */} -
- -
- { - setTempoApiToken(e.target.value); - setDirty(true); - }} - placeholder={me?.tempo_token_set ? "Leave blank to keep current token" : "Paste your Tempo API token here"} - className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 pr-10 text-sm text-gray-200 placeholder-gray-600 focus:border-purple-500 focus:outline-none" - /> - -
-
- {me?.tempo_token_set ? ( - - - Token configured - - ) : ( - - - Not configured - - )} - - Get it at: Jira → Apps → Tempo → Settings → API Integration - -
-
- - {/* Test Tempo connection */} -
- - {tempoTestResult && ( -
- - {tempoTestResult} -
- )} - {tempoTestError && ( -
- - {tempoTestError} -
- )} -
- -
- -
-
-
} -
- - ); -} - -// --------------------------------------------------------------------------- -// Jira Admin Config Section (admin only) -// --------------------------------------------------------------------------- - -function JiraConfigSection() { - const qc = useQueryClient(); - const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null); - - const { data: cfg, isLoading } = useQuery({ - queryKey: ["jira-config"], - queryFn: getJiraConfig, - }); - - const [form, setForm] = useState({}); - const effective = { ...cfg, ...form }; - - const saveMut = useMutation({ - mutationFn: updateJiraConfig, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["jira-config"] }); - setForm({}); - setToast({ msg: "Jira configuration saved", type: "success" }); - }, - onError: () => setToast({ msg: "Failed to save Jira configuration", type: "error" }), - }); - if (isLoading) { return (
@@ -1278,9 +1013,7 @@ function JiraConfigSection() { placeholder="OFS-20795" className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" /> -

- Campaigns are created directly under this issue -

+

Campaigns are created directly under this issue

@@ -1293,12 +1026,110 @@ function JiraConfigSection() { className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" />

- Standalone tests (not in a campaign) are nested under this issue. Falls back to Campaign Parent if not set. + Standalone tests are nested here. Falls back to Campaign Parent if not set.

-
+ {/* ── Admin account (replaces per-user tokens) ───────────── */} +
+

Admin Jira Account

+

+ One admin account handles all Jira and Tempo operations. Regular users need zero configuration — their Atlassian account ID is auto-detected on login. +

+ +
+
+ + setForm((prev) => ({ ...prev, admin_email: e.target.value }))} + placeholder="jira-admin@company.com" + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" + /> +
+ {cfg?.admin_email_set ? ( + + Configured + + ) : ( + + Not configured + + )} + Leave blank to keep current +
+
+ +
+ +
+ setForm((prev) => ({ ...prev, admin_api_token: e.target.value }))} + placeholder={cfg?.admin_email_set ? "Leave blank to keep current token" : "Paste Atlassian API token here"} + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 pr-10 text-sm text-gray-200 placeholder-gray-600 focus:border-cyan-500 focus:outline-none" + /> + +
+

+ Create at{" "} + + id.atlassian.com → + +

+
+ +
+ +
+ setForm((prev) => ({ ...prev, tempo_admin_token: e.target.value }))} + placeholder={cfg?.tempo_admin_token_set ? "Leave blank to keep current token" : "Paste Tempo API token here"} + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 pr-10 text-sm text-gray-200 placeholder-gray-600 focus:border-purple-500 focus:outline-none" + /> + +
+
+ {cfg?.tempo_admin_token_set ? ( + + Configured + + ) : ( + + Not configured + + )} + + Jira → Apps → Tempo → Settings → API Integration + +
+
+
+
+ +
+ {/* ── Test connections ───────────────────────────────────── */} +
+
+

Test Jira connection

+ + {jiraTestResult && ( +
+ + Connected as: {jiraTestResult.connectedAs} +
+ )} + {jiraTestError && ( +
+ + {jiraTestError} +
+ )} +
+ +
+

Test Tempo connection

+ + {tempoTestResult && ( +
+ + {tempoTestResult} +
+ )} + {tempoTestError && ( +
+ + {tempoTestError} +
+ )} +
+
);