"""AWS Lambda handler for GitHub search and offboarding issue creation.""" import logging import os from typing import Any import sentry_sdk from config import GitHubConfig from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration from shared.schemas import ( OffboardUserWithCopilotEvent, OffboardUserWithCopilotResponse, SearchTextInOrgEvent, SearchTextInOrgResponse, ) from shared.schemas.common import Auth0ActionResult, TerraformHit from github_client.github_client import GitHubClient from github_client.utils import split_full_name_safely sentry_sdk.init( dsn=os.environ.get('SENTRY_DSN'), environment=os.environ.get('ENVIRONMENT'), integrations=[AwsLambdaIntegration()], ) LOGGER = logging.getLogger(__name__) logging.basicConfig( level=logging.INFO, format='%(asctime)s %(name)s %(levelname)s: %(message)s' ) def _format_hits(hits: list[TerraformHit]) -> str: """Render Terraform hits as a Markdown bullet list, or '_None_' if empty. :param hits: Terraform hits to render. :return: Markdown bullet list of linked file paths. """ return ( '\n'.join(f'- [{h.path}]({h.html_url}) in `{h.repository}`' for h in hits) or '_None_' ) def _format_auth0_results(results: list[Auth0ActionResult]) -> str: """Render Auth0 action results as a Markdown bullet list, or '_None_' if empty. Each line is self-describing about the operation performed (deleted / blocked), so the GitHub issue does not need to know the ticket type. :param results: Per-tenant Auth0 outcomes to render. :return: Markdown bullet list of tenant/user_id/operation lines. """ return ( '\n'.join( f'- `{r.tenant}` / `{r.user_id}` — **{r.operation}**' for r in results ) or '_None_' ) def handler(event: dict[str, Any], context: Any) -> dict[str, Any]: """Route Lambda event to the appropriate action handler. :param event: Lambda event dict with required 'action' and 'dry_run' keys :param context: Lambda context object (unused) :return: Action-specific response dict :raises ValueError: If action is unknown """ action = event.get('action') if action == 'search-text-in-org': return _handle_search_text_in_org(SearchTextInOrgEvent.model_validate(event)) if action == 'offboard-user-with-copilot': return _handle_offboard_user_with_copilot( OffboardUserWithCopilotEvent.model_validate(event) ) raise ValueError(f'Unknown action: {action!r}') def _handle_search_text_in_org(evt: SearchTextInOrgEvent) -> dict[str, Any]: """Search GitHub org Terraform files for a user's identifiers. Runs one *quoted* search per identifier form and merges the results, deduplicating by html_url. Searching each term separately (rather than one unquoted boolean-OR query) is required because the legacy code search API treats an unquoted OR query as a soup of bare tokens that matches nothing for these dotted/``@`` identifiers. Identifier forms and their confidence: - full email (e.g. ``jane.doe@example.com``) — high - email local-part (e.g. ``jane.doe``) — high - first-initial + last-name (e.g. ``jdoe``) — low; this form collides with unrelated resource/identifier names and yields many false positives, so its hits are tagged ``low`` and surfaced separately. A URL matched by any high-confidence term is reported as high even if a low-confidence term also matched it. Only the fields needed by Step Functions (path, html_url, repository, confidence) are returned, to stay well within the 256 KB payload limit. :param evt: Validated event containing 'email', 'full_name', 'org', and optional 'repo_filter', 'ticket_id' :return: {ticket_id, email, full_name, terraform_hits: [{path, html_url, repository, confidence}]} """ cfg = GitHubConfig() client = GitHubClient(cfg) email = evt.email username_from_email = evt.email.split('@')[0] if '@' in evt.email else evt.email first_name, last_name = split_full_name_safely(evt.full_name) # Preserve insertion order so high-confidence terms are searched first; # a later low-confidence match never downgrades an existing high hit. high_terms = [email] if username_from_email != email: high_terms.append(username_from_email) low_terms: list[str] = [] if first_name and last_name: low_terms.append(first_name[0].lower() + last_name.lower()) # html_url -> TerraformHit. Searched high-confidence-first; a hit already # recorded as high is never overwritten by a low-confidence match. hits_by_url: dict[str, TerraformHit] = {} for confidence, term in [('high', t) for t in high_terms] + [ ('low', t) for t in low_terms ]: results = client.search_text_occurrences_in_org(evt.org, term, evt.repo_filter) for item in results: html_url = item.get('html_url', '') if not html_url: continue existing = hits_by_url.get(html_url) if existing is not None and existing.confidence == 'high': # Never downgrade an already-high hit. continue repository = item.get('repository', {}).get('full_name', '') if not repository: LOGGER.warning( 'Skipping search hit with missing repository: %s', html_url ) continue hits_by_url[html_url] = TerraformHit( path=item.get('path', ''), html_url=html_url, repository=repository, confidence=confidence, ) terraform_hits = list(hits_by_url.values()) high_count = sum(1 for h in terraform_hits if h.confidence == 'high') LOGGER.info( 'Ticket %s: found %d terraform hit(s) (%d high / %d low confidence) ' 'for %s / %s', evt.ticket_id, len(terraform_hits), high_count, len(terraform_hits) - high_count, evt.email, evt.full_name, ) return SearchTextInOrgResponse( ticket_id=evt.ticket_id, email=evt.email, full_name=evt.full_name, terraform_hits=terraform_hits, ).model_dump() def _handle_offboard_user_with_copilot( evt: OffboardUserWithCopilotEvent, ) -> dict[str, Any]: """Create a GitHub issue per affected repository to track access cleanup. Groups terraform_hits by repository and creates one issue in each repo where references were found. Issues are unassigned — an engineer can pick them up or assign Copilot manually. The issue title and body are worded for the ``operation`` on the event: ``offboard`` (user is leaving — remove references) or ``suspend`` (user is returning — gate access, do not delete the underlying resources). :param evt: Validated event containing the cleanup details :return: {ticket_id, issue_urls, issue_numbers, dry_run} """ is_suspend = evt.operation == 'suspend' if evt.dry_run: LOGGER.info( '[Dry run] Would create %s issue(s) for %s (%s) ticket %s.', evt.operation, evt.full_name, evt.email, evt.ticket_id, ) return OffboardUserWithCopilotResponse( ticket_id=evt.ticket_id, dry_run=True, issue_urls=[], issue_numbers=[], ).model_dump() cfg = GitHubConfig() client = GitHubClient(cfg) if is_suspend: issue_title = f'Suspend access for {evt.full_name} ({evt.email})' else: issue_title = f'Offboard user {evt.full_name} ({evt.email})' auth0_lines = _format_auth0_results(evt.auth0_results) # Group hits by repository; skip issue creation entirely if there are no hits. hits_by_repo: dict[str, list[TerraformHit]] = {} for hit in evt.terraform_hits: hits_by_repo.setdefault(hit.repository, []).append(hit) if not hits_by_repo: LOGGER.info( 'No terraform hits for ticket %s; skipping issue creation.', evt.ticket_id, ) return OffboardUserWithCopilotResponse( ticket_id=evt.ticket_id, dry_run=False, issue_urls=[], issue_numbers=[], ).model_dump() target_repos = list(hits_by_repo.keys()) issue_urls: list[str] = [] issue_numbers: list[int] = [] for repo in target_repos: repo_hits = hits_by_repo.get(repo, []) high_hits = [h for h in repo_hits if h.confidence == 'high'] low_hits = [h for h in repo_hits if h.confidence == 'low'] terraform_section = ( f'### Terraform references found\n\n{_format_hits(high_hits)}\n\n' ) if low_hits: terraform_section += ( '### Possible matches (low confidence)\n\n' '_Matched on the initial+lastname form, which often collides ' 'with unrelated resource names. Verify before removing._\n\n' f'{_format_hits(low_hits)}\n\n' ) if is_suspend: heading = f'## Suspension: {evt.full_name}' date_label = 'Suspension start date' closing = ( 'This user is being **temporarily suspended** and is expected to ' 'return, so their access must be revoked **without deleting the ' 'underlying resources**. Please gate or disable access in the ' 'references below rather than removing them.' ) else: heading = f'## Offboarding: {evt.full_name}' date_label = 'Last working day' closing = 'Please review and clean up any remaining references.' issue_body = ( f'{heading}\n\n' f'**Jira ticket:** {evt.ticket_id} \n' f'**Email:** {evt.email} \n' f'**{date_label}:** {evt.last_working_day}\n\n' f'### Auth0 accounts actioned\n\n{auth0_lines}\n\n' f'{terraform_section}' f'{closing}' ) issue = client.create_issue(repo, issue_title, issue_body) html_url = issue.get('html_url', '') number = issue.get('number') if not html_url or not number: LOGGER.error( 'GitHub API returned malformed issue response for repo %s: ' 'html_url=%r, number=%r', repo, html_url, number, ) raise ValueError( f'GitHub API returned malformed issue response for repo {repo}' ) issue_urls.append(html_url) issue_numbers.append(number) LOGGER.info( 'Created %d %s issue(s) for ticket %s: %s', len(issue_urls), evt.operation, evt.ticket_id, ', '.join(issue_urls), ) return OffboardUserWithCopilotResponse( ticket_id=evt.ticket_id, dry_run=False, issue_urls=issue_urls, issue_numbers=issue_numbers, ).model_dump()