diff --git a/README.md b/README.md index daff0e4f..31b3981b 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ The `service_parameters.json` file gets created when starting a service using `. "serviceId": "emulator", "treeAlgorithm": "CCF", "signatureAlgorithm": "ES256", + "insertPolicy": "*", "serviceCertificate": "-----BEGIN CERTIFICATE-----..." } ``` diff --git a/dev-requirements.txt b/dev-requirements.txt index ee22e660..4b601892 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,7 @@ pyOpenSSL pytest +jsonschema requests==2.31.0 requests-toolbelt==0.9 urllib3<2.0.0 +myst-parser diff --git a/docs/registration_policies.md b/docs/registration_policies.md new file mode 100644 index 00000000..2fc0e235 --- /dev/null +++ b/docs/registration_policies.md @@ -0,0 +1,183 @@ +# Registration Policies + +- References + - [5.2.2. Registration Policies](https://www.ietf.org/archive/id/draft-birkholz-scitt-architecture-02.html#name-registration-policies) + +## Simple decoupled file based policy engine + +The SCITT API emulator can deny entry based on presence of +`operation.policy.{insert,denied,failed}` files. Currently only for use with +`use_lro=True`. + +This is a simple way to enable evaluation of claims prior to submission by +arbitrary policy engines which watch the workspace (fanotify, inotify, etc.). + +[![asciicast-of-simple-decoupled-file-based-policy-engine](https://asciinema.org/a/572766.svg)](https://asciinema.org/a/572766) + +Start the server + +```console +$ rm -rf workspace/ +$ mkdir -p workspace/storage/operations +$ scitt-emulator server --workspace workspace/ --tree-alg CCF --use-lro +Service parameters: workspace/service_parameters.json +^C +``` + +Modification of config to non-`*` insert policy. Restart SCITT API emulator server after this. + +```console +$ echo "$(cat workspace/service_parameters.json)" \ + | jq '.insertPolicy = "allowlist.schema.json"' \ + | tee workspace/service_parameters.json.new \ + && mv workspace/service_parameters.json.new workspace/service_parameters.json +{ + "serviceId": "emulator", + "treeAlgorithm": "CCF", + "signatureAlgorithm": "ES256", + "serviceCertificate": "-----BEGIN CERTIFICATE-----", + "insertPolicy": "allowlist.schema.json" +} +``` + +Basic policy engine in two files + +**enforce_policy.py** + +```python +import os +import sys +import pathlib + +policy_reason = "" +if "POLICY_REASON_PATH" in os.environ: + policy_reason = pathlib.Path(os.environ["POLICY_REASON_PATH"]).read_text() + +cose_path = pathlib.Path(sys.argv[-1]) +policy_action_path = cose_path.with_suffix(".policy." + os.environ["POLICY_ACTION"].lower()) +policy_action_path.write_text(policy_reason) +``` + +Simple drop rule based on claim content allowlist. + +**allowlist.schema.json** + +```json +{ + "$id": "https://schema.example.com/scitt-allowlist.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "issuer": { + "type": "string", + "enum": [ + "did:web:example.org" + ] + } + } +} +``` + +**jsonschema_validator.py** + +```python +import os +import sys +import json +import pathlib +import traceback + +import cbor2 +import pycose +from jsonschema import validate, ValidationError +from pycose.messages import CoseMessage, Sign1Message + +from scitt_emulator.scitt import ClaimInvalidError, COSE_Headers_Issuer + +claim = sys.stdin.buffer.read() + +msg = CoseMessage.decode(claim) + +if pycose.headers.ContentType not in msg.phdr: + raise ClaimInvalidError("Claim does not have a content type header parameter") +if COSE_Headers_Issuer not in msg.phdr: + raise ClaimInvalidError("Claim does not have an issuer header parameter") + +if not msg.phdr[pycose.headers.ContentType].startswith("application/json"): + raise TypeError( + f"Claim content type does not start with application/json: {msg.phdr[pycose.headers.ContentType]!r}" + ) + +SCHEMA = json.loads(pathlib.Path(os.environ["SCHEMA_PATH"]).read_text()) + +try: + validate( + instance={ + "$schema": "https://schema.example.com/scitt-policy-engine-jsonschema.schema.json", + "issuer": msg.phdr[COSE_Headers_Issuer], + "claim": json.loads(msg.payload.decode()), + }, + schema=SCHEMA, + ) +except ValidationError as error: + print(str(error), file=sys.stderr) + sys.exit(1) +``` + +We'll create a small wrapper to serve in place of a more fully featured policy +engine. + +**policy_engine.sh** + +```bash +export SCHEMA_PATH="${1}" +CLAIM_PATH="${2}" + +echo ${CLAIM_PATH} + +(python3 jsonschema_validator.py < ${CLAIM_PATH} 2>error && POLICY_ACTION=insert python3 enforce_policy.py ${CLAIM_PATH}) || (python3 -c 'import sys, json; print(json.dumps({"type": "denied", "detail": sys.stdin.read()}))' < error > reason.json; POLICY_ACTION=denied POLICY_REASON_PATH=reason.json python3 enforce_policy.py ${CLAIM_PATH}) +``` + +Example running allowlist check and enforcement. + +```console +$ npm install -g nodemon +$ nodemon -e .cose --exec 'find workspace/storage/operations -name \*.cose -exec nohup sh -xe policy_engine.sh $(cat workspace/service_parameters.json | jq -r .insertPolicy) {} \;' +``` + +Also ensure you restart the server with the new config we edited. + +```console +$ scitt-emulator server --workspace workspace/ --tree-alg CCF --use-lro +``` + +Create claim from allowed issuer (`.org`) and from non-allowed (`.com`). + +```console +$ scitt-emulator client create-claim --issuer did:web:example.com --content-type application/json --payload '{"sun": "yellow"}' --out claim.cose +Claim written to claim.cose +$ scitt-emulator client submit-claim --claim claim.cose --out claim.receipt.cbor +Traceback (most recent call last): + File "/home/alice/.local/bin/scitt-emulator", line 33, in + sys.exit(load_entry_point('scitt-emulator', 'console_scripts', 'scitt-emulator')()) + File "/home/alice/Documents/python/scitt-api-emulator/scitt_emulator/cli.py", line 22, in main + args.func(args) + File "/home/alice/Documents/python/scitt-api-emulator/scitt_emulator/client.py", line 196, in + func=lambda args: submit_claim( + File "/home/alice/Documents/python/scitt-api-emulator/scitt_emulator/client.py", line 107, in submit_claim + raise_for_operation_status(operation) + File "/home/alice/Documents/python/scitt-api-emulator/scitt_emulator/client.py", line 43, in raise_for_operation_status + raise ClaimOperationError(operation) +scitt_emulator.client.ClaimOperationError: Operation error denied: 'did:web:example.com' is not one of ['did:web:example.org'] + +Failed validating 'enum' in schema['properties']['issuer']: + {'enum': ['did:web:example.org'], 'type': 'string'} + +On instance['issuer']: + 'did:web:example.com' + +$ scitt-emulator client create-claim --issuer did:web:example.org --content-type application/json --payload '{"sun": "yellow"}' --out claim.cose +Claim written to claim.cose +$ scitt-emulator client submit-claim --claim claim.cose --out claim.receipt.cbor +Claim registered with entry ID 1 +Receipt written to claim.receipt.cbor +``` diff --git a/environment.yml b/environment.yml index f4dc8bcd..d46121c8 100644 --- a/environment.yml +++ b/environment.yml @@ -34,3 +34,5 @@ dependencies: - rkvst-archivist==0.20.0 - six==1.16.0 - urllib3<2.0.0 + - myst-parser==1.0.0 + - jsonschema==4.17.3 diff --git a/scitt_emulator/client.py b/scitt_emulator/client.py index d7f5edd3..40645bda 100644 --- a/scitt_emulator/client.py +++ b/scitt_emulator/client.py @@ -17,6 +17,20 @@ HTTP_DEFAULT_RETRY_DELAY = 1 +class ClaimOperationError(Exception): + def __init__(self, operation): + self.operation = operation + + def __str__(self): + error_type = self.operation.get("error", {}).get( + "type", "error.type not present", + ) + error_detail = self.operation.get("error", {}).get( + "detail", "error.detail not present", + ) + return f"Operation error {error_type}: {error_detail}" + + def raise_for_status(response: httpx.Response): if response.is_success: return @@ -26,7 +40,7 @@ def raise_for_status(response: httpx.Response): def raise_for_operation_status(operation: dict): if operation["status"] != "failed": return - raise RuntimeError(f"Operation error: {operation['error']}") + raise ClaimOperationError(operation) class HttpClient: diff --git a/scitt_emulator/scitt.py b/scitt_emulator/scitt.py index 18469941..f33ca89f 100644 --- a/scitt_emulator/scitt.py +++ b/scitt_emulator/scitt.py @@ -4,6 +4,7 @@ from typing import Optional from abc import ABC, abstractmethod from pathlib import Path +import contextlib import time import json import uuid @@ -22,6 +23,10 @@ COSE_Headers_Tree_Alg = "tree_alg" COSE_Headers_Issued_At = "issued_at" +# permissive insert policy +MOST_PERMISSIVE_INSERT_POLICY = "*" +DEFAULT_INSERT_POLICY = MOST_PERMISSIVE_INSERT_POLICY + class ClaimInvalidError(Exception): pass @@ -35,6 +40,10 @@ class OperationNotFoundError(Exception): pass +class PolicyResultDecodeError(Exception): + pass + + class SCITTServiceEmulator(ABC): def __init__( self, service_parameters_path: Path, storage_path: Optional[Path] = None @@ -94,8 +103,14 @@ def get_claim(self, entry_id: str) -> bytes: return claim def submit_claim(self, claim: bytes, long_running=True) -> dict: + insert_policy = self.service_parameters.get("insertPolicy", DEFAULT_INSERT_POLICY) + if long_running: return self._create_operation(claim) + elif insert_policy != MOST_PERMISSIVE_INSERT_POLICY: + raise NotImplementedError( + f"non-* insertPolicy only works with long_running=True: {insert_policy!r}" + ) else: return self._create_entry(claim) @@ -142,11 +157,57 @@ def _create_operation(self, claim: bytes): return operation + def _sync_policy_result(self, operation: dict): + operation_id = operation["operationId"] + policy_insert_path = self.operations_path / f"{operation_id}.policy.insert" + policy_denied_path = self.operations_path / f"{operation_id}.policy.denied" + policy_failed_path = self.operations_path / f"{operation_id}.policy.failed" + insert_policy = self.service_parameters.get("insertPolicy", DEFAULT_INSERT_POLICY) + + policy_result = {"status": operation["status"]} + + if insert_policy == MOST_PERMISSIVE_INSERT_POLICY: + policy_result["status"] = "succeeded" + if policy_insert_path.exists(): + policy_result["status"] = "succeeded" + policy_insert_path.unlink() + if policy_failed_path.exists(): + policy_result["status"] = "failed" + if policy_failed_path.stat().st_size != 0: + try: + policy_result_error = json.loads(policy_failed_path.read_text()) + except Exception as error: + raise PolicyResultDecodeError(operation_id) from error + policy_result["error"] = policy_result_error + policy_failed_path.unlink() + if policy_denied_path.exists(): + policy_result["status"] = "denied" + if policy_denied_path.stat().st_size != 0: + try: + policy_result_error = json.loads(policy_denied_path.read_text()) + except Exception as error: + raise PolicyResultDecodeError(operation_id) from error + policy_result["error"] = policy_result_error + policy_denied_path.unlink() + + return policy_result + def _finish_operation(self, operation: dict): operation_id = operation["operationId"] operation_path = self.operations_path / f"{operation_id}.json" claim_src_path = self.operations_path / f"{operation_id}.cose" + policy_result = self._sync_policy_result(operation) + if policy_result["status"] == "running": + return operation + if policy_result["status"] != "succeeded": + operation["status"] = "failed" + if "error" in policy_result: + operation["error"] = policy_result["error"] + operation_path.unlink() + claim_src_path.unlink() + return operation + claim = claim_src_path.read_bytes() entry = self._create_entry(claim) claim_src_path.unlink() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 00000000..ea3d92d9 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,262 @@ +# Copyright (c) SCITT Authors +# Licensed under the MIT License. +import os +import sys +import time +import json +import copy +import types +import pathlib +import tempfile +import textwrap +import threading +import itertools +import subprocess +import contextlib +import unittest.mock +import pytest +import myst_parser.parsers.docutils_ +import docutils.nodes +import docutils.utils + +from scitt_emulator.client import ClaimOperationError + +from .test_cli import ( + Service, + content_type, + payload, + execute_cli, +) + + +repo_root = pathlib.Path(__file__).parents[1] +docs_dir = repo_root.joinpath("docs") +allowlisted_issuer = "did:web:example.org" +non_allowlisted_issuer = "did:web:example.com" +CLAIM_DENIED_ERROR = {"type": "denied", "detail": "content_address_of_reason"} +CLAIM_DENIED_ERROR_BLOCKED = { + "type": "denied", + "detail": textwrap.dedent( + """ + 'did:web:example.com' is not one of ['did:web:example.org'] + + Failed validating 'enum' in schema['properties']['issuer']: + {'enum': ['did:web:example.org'], 'type': 'string'} + + On instance['issuer']: + 'did:web:example.com' + """ + ).lstrip(), +} + + +class SimpleFileBasedPolicyEngine: + def __init__(self, config): + self.config = config + + def __enter__(self): + self.stop_event = threading.Event() + self.thread = threading.Thread( + name="policy", + target=self.poll_workspace, + args=[self.config, self.stop_event], + ) + self.thread.start() + return self + + def __exit__(self, *args): + self.stop_event.set() + self.thread.join() + + @staticmethod + def poll_workspace(config, stop_event): + operations_path = pathlib.Path(config["storage_path"], "operations") + command_jsonschema_validator = [ + sys.executable, + str(config["jsonschema_validator"].resolve()), + ] + command_enforce_policy = [ + sys.executable, + str(config["enforce_policy"].resolve()), + ] + + running = True + while running: + for cose_path in operations_path.glob("*.cose"): + denial = copy.deepcopy(CLAIM_DENIED_ERROR) + with open(cose_path, "rb") as stdin_fileobj: + env = { + **os.environ, + "SCHEMA_PATH": str(config["schema_path"].resolve()), + "PYTHONPATH": ":".join( + os.environ.get("PYTHONPATH", "").split(":") + + [str(pathlib.Path(__file__).parents[1].resolve())] + ), + } + exit_code = 0 + try: + subprocess.check_output( + command_jsonschema_validator, + stdin=stdin_fileobj, + stderr=subprocess.STDOUT, + env=env, + ) + except subprocess.CalledProcessError as error: + denial["detail"] = error.output.decode() + exit_code = error.returncode + # EXIT_FAILRUE from validator == MUST block + with tempfile.TemporaryDirectory() as tempdir: + policy_reason_path = pathlib.Path(tempdir, "reason.json") + policy_reason_path.write_text(json.dumps(denial)) + env = { + **os.environ, + "POLICY_REASON_PATH": str(policy_reason_path), + "POLICY_ACTION": { + 0: "insert", + }.get(exit_code, "denied"), + } + command = command_enforce_policy + [cose_path] + exit_code = subprocess.call(command, env=env) + time.sleep(0.1) + running = not stop_event.is_set() + +def docutils_recursively_extract_nodes(node, samples = None): + if samples is None: + samples = [] + if isinstance(node, list): + node = types.SimpleNamespace(children=node) + return samples + list(itertools.chain(*[ + [ + child, + *docutils_recursively_extract_nodes(child), + ] + for child in node.children + if hasattr(child, "children") + ])) + +def docutils_find_code_samples(nodes): + samples = {} + for i, node in enumerate(nodes): + # Look ahead for next literal allow with code sample. Pattern is: + # + # **strong.suffix** + # + # ```language + # content + # ```` + # TODO Gracefully handle expections to index out of bounds + if ( + isinstance(node, docutils.nodes.strong) + and isinstance(nodes[i + 3], docutils.nodes.literal_block) + ): + samples[node.astext()] = nodes[i + 3].astext() + return samples + +def test_docs_registration_policies(tmp_path): + workspace_path = tmp_path / "workspace" + + claim_path = tmp_path / "claim.cose" + receipt_path = tmp_path / "claim.receipt.cbor" + entry_id_path = tmp_path / "claim.entry_id.txt" + retrieved_claim_path = tmp_path / "claim.retrieved.cose" + + # Grab code samples from docs + # TODO Abstract into abitrary docs testing code + doc_path = docs_dir.joinpath("registration_policies.md") + markdown_parser = myst_parser.parsers.docutils_.Parser() + document = docutils.utils.new_document(str(doc_path.resolve())) + parsed = markdown_parser.parse(doc_path.read_text(), document) + nodes = docutils_recursively_extract_nodes(document) + for name, content in docutils_find_code_samples(nodes).items(): + tmp_path.joinpath(name).write_text(content) + + with Service( + { + "tree_alg": "CCF", + "workspace": workspace_path, + "error_rate": 0.1, + "use_lro": True, + } + ) as service, SimpleFileBasedPolicyEngine( + { + "storage_path": service.server.app.scitt_service.storage_path, + "enforce_policy": tmp_path.joinpath("enforce_policy.py"), + "jsonschema_validator": tmp_path.joinpath("jsonschema_validator.py"), + "schema_path": tmp_path.joinpath("allowlist.schema.json"), + } + ) as policy_engine: + # set the policy to enforce + service.server.app.scitt_service.service_parameters["insertPolicy"] = "external" + + # create denied claim + command = [ + "client", + "create-claim", + "--out", + claim_path, + "--issuer", + non_allowlisted_issuer, + "--content-type", + content_type, + "--payload", + payload, + ] + execute_cli(command) + assert os.path.exists(claim_path) + + # submit denied claim + command = [ + "client", + "submit-claim", + "--claim", + claim_path, + "--out", + receipt_path, + "--out-entry-id", + entry_id_path, + "--url", + service.url + ] + check_error = None + try: + execute_cli(command) + except ClaimOperationError as error: + check_error = error + assert check_error + assert "error" in check_error.operation + assert check_error.operation["error"] == CLAIM_DENIED_ERROR_BLOCKED + assert not os.path.exists(receipt_path) + assert not os.path.exists(entry_id_path) + + # create accepted claim + command = [ + "client", + "create-claim", + "--out", + claim_path, + "--issuer", + allowlisted_issuer, + "--content-type", + content_type, + "--payload", + payload, + ] + execute_cli(command) + assert os.path.exists(claim_path) + + # submit accepted claim + command = [ + "client", + "submit-claim", + "--claim", + claim_path, + "--out", + receipt_path, + "--out-entry-id", + entry_id_path, + "--url", + service.url + ] + execute_cli(command) + assert os.path.exists(receipt_path) + assert os.path.exists(entry_id_path)