Repository: hyperledger/aries-cloudagent-python

Issue:
Allow to accept TAA using startup parameters
Currently it is not possible to automatically accept the TAA using startup parameters. It is possible if you have an interactive tty, but this is not always the case in server environments. If you know which ledger you're going to interact with, it would be possible to set the aml mechanism in the acapy startup parameters to be able to automate the complete startup process. 

With the new multi-ledger functionality we could even allow to set this per ledger, so you can handle it for all configured ledgers.

Maybe there are good reasons not to do this, so interested to hear you input


Discussion:
The TAA is only needed for writing to a ledger.  My assumption has been that a single instance of ACA-Py for issuing will only be writing to a single ledger, and so the current mechanism for handling the TAA were sufficient (e.g. not really impacted for multi-ledger).

That said, having a start-up parameter to say "accept-indy-taa" seems like a good idea.  I think I sort of assumed it was already there :-).

Resolving patch:
diff --git a/aries_cloudagent/config/argparse.py b/aries_cloudagent/config/argparse.py
--- a/aries_cloudagent/config/argparse.py
+++ b/aries_cloudagent/config/argparse.py
@@ -800,6 +800,18 @@ def add_arguments(self, parser: ArgumentParser):
                 " HyperLedger Indy ledgers."
             ),
         )
+        parser.add_argument(
+            "--accept-taa",
+            type=str,
+            nargs=2,
+            metavar=("<acceptance-mechanism>", "<taa-version>"),
+            env_var="ACAPY_ACCEPT_TAA",
+            help=(
+                "Specify the acceptance mechanism and taa version for which to accept"
+                " the transaction author agreement. If not provided, the TAA must"
+                " be accepted through the TTY or the admin API."
+            ),
+        )
 
     def get_settings(self, args: Namespace) -> dict:
         """Extract ledger settings."""
@@ -838,6 +850,9 @@ def get_settings(self, args: Namespace) -> dict:
                 settings["ledger.keepalive"] = args.ledger_keepalive
             if args.ledger_socks_proxy:
                 settings["ledger.socks_proxy"] = args.ledger_socks_proxy
+            if args.accept_taa:
+                settings["ledger.taa_acceptance_mechanism"] = args.accept_taa[0]
+                settings["ledger.taa_acceptance_version"] = args.accept_taa[1]
 
         return settings
 
diff --git a/aries_cloudagent/config/ledger.py b/aries_cloudagent/config/ledger.py
--- a/aries_cloudagent/config/ledger.py
+++ b/aries_cloudagent/config/ledger.py
@@ -4,6 +4,7 @@
 import logging
 import re
 import sys
+from typing import Optional
 import uuid
 
 import markdown
@@ -140,7 +141,7 @@ async def ledger_config(
                     not taa_accepted
                     or taa_info["taa_record"]["digest"] != taa_accepted["digest"]
                 ):
-                    if not await accept_taa(ledger, taa_info, provision):
+                    if not await accept_taa(ledger, profile, taa_info, provision):
                         return False
 
         # Publish endpoints if necessary - skipped if TAA is required but not accepted
@@ -162,13 +163,8 @@ async def ledger_config(
     return True
 
 
-async def accept_taa(ledger: BaseLedger, taa_info, provision: bool = False) -> bool:
-    """Perform TAA acceptance."""
-
-    if not sys.stdout.isatty():
-        LOGGER.warning("Cannot accept TAA without interactive terminal")
-        return False
-
+async def select_aml_tty(taa_info, provision: bool = False) -> Optional[str]:
+    """Select acceptance mechanism from AML."""
     mechanisms = taa_info["aml_record"]["aml"]
     allow_opts = OrderedDict(
         [
@@ -230,16 +226,62 @@ async def accept_taa(ledger: BaseLedger, taa_info, provision: bool = False) -> b
         try:
             opt = await prompt_toolkit.prompt(opts_text, async_=True)
         except EOFError:
-            return False
+            return None
         if not opt:
             opt = "1"
         opt = opt.strip()
         if opt in ("x", "X"):
-            return False
+            return None
         if opt in num_mechanisms:
             mechanism = num_mechanisms[opt]
             break
 
-    await ledger.accept_txn_author_agreement(taa_info["taa_record"], mechanism)
+    return mechanism
+
 
+async def accept_taa(
+    ledger: BaseLedger,
+    profile: Profile,
+    taa_info,
+    provision: bool = False,
+) -> bool:
+    """Perform TAA acceptance."""
+
+    mechanisms = taa_info["aml_record"]["aml"]
+    mechanism = None
+
+    taa_acceptance_mechanism = profile.settings.get("ledger.taa_acceptance_mechanism")
+    taa_acceptance_version = profile.settings.get("ledger.taa_acceptance_version")
+
+    # If configured, accept the TAA automatically
+    if taa_acceptance_mechanism:
+        taa_record_version = taa_info["taa_record"]["version"]
+        if taa_acceptance_version != taa_record_version:
+            raise LedgerError(
+                f"TAA version ({taa_record_version}) is different from TAA accept "
+                f"version ({taa_acceptance_version}) from configuration. Update the "
+                "TAA version in the config to accept the TAA."
+            )
+
+        if taa_acceptance_mechanism not in mechanisms:
+            raise LedgerError(
+                f"TAA acceptance mechanism '{taa_acceptance_mechanism}' is not a "
+                "valid acceptance mechanism. Valid mechanisms are: "
+                + str(list(mechanisms.keys()))
+            )
+
+        mechanism = taa_acceptance_mechanism
+    # If tty is available use it (allows to accept newer TAA than configured)
+    elif sys.stdout.isatty():
+        mechanism = await select_aml_tty(taa_info, provision)
+    else:
+        LOGGER.warning(
+            "Cannot accept TAA without interactive terminal or taa accept config"
+        )
+
+    if not mechanism:
+        return False
+
+    LOGGER.debug(f"Accepting the TAA using mechanism '{mechanism}'")
+    await ledger.accept_txn_author_agreement(taa_info["taa_record"], mechanism)
     return True


Test patch:
diff --git a/aries_cloudagent/config/tests/test_ledger.py b/aries_cloudagent/config/tests/test_ledger.py
--- a/aries_cloudagent/config/tests/test_ledger.py
+++ b/aries_cloudagent/config/tests/test_ledger.py
@@ -1,6 +1,6 @@
 from os import remove
 from tempfile import NamedTemporaryFile
-
+import pytest
 from asynctest import TestCase as AsyncTestCase, mock as async_mock
 
 from .. import argparse
@@ -643,14 +643,23 @@ async def test_load_multiple_genesis_transactions_from_config_io_x(self):
                 )
 
     @async_mock.patch("sys.stdout")
-    async def test_ledger_accept_taa_not_tty(self, mock_stdout):
+    async def test_ledger_accept_taa_not_tty_not_accept_config(self, mock_stdout):
         mock_stdout.isatty = async_mock.MagicMock(return_value=False)
+        mock_profile = InMemoryProfile.test_profile()
+
+        taa_info = {
+            "taa_record": {"version": "1.0", "text": "Agreement"},
+            "aml_record": {"aml": ["wallet_agreement", "on_file"]},
+        }
 
-        assert not await test_module.accept_taa(None, None, provision=False)
+        assert not await test_module.accept_taa(
+            None, mock_profile, taa_info, provision=False
+        )
 
     @async_mock.patch("sys.stdout")
-    async def test_ledger_accept_taa(self, mock_stdout):
+    async def test_ledger_accept_taa_tty(self, mock_stdout):
         mock_stdout.isatty = async_mock.MagicMock(return_value=True)
+        mock_profile = InMemoryProfile.test_profile()
 
         taa_info = {
             "taa_record": {"version": "1.0", "text": "Agreement"},
@@ -663,7 +672,9 @@ async def test_ledger_accept_taa(self, mock_stdout):
             test_module.prompt_toolkit, "prompt", async_mock.CoroutineMock()
         ) as mock_prompt:
             mock_prompt.side_effect = EOFError()
-            assert not await test_module.accept_taa(None, taa_info, provision=False)
+            assert not await test_module.accept_taa(
+                None, mock_profile, taa_info, provision=False
+            )
 
         with async_mock.patch.object(
             test_module, "use_asyncio_event_loop", async_mock.MagicMock()
@@ -671,7 +682,9 @@ async def test_ledger_accept_taa(self, mock_stdout):
             test_module.prompt_toolkit, "prompt", async_mock.CoroutineMock()
         ) as mock_prompt:
             mock_prompt.return_value = "x"
-            assert not await test_module.accept_taa(None, taa_info, provision=False)
+            assert not await test_module.accept_taa(
+                None, mock_profile, taa_info, provision=False
+            )
 
         with async_mock.patch.object(
             test_module, "use_asyncio_event_loop", async_mock.MagicMock()
@@ -682,7 +695,53 @@ async def test_ledger_accept_taa(self, mock_stdout):
                 accept_txn_author_agreement=async_mock.CoroutineMock()
             )
             mock_prompt.return_value = ""
-            assert await test_module.accept_taa(mock_ledger, taa_info, provision=False)
+            assert await test_module.accept_taa(
+                mock_ledger, mock_profile, taa_info, provision=False
+            )
+
+    async def test_ledger_accept_taa_tty(self):
+        taa_info = {
+            "taa_record": {"version": "1.0", "text": "Agreement"},
+            "aml_record": {"aml": {"wallet_agreement": "", "on_file": ""}},
+        }
+
+        # Incorrect version
+        with pytest.raises(LedgerError):
+            mock_profile = InMemoryProfile.test_profile(
+                {
+                    "ledger.taa_acceptance_mechanism": "wallet_agreement",
+                    "ledger.taa_acceptance_version": "1.5",
+                }
+            )
+            assert not await test_module.accept_taa(
+                None, mock_profile, taa_info, provision=False
+            )
+
+        # Incorrect mechanism
+        with pytest.raises(LedgerError):
+            mock_profile = InMemoryProfile.test_profile(
+                {
+                    "ledger.taa_acceptance_mechanism": "not_exist",
+                    "ledger.taa_acceptance_version": "1.0",
+                }
+            )
+            assert not await test_module.accept_taa(
+                None, mock_profile, taa_info, provision=False
+            )
+
+        # Valid
+        mock_profile = InMemoryProfile.test_profile(
+            {
+                "ledger.taa_acceptance_mechanism": "on_file",
+                "ledger.taa_acceptance_version": "1.0",
+            }
+        )
+        mock_ledger = async_mock.MagicMock(
+            accept_txn_author_agreement=async_mock.CoroutineMock()
+        )
+        assert await test_module.accept_taa(
+            mock_ledger, mock_profile, taa_info, provision=False
+        )
 
     async def test_ledger_config(self):
         """Test required argument parsing."""