Calculating Risk Scores and Updating a Fact Sheet Based on Survey Responses

Learn how to set up an event-triggered automation that calculates the aggregated risk score for a fact sheet based on survey responses and updates this field on a fact sheet.

Overview

Risk management is a critical aspect of any organization's IT operations. Understanding the potential risks associated with different areas of your IT landscape allows you to make informed decisions and implement appropriate measures to mitigate these risks. One efficient way to assess risk is through surveys, where stakeholders can provide their insights on various risk factors. To learn more about surveys in SAP LeanIX, see Surveys.

In this tutorial, you'll learn how to set up a trigger-based automation that assigns an aggregated risk score to a fact sheet based on survey responses gathered from stakeholders. The total risk score is computed within a calculated field in the survey. Once a survey run is completed for a fact sheet, a Python script initiates the following actions:

  • Maps the total risk score value from the survey to readable values: Low risk, Medium risk, or High risk
  • Updates custom fields associated with risk assessment on the target fact sheet

Prerequisites

Before you start, do the following:

  • Get admin access to your SAP LeanIX workspace.
  • Obtain an API token by creating a technical user with the admin role. For more information, see Technical Users.

This tutorial assumes you have basic knowledge of:

  • JavaScript
  • GraphQL
  • REST APIs
  • Webhooks
  • Python

Step 1: Create a Survey for Risk Assessment

To gather information from stakeholders, create a survey for risk assessment with answer options for each question. All questions in the survey are mandatory. Each answer is associated with a score, which is then used to calculate the total risk score. To learn how to create surveys, see Creating a Survey.

For the purpose of this tutorial, we use a survey with basic questions and answer options. You can use this survey as a template and adjust it to your organization's needs.

Question CategorySurvey QuestionLow Risk (1)Medium Risk (2)High Risk (3)
Security complianceAssess the compliance of this technology with our organization's security standards.Fully compliantPartially compliantNot compliant
Vendor supportRate the level of support provided by the vendor for this technology.Excellent supportAdequate supportPoor support
Data sensitivityAssess the sensitivity of the data handled by this technology.Low sensitivityMedium sensitivityHigh sensitivity
Operational impactAssess the potential impact on operations if this technology were to fail.Low impactMedium impactHigh impact

The aggregated risk score is computed in a calculated field within the survey, which uses a JavaScript code. This code employs a weighted scoring system to determine the final risk score. In our scenario, each question is assigned an equal weight of 0.25, meaning all questions contribute equally to the final score. To learn how to configure calculated fields, see Calculated Fields in Surveys.

Configuration of the Aggregated Risk Score calculated field:

var score1 = 0;
var score2 = 0;
var score3 = 0;
var score4 = 0;

// Distributed weight for questions 1 to 4. The total weight equals to 1.
const weight = 0.25; 

score1 += answers[0] === 'Fully compliant' ? 1 : 0;
score1 += answers[0] === 'Partially compliant' ? 2 : 0;
score1 += answers[0] === 'Not compliant' ? 3 : 0;

score2 += answers[1] === 'Excellent support' ? 1 : 0;
score2 += answers[1] === 'Adequate support' ? 2 : 0;
score2 += answers[1] === 'Poor support' ? 3 : 0;

score3 += answers[2] === 'Low sensitivity' ? 1 : 0;
score3 += answers[2] === 'Medium sensitivity' ? 2 : 0;
score3 += answers[2] === 'High sensitivity' ? 3 : 0;

score4 += answers[3] === 'Low impact' ? 1 : 0;
score4 += answers[3] === 'Medium impact' ? 2 : 0;
score4 += answers[3] === 'High impact' ? 3 : 0;

// Total and average score values
// totalValue = score1 + score2 + score3 + score4;
// avgValues = totalValue/4;

var scoreTotal = 0;
// Each score is multiplied by the weight
scoreTotal = score1 * weight + score2 * weight + score3 * weight + score4 * weight;
return scoreTotal.toFixed(2);

The following code snippet represents the survey for risk assessment in JSON format. You can import this survey to your workspace. To learn how to import surveys, see Importing and Exporting a Survey.

Survey for risk assessment in JSON format:

{
	"title": "Technology Risk Assessment",
	"questions": [
		{
			"id": "f01ec0ea-5137-75e6-73b0-53d8fbf02e27",
			"label": "Assess the compliance of this technology with our organization's security standards.",
			"type": "radio",
			"options": [
				{
					"id": "c224120d-a771-ffb8-cb00-1bd0c96cfaa3",
					"label": "Fully compliant"
				},
				{
					"id": "57d39808-566d-9397-4165-208e118152e4",
					"label": "Partially compliant"
				},
				{
					"id": "04e78da6-161a-2162-1d9e-527deb5987de",
					"label": "Not compliant"
				}
			],
			"powerfeature": false,
			"settings": {
				"version": 1,
				"isMandatory": true
			}
		},
		{
			"id": "b10cb0ee-4137-85e6-73b0-53d8fbf02e28",
			"label": "Rate the level of support provided by the vendor for this technology.",
			"type": "radio",
			"options": [
				{
					"id": "a124120d-a771-ffb8-cb00-1bd0c96cfaa4",
					"label": "Excellent support"
				},
				{
					"id": "67d39808-566d-9397-4165-208e118152e5",
					"label": "Adequate support"
				},
				{
					"id": "14e78da6-161a-2162-1d9e-527deb5987df",
					"label": "Poor support"
				}
			],
			"powerfeature": false,
			"settings": {
				"version": 1,
				"isMandatory": true
			}
		},
		{
			"id": "c20db0ef-6137-85e6-73b0-53d8fbf02e29",
			"label": "Assess the sensitivity of the data handled by this technology.",
			"type": "radio",
			"options": [
				{
					"id": "b214120d-a771-ffb8-cb00-1bd0c96cfaa5",
					"label": "Low sensitivity"
				},
				{
					"id": "77d39808-566d-9397-4165-208e118152e6",
					"label": "Medium sensitivity"
				},
				{
					"id": "24e78da6-161a-2162-1d9e-527deb5987dg",
					"label": "High sensitivity"
				}
			],
			"powerfeature": false,
			"settings": {
				"version": 1,
				"isMandatory": true
			}
		},
		{
			"id": "d30ec0ef-7137-85e6-73b0-53d8fbf02e30",
			"label": "Assess the potential impact on operations if this technology were to fail.",
			"type": "radio",
			"options": [
				{
					"id": "c214120d-a771-ffb8-cb00-1bd0c96cfaa6",
					"label": "Low impact"
				},
				{
					"id": "87d39808-566d-9397-4165-208e118152e7",
					"label": "Medium impact"
				},
				{
					"id": "34e78da6-161a-2162-1d9e-527deb5987dh",
					"label": "High impact"
				}
			],
			"powerfeature": false,
			"settings": {
				"version": 1,
				"isMandatory": true
			}
		},
		{
			"id": "f206fcd0-d174-e9d3-bfcf-9841650e6b7c",
			"label": "Aggregated Risk Score",
			"type": "calc",
			"powerfeature": true,
			"settings": {
				"version": 1,
				"formula": "var score1 = 0;\nvar score2 = 0;\nvar score3 = 0;\nvar score4 = 0;\n\n// Distributed weight for questions 1 to 4. The total weight equals to 1.\nconst weight = 0.25; \n\nscore1 += answers[0] === 'Fully compliant' ? 1 : 0;\nscore1 += answers[0] === 'Partially compliant' ? 2 : 0;\nscore1 += answers[0] === 'Not compliant' ? 3 : 0;\n\nscore2 += answers[1] === 'Excellent support' ? 1 : 0;\nscore2 += answers[1] === 'Adequate support' ? 2 : 0;\nscore2 += answers[1] === 'Poor support' ? 3 : 0;\n\nscore3 += answers[2] === 'Low sensitivity' ? 1 : 0;\nscore3 += answers[2] === 'Medium sensitivity' ? 2 : 0;\nscore3 += answers[2] === 'High sensitivity' ? 3 : 0;\n\nscore4 += answers[3] === 'Low impact' ? 1 : 0;\nscore4 += answers[3] === 'Medium impact' ? 2 : 0;\nscore4 += answers[3] === 'High impact' ? 3 : 0;\n\n// Total and average score values\n// totalValue = score1 + score2 + score3 + score4;\n// avgValues = totalValue/4;\n\nvar scoreTotal = 0;\n// Each score is multiplied by the weight\nscoreTotal = score1 * weight + score2 * weight + score3 * weight + score4 * weight;\nreturn scoreTotal.toFixed(2);"
			}
		}
	]
}

Step 2: Get the Survey ID

The automation is initiated only for a specific survey. To get the survey ID, navigate to the survey page in your workspace and copy the id from the URL.

Alternatively, you can get the survey id through the API. To do that, make a GET request to the following endpoint and save the id from the response. To learn how to get an access token, see Authentication to SAP LeanIX Services.

https://{SUBDOMAIN}.leanix.net/services/poll/v2/polls

Example request:

curl -X GET 
--header 'Accept: application/json' 
--header 'Authorization: Bearer {ACCESS_TOKEN}'
'https://{SUBDOMAIN}.leanix.net/services/poll/v2/polls?q=Technology%Risk%20Assessment'

Example response:

{
  "status": "OK",
  "type": "Poll",
  "errors": [],
  "total": 1,
  "data": [
    {
      "id": "411f278a-eb38-4b45-84b3-5abf648d0e49",
      "legacyId": null,
      "questionnaire": {
        "id": "f01ec0ea-5137-75e6-73b0-53d8fbf02e27",
        "questions": [...]
      },
      "title": "Technology Risk Assessment",
      ...
    }
  ]
}

Step 3: Create Custom Fields on Fact Sheets

Create custom fields for risk assessment on the fact sheet types for which you plan to assess risks, such as applications, IT components, data objects, or providers.

To create custom fields on a fact sheet, follow these steps:

  1. In the administration area, navigate to the Meta Model Configuration section.

  2. Select a fact sheet type for which you plan to run risk assessments. You land on the fact sheet configuration page.

  3. Create a dedicated subsection for risk assessment. To do that, select any section, click Add subsection, then create a name and label for the subsection in the right-side panel. Save the changes.

  4. To create custom fields within the subsection, select it, click Add field, then specify the field details in the right-side panel. Use the following table for reference.

    KeyField TypeDisplayed AsLabelValues and Labels
    riskAssessmentScoreStringTextAggregated Risk ScoreN/a
    technicalRiskLevelSingle SelectStatusRisk Level- lowRisk: Low risk
    - mediumRisk: Medium risk
    - highRisk: High risk
  5. Save the changes.

Custom fields are added to the fact sheet. If needed, add the same custom fields to more fact sheet types.

Step 4: Create and Deploy a Function

Create and deploy a function that assigns risk scores to fact sheets based on survey responses. You can deploy the function using your preferred method, such as through a Function as a Service (FaaS) provider. For instructions, refer to the documentation of your FaaS provider.

The script provided in this tutorial performs the following tasks:

  • Authenticates to SAP LeanIX services

  • Parses the webhook payload to retrieve the results of a completed survey run

  • Maps the aggregated risk score calculated within the survey to readable values, as shown in the following table:

    Risk LevelAggregated Risk Score RangeDescription
    Low risk1-1.6The technology is generally compliant, has strong vendor support, is not complex, and handles low sensitivity data.
    Medium risk1.7-2.3The technology may have some areas of non-compliance, weaker vendor support, moderate complexity, and handles medium sensitivity data.
    High risk2.4-3The technology is not compliant, has poor vendor support, is highly complex, and handles high sensitivity data.
  • Updates the following custom fields on the target fact sheet using the updateFactSheet GraphQL mutation:

    • riskAssessmentScore
    • technicalRiskLevel

Example code:

import json
import logging
import requests
import os

logging.basicConfig(level=logging.DEBUG)

# Request timeout
TIMEOUT = 20

# Poll ID is used to retrieve the Poll 

# API token and Subdomain are set as env variables.
# It is adviced not to hard code sensitive information in your code.
LEANIX_API_TOKEN = os.getenv('LEANIX_API_TOKEN')
LEANIX_SUBDOMAIN = os.getenv('LEANIX_SUBDOMAIN')
LEANIX_FQDN = f'https://{LEANIX_SUBDOMAIN}.leanix.net/services'

# OAuth2 URL to request the access token.
LEANIX_OAUTH2_URL = f'{LEANIX_FQDN}/mtm/v1/oauth2/token'

# GraphQL Endpoint
LEANIX_GRAPHQL_URL = f'{LEANIX_FQDN}/pathfinder/v1/graphql'

# Pass the desired Poll ID and Question ID as env variables, to be able to
# collect answers from different polls and runs.
POLL_ID = os.getenv('POLL_ID')
QUESTION_ID = os.getenv('QUESTION_ID')

# We want to trigger the survey based only for the `POLL_RESULT_FINALIZED` event.
EVENT_TYPE = 'POLL_RESULT_FINALIZED'

def obtain_access_token() -> str:
    """Obtains a LeanIX Access token using the Technical User generated
    API secret.
    Returns:
        str: The LeanIX Access Token
    """
    if not LEANIX_API_TOKEN:
        raise Exception('A valid token is required')
    response = requests.post(
        LEANIX_OAUTH2_URL,
        auth=('apitoken', LEANIX_API_TOKEN),
        data={'grant_type': 'client_credentials'},
        timeout=TIMEOUT
    )
    response.raise_for_status()
    return response.json().get('access_token')

def _request_headers() -> dict:
    """Generates the necessary headers for interacting with the LeanIX GraphQL API
    Returns:
        dict: A dictionary with the headers required for making the request
    """
    access_token = obtain_access_token()
    auth_header = f'Bearer {access_token}'
    # Provide the headers
    headers = {
        'Authorization': auth_header,
    }
    return headers

def risk_level_from_score(score: float) -> str|None:
    """Function to determine risk level based on the provided score.
    Args:
        score (float): The score based on which risk level is to be determined.
    Returns:
        str: Returns the string 'lowRisk' if the score is less than or equal to 1.6, 'mediumRisk' if score is more than 1.6 and less than or equal to 2.3, and 'highRisk' if score is more than 2.3.
        None: This is returned if the provided score does not meet any of the conditions for risk level categorization.
    """
    if score <= 1.6:
        return 'lowRisk'
    elif score >1.6 and score <= 2.3:
        return 'mediumRisk'
    elif score >2.3:
        return 'highRisk'

def execute_mutation(mutation: str, variables: dict) -> dict:
    """This function executes a mutation request on a GraphQL endpoint. 
    Args:
        mutation (str): The GraphQL mutation to be performed.
        variables (dict): The variables to be used in the mutation.
    Returns:
        dict: The JSON response from the GraphQL endpoint as a dictionary.
    Raises:
        HTTPError: If the request to the GraphQL endpoint results in an HTTP error.
    """
    headers = _request_headers()
    # In practice with the GraphQL API, mutations are technically not placed under 
    # a 'mutation' field. Instead, they are placed under the `query` attribute.
    response = requests.post(
        LEANIX_GRAPHQL_URL,
        headers=headers,
        json={'query': mutation, 'variables': variables},
        timeout=TIMEOUT
    )
    response.raise_for_status()
    return response.json()

def update_fact_sheet(
    fact_sheet_id: str, risk_assesment_score: float, risk_level: str
) -> dict:
    """ This function updates the fact sheet with the given risk assessment score and risk level.
    Args:
        fact_sheet_id (str): The ID of the fact sheet to be updated.
        risk_assesment_score (float): The risk assessment score used to update the fact sheet.
        risk_level (str): The risk level used to update the fact sheet.
    Returns:
        dict: The updated fact sheet as a dictionary.
    """
    mutation= """
        mutation RiskAssessment($id: ID!, $patches: [Patch]!, $validateOnly: Boolean) {
            updateFactSheet(id: $id, patches: $patches, validateOnly: $validateOnly) {
                factSheet {
                    ... on Application {
                        type
                        id
                        name
                        riskAssessmentScore
                        technicalRiskLevel
                    }
                }
            }
        }
    """
    variables = {
        'id': fact_sheet_id,
        'patches': [
            {
                'op': 'replace',
                'path': '/riskAssessmentScore',
                'value': f'{risk_assesment_score}'
            },
            {
                'op': 'replace',
                'path': '/technicalRiskLevel',
                'value': f'{risk_level}'
            }
        ],
        'validateOnly': False
    }
    logging.debug(f'Executing mutation: {mutation} with variables: {variables}')
    response = execute_mutation(mutation, variables)
    logging.debug(f'Response from GraphQL request was: {response}')
    return response

def parse_webhook(req: dict) -> requests.Response|None:
    """Function to parse a webhook request. It checks if the poll id matches the expected POLL_ID.
    If it matches, it searches for a fact sheet id and the relative poll answers.
    If these are found, a risk-level is computed, and the fact sheet is updated with the risk level.
    Args:
        req (dict): The webhook request payload.
    Returns:
        requests.Response: The response from the `update_fact_sheet` function call.
        None: If poll id does not match the `POLL_ID`, or if `fact_sheet_id` is not provided 
        or answers are not provided for the `QUESTION_ID`.
    """
    webhook_poll_id = req.get('pollRun',{}).get('poll', {}).get('id')
    if webhook_poll_id == POLL_ID:
        fact_sheet_id = req.get('pollResult', {}).get('factSheet', {}).get('id')
        if not fact_sheet_id:
            # Don't trigger if there is no fact sheet id in the payload.
            logging.error('No fact sheet id was provided, aborting run')
            return
        poll_answers = req.get('pollResult', {}).get('answers', [])
        for poll_answer in poll_answers:
            if poll_answer.get('questionId') == QUESTION_ID:
                if not poll_answer.get('answer', [])[0]:
                    logging.info(f'No answers were provided for poll: {POLL_ID}')
                    return
                else:
                    score = float(poll_answer.get('answer', [])[0])
                    risk_level = risk_level_from_score(score)
                    return update_fact_sheet(fact_sheet_id, score, risk_level)



def event_handler(req: dict) -> bool:
    """Handles webhook requests. Logs that a webhook request has been received.
    Checks if both `POLL_ID` and `QUESTION_ID` are non-empty. 
    If one or both are empty, an error is logged and an exception is thrown. 
    If both are present, it checks the request's poll result event type.
    If the event type is the one expected, it calls the parse_webhook function to handle the request.
    Args:
        req (dict): The webhook request as a dictionary to be handled.
    Returns:
        bool: Returns True if successfully executed, False otherwise.
    Raises:
        Exception: If no POLL_ID or QUESTION_ID was provided.
    """
    logging.info('Received webhook request')
    if not QUESTION_ID or not POLL_ID:
        logging.error('No POLL_ID or QUESTION_ID provided')
        raise Exception('No POLL_ID or QUESTION_ID was provided, aborting run')
    # Trigger the script only if the survey has been finalized
    event_type = req.get('pollResultEventType')
    if event_type == EVENT_TYPE:
        parse_webhook(req)

Environment Variables

For the script to work correctly, configure the environment variables listed in the following table.

These variables are crucial for handling sensitive data such as access tokens and IDs, as well as for controlling the behavioral aspects of the script. By configuring these variables, you make it adaptable for various run scenarios.

Environment VariableData TypeDescription
LEANIX_API_TOKENStringThe authentication token used for accessing the API. To learn how to get an access token, see Authentication to SAP LeanIX Services.
LEANIX_SUBDOMAINStringThe subdomain of your SAP LeanIX workspace. You can copy the subdomain value from the workspace URL.
POLL_IDStringThe ID of the survey to be run.
QUESTION_IDStringThe ID of a specific question within the survey.
EVENT_TYPEStringThe event type within a survey run. As the survey progresses, multiple events occur that reflect the state changes of each result. This variable ensures that the automation is only initiated when the webhook event matches the POLL_RESULT_FINALIZED event type, preventing triggers for other event types.

Step 5: Create a Webhook for Survey Responses

Create a PUSH webhook for the POLL_RESULT_UPDATED event. In the Target URL field, enter the function URL. For instructions, see Creating a Webhook.

Step 6: Start a Survey Run

To send the survey to stakeholders and start collecting responses, initiate a survey run. Run the survey only for the fact sheet types on which you've created custom fields. To learn how to start a survey run, see Sending Out a Survey.

The automation is triggered once a survey run is completed for a specific fact sheet.

📘

Note

Because each new survey response overwrites the previous responses, you may want to send the survey only to a user with the Accountable subscription type who carries overall accountability for the fact sheet.

You can run the survey as often as you need. The fact sheet fields are updated accordingly on each completed survey run.

Summary

In this tutorial, you learned how to implement a trigger-based automation to calculate an aggregated risk score for fact sheets based on survey responses.

You can use this tutorial as a starting point and modify the survey and script to your needs. For example, you can calculate the business criticality of applications, the technical fit of IT components, and more.