diff --git a/charts/vc-authn-oidc/templates/deployment.yaml b/charts/vc-authn-oidc/templates/deployment.yaml index d22b91c0..b49b0933 100644 --- a/charts/vc-authn-oidc/templates/deployment.yaml +++ b/charts/vc-authn-oidc/templates/deployment.yaml @@ -39,6 +39,12 @@ spec: - name: auth-session-ttl configMap: name: {{ include "global.fullname" . }}-session-timeout + - name: custom-variable-substitution + configMap: + name: {{ include "global.fullname" . }}-variable-substitution-config + items: + - key: user_variable_substitution.py + path: user_variable_substitution.py containers: - name: {{ .Chart.Name }} securityContext: @@ -72,6 +78,8 @@ spec: value: {{ .Values.controller.presentationExpireTime | quote }} # - name: CONTROLLER_SESSION_TIMEOUT_CONFIG_FILE # value: /home/aries/sessiontimeout.json + - name: CONTROLLER_VARIABLE_SUBSTITUTION_OVERRIDE + value: /home/aries/user_variable_substitution.py - name: CONTROLLER_PRESENTATION_CLEANUP_TIME value: {{ .Values.controller.sessionTimeout.duration | quote }} - name: ACAPY_AGENT_URL @@ -133,6 +141,9 @@ spec: - name: auth-session-ttl mountPath: /home/aries/sessiontimeout.json subPath: sessiontimeout.json + - name: custom-variable-substitution + mountPath: /home/aries/user_variable_substitution.py + subPath: user_variable_substitution.py {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index c8781bc9..ac2d29bb 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -27,6 +27,7 @@ services: - CONTROLLER_PRESENTATION_EXPIRE_TIME=${CONTROLLER_PRESENTATION_EXPIRE_TIME} - CONTROLLER_PRESENTATION_CLEANUP_TIME=${CONTROLLER_PRESENTATION_CLEANUP_TIME} - CONTROLLER_SESSION_TIMEOUT_CONFIG_FILE=${CONTROLLER_SESSION_TIMEOUT_CONFIG_FILE} + - CONTROLLER_VARIABLE_SUBSTITUTION_OVERRIDE=${CONTROLLER_VARIABLE_SUBSTITUTION_OVERRIDE} - ACAPY_TENANCY=${AGENT_TENANT_MODE} - ACAPY_AGENT_URL=${AGENT_ENDPOINT} - ACAPY_ADMIN_URL=${AGENT_ADMIN_URL} @@ -45,6 +46,7 @@ services: volumes: - ../oidc-controller:/app:rw - ./oidc-controller/config/sessiontimeout.json:/home/aries/sessiontimeout.json + - ./oidc-controller/config/user_variable_substitution.py:/home/aries/user_variable_substitution.py networks: - vc_auth diff --git a/docker/manage b/docker/manage index 605bd308..7157f855 100755 --- a/docker/manage +++ b/docker/manage @@ -179,6 +179,9 @@ configureEnvironment() { # The path to the auth_session timeouts config file export CONTROLLER_SESSION_TIMEOUT_CONFIG_FILE="/home/aries/sessiontimeout.json" + # Extend Variable Substitutions + export CONTROLLER_VARIABLE_SUBSTITUTION_OVERRIDE="/home/aries/user_variable_substitution.py" + #controller app settings export INVITATION_LABEL=${INVITATION_LABEL:-"VC-AuthN"} export SET_NON_REVOKED="True" diff --git a/docker/oidc-controller/config/user_variable_substitution.py b/docker/oidc-controller/config/user_variable_substitution.py new file mode 100644 index 00000000..e69de29b diff --git a/docker/oidc-controller/config/user_variable_substitution_example.py b/docker/oidc-controller/config/user_variable_substitution_example.py new file mode 100644 index 00000000..d367fd57 --- /dev/null +++ b/docker/oidc-controller/config/user_variable_substitution_example.py @@ -0,0 +1,24 @@ +def sub_days_plus_one(days: str) -> int: + """Strings like '$sub_days_plus_one_4' will be replaced with the + final number incremented by one. In this case 5. + $sub_days_plus_one_4 -> 5 + $sub_days_plus_one_10 -> 11""" + return int(days) + 1 + + +variable_substitution_map.add_variable_substitution( + r"\$sub_days_plus_one_(\d+)", sub_days_plus_one +) + + +def sub_string_for_sure(_: str) -> str: + """Turns strings like $sub_string_for_sure_something into the string 'sure' + $sub_string_for_sure_something -> sure + $sub_string_for_sure_i -> sure + """ + return "sure" + + +variable_substitution_map.add_variable_substitution( + r"\$sub_string_for_sure_(.+)", sub_string_for_sure +) diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 0577e945..652f7f70 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -125,12 +125,42 @@ and at runtime when the user navigates to the QR code page, the proof would incl See the `oidc-controller\api\verificationConfigs\variableSubstitutions.py` file for implementations. #### Customizing variables +For user defined variable substitutions users can set the environment +variable to point at a python file defining new substitutions. -In `oidc-controller\api\verificationConfigs\variableSubstitutions.py` there are the built-in variables above. -For an advanced use case, if you require further customization, it could be possible to just replace that `variableSubstitutions.py` file -in a VC-AuthN implementation and the newly introduced variables would be run if they are included in a proof request configuration. +##### User Defined Variable API +In `oidc-controller\api\verificationConfigs\variableSubstitutions.py` +you will find the method `add_variable_substitution` which can be used +to modify the existing instance of `VariableSubstitutionMap` named +`variable_substitution_map`. -For regular variables they can be added to the `static_map`, mapping your variable name to a function doing the operation. -For "dynamic" ones alter `__contains__` and `__getitem__` to use a regex to parse and extract what is needed. +Takes a valid regular expression `pattern` and a function who's +arguments correspond with each regex group `substitution_function`. Each +captured regex group will be passed to the function as a `str`. -The file `oidc-controller\api\verificationConfigs\helpers.py` contains the function that recurses through the config doing any substitutions, so it would pick up whatever is available in `variableSubstitutions.py` \ No newline at end of file +Here is an example python file that would define a new variable +substitution `$today_plus_x_times_y` which will add X days multiplied +by Y days to today's date + +```python +from datetime import datetime, timedelta + +def today_plus_times(added_days: str, multiplied_days: str) -> int: + return int( + ((datetime.today() + timedelta(days=int(added_days))) * timedelta(days=int(multiplied_days))) + ).strftime("%Y%m%d")) + +# variable_substitution_map will already be defined in variableSubstitutions.py +variable_substitution_map.add_variable_substitution(r"\$today_plus_(\d+)_times_(\d+)", today_plus_times) +``` + +For an example of this python file see `docker/oidc-controller/config/user_variable_substitution_example.py` + +All that is necessary is the adding of substitution variables.These +changes will be applied by vc-authn during startup. The variable +`variable_substitution_map` **will already be defined**. + +After loading the python file during the service startup each new user +defined variable is logged for confirmation. Any failures to load +these changes will be logged. If no new definitions are found +indication of this will also be logged diff --git a/oidc-controller/api/core/config.py b/oidc-controller/api/core/config.py index e3b1f40d..76f0efd7 100644 --- a/oidc-controller/api/core/config.py +++ b/oidc-controller/api/core/config.py @@ -8,6 +8,7 @@ from pathlib import Path from pydantic_settings import BaseSettings from pydantic import ConfigDict +from typing import Any import structlog @@ -227,6 +228,9 @@ class GlobalConfig(BaseSettings): ) SET_NON_REVOKED: bool = strtobool(os.environ.get("SET_NON_REVOKED", True)) + CONTROLLER_VARIABLE_SUBSTITUTION_OVERRIDE: str | None = os.environ.get( + "CONTROLLER_VARIABLE_SUBSTITUTION_OVERRIDE" + ) model_config = ConfigDict(case_sensitive=True) diff --git a/oidc-controller/api/verificationConfigs/tests/test_variable_substitutions.py b/oidc-controller/api/verificationConfigs/tests/test_variable_substitutions.py index 3c29d8f3..1270ce6c 100644 --- a/oidc-controller/api/verificationConfigs/tests/test_variable_substitutions.py +++ b/oidc-controller/api/verificationConfigs/tests/test_variable_substitutions.py @@ -1,4 +1,5 @@ import time +from typing_extensions import Callable import pytest from datetime import datetime, timedelta from api.verificationConfigs.variableSubstitutions import VariableSubstitutionMap @@ -27,7 +28,17 @@ def test_get_threshold_years_date(): expected_date = ( datetime.today().replace(year=datetime.today().year - years).strftime("%Y%m%d") ) - assert vsm.get_threshold_years_date(years) == int(expected_date) + assert vsm.get_threshold_years_date(str(years)) == int(expected_date) + + +def test_user_defined_func(): + vsm = VariableSubstitutionMap() + func: Callable[[int], int] = lambda x, y: int(x) + int(y) + vsm.add_variable_substitution(r"\$years since (\d+) (\d+)", func) + days = 10 + years = 22 + assert f"$years since {years} {days}" in vsm + assert vsm[f"$years since {years} {days}"]() == years + days def test_contains_static_variable(): diff --git a/oidc-controller/api/verificationConfigs/variableSubstitutions.py b/oidc-controller/api/verificationConfigs/variableSubstitutions.py index a00efcb0..57fd863b 100644 --- a/oidc-controller/api/verificationConfigs/variableSubstitutions.py +++ b/oidc-controller/api/verificationConfigs/variableSubstitutions.py @@ -8,6 +8,15 @@ from datetime import datetime, timedelta import time import re +import copy + +import structlog +from typing_extensions import Callable +from ..core.config import settings + +logger: structlog.typing.FilteringBoundLogger = structlog.getLogger(__name__) + +SubstitutionFunction = Callable[..., int | str] class VariableSubstitutionMap: @@ -20,7 +29,25 @@ def __init__(self): "$tomorrow_int": self.get_tomorrow_date, } - def get_threshold_years_date(self, years: int) -> int: + self.user_static_map: dict[re.Pattern[str], SubstitutionFunction] = { + re.compile(r"\$threshold_years_(\d+)"): self.get_threshold_years_date + } + + def add_variable_substitution( + self, pattern: str, substitution_function: SubstitutionFunction + ): + """Takes a valid regular expression PATTERN and a function + who's arguments correspond with each regex group + SUBSTITUTION_FUNCTION. Each captured regex group will be + passed to the function as a str. + + Examples: + vsm.add_variable_substitution(r\"\\$years since (\\d+) (\\d+)\", lambda x, y: int(x) + int(y)) + vsm[f"$years since {10} {12}"] => 22 + """ + self.user_static_map[re.compile(pattern)] = substitution_function + + def get_threshold_years_date(self, years: str) -> int: """ Calculate the threshold date for a given number of years. @@ -30,7 +57,9 @@ def get_threshold_years_date(self, years: int) -> int: Returns: int: The current date minux X years in YYYYMMDD format. """ - threshold_date = datetime.today().replace(year=datetime.today().year - years) + threshold_date = datetime.today().replace( + year=datetime.today().year - int(years) + ) return int(threshold_date.strftime("%Y%m%d")) def get_now(self) -> int: @@ -63,16 +92,47 @@ def get_tomorrow_date(self) -> int: # For "dynamic" variables, use a regex to match the key and return a lambda function # So a proof request can use $threshold_years_X to get the years back for X years def __contains__(self, key: str) -> bool: - return key in self.static_map or re.match(r"\$threshold_years_(\d+)", key) + res = key in self.static_map + if not res: + for i in self.user_static_map.keys(): + if re.match(i, key): + return True + return res def __getitem__(self, key: str): if key in self.static_map: return self.static_map[key] - match = re.match(r"\$threshold_years_(\d+)", key) - if match: - return lambda: self.get_threshold_years_date(int(match.group(1))) + for i, j in self.user_static_map.items(): + if nmatch := re.match(i, key): + return lambda: j(*nmatch.groups()) raise KeyError(f"Key {key} not found in format_args_function_map") # Create an instance of the custom mapping class variable_substitution_map = VariableSubstitutionMap() + + +def apply_user_variables(): + try: + with open(settings.CONTROLLER_VARIABLE_SUBSTITUTION_OVERRIDE) as user_file: + code = user_file.read() + except TypeError: + logger.warning("No user defined variable substitutions file provided") + return None + except FileNotFoundError: + logger.warning("User defined variable substitutions file could not be found") + return None + else: + og_substitution_map = copy.copy(variable_substitution_map.user_static_map) + exec(code) + if len(variable_substitution_map.user_static_map) <= 1: + logger.info("No new user created variable substitution where found") + + for pattern, func in variable_substitution_map.user_static_map.items(): + if pattern not in og_substitution_map: + logger.info( + f'New user created variable substitution: The pattern "{pattern.pattern}" is now mapped to the function {func.__name__}' + ) + + +apply_user_variables()