Repository: oscal-compass/compliance-trestle (python)

Issue:
Support adding generic properties during SSP authoring
## Issue description / feature objectives

When a custom YAML header is used with ssp generate, it would be helpful to be able to specify extra properties at the implemented requirement and statement level.

An example use case for this would be adding `implementation-status` and `control-origination` properties to meet FedRAMP OSCAL SSP requirements without having to edit the JSON.

## Caveats / Assumptions
- This would support generic properties and there would be no special handling per framework (i.e. this will not map to specific OSCAL fields)
- The `x-trestle-add-props` logic for profile authoring could be leveraged

## Completion Criteria

- Similar to the profile markdown, add a comment section that can be uncommented to process custom properties.
- This must support adding namespace information for each property or adding applicable properties under a higher level namespace key in a nested fashion.
- This must support adding properties to implemented requirements and statements (not at the by-component level)





Pull request description:
feat: adds `x-trestle-add-props` to the YAML header in SSP markdown
## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

- [ ] Hot fix (emergency fix and release)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [X] New feature (non-breaking change which adds functionality)
- [X] Documentation (change which affects the documentation site)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Release (`develop` -> `main`)

## Quality assurance (all should be covered).

- [ ] My code follows the code style of this project.
- [X] Documentation for my change is up to date?
- [X] My PR meets testing requirements.
- [X] All new and existing tests passed.
- [X] All commits are signed-off.

## Summary

Reuses logic from profile markdown generation with `x-trestle-add-props` in SSP markdown.

Closes #1524 

## Key links:

- [Sonar coverage](https://sonarcloud.io/dashboard?id=compliance-trestle)

# Before you merge

- Ensure it is a 'squash commit' if not a release.
- Ensure CI is currently passing
- Check sonar. If you are working for a fork a maintainer will reach out, if required.


Patch:
diff --git a/docs/tutorials/ssp_profile_catalog_authoring/ssp_profile_catalog_authoring.md b/docs/tutorials/ssp_profile_catalog_authoring/ssp_profile_catalog_authoring.md
index 43a3b343..5679e173 100644
--- a/docs/tutorials/ssp_profile_catalog_authoring/ssp_profile_catalog_authoring.md
+++ b/docs/tutorials/ssp_profile_catalog_authoring/ssp_profile_catalog_authoring.md
@@ -929,6 +929,24 @@ x-trestle-global:
     title: comp prof aa
     href: trestle://profiles/comp_prof/profile.json
   sort-id: ac-01
+x-trestle-add-props:
+  # Add or modify control properties here
+  # Properties may be at the control or part level
+  # Add control level properties like this:
+  #   - name: ac1_new_prop
+  #     value: new property value
+  #
+  # Add properties to a statement part like this, where "b." is the label of the target statement part
+  #   - name: ac1_new_prop
+  #     value: new property value
+  #     smt-part: b.
+  #
+  - name: prop_with_ns
+    value: prop with ns
+    ns: https://my_new_namespace
+  - name: prop_with_no_ns
+    value: prop with no ns
+    ns: https://my_added_namespace
 ---
 
 # ac-1 - \[Access Control\] Policy and Procedures
@@ -1036,7 +1054,9 @@ lines or modify/remove the lines with `### ` in them, corresponding to system co
 
 If you edit the control markdown files you may run `ssp-generate` again and your edits will not be overwritten.  When writing out the markdown for a control, any existing markdown for that control will be read and the response text for each part will be re-inserted into the new markdown file.  If the new markdown has added parts the original responses will be placed correctly in the new file, but if any part is removed from the source control json file then any corresponding prose will be lost after the next `ssp-generate`.
 
-## `trestle author ssp-assemble`
+The `x-trestle-add-props` key of the yaml header allows the addition of properties to the implemented requirements under the SSP control implementation, comprising `name`, `value`, and optionally a namespace `ns` value. Similarly to the `x-trestle-add-props` in profile authoring, the properties may be added at the control level, or attached to a top level statement part by adding a value, `smt-part` with value `a.`, `b.` or any other label for one of the top level statement parts.
+
+`trestle author ssp-assemble`
 
 After manually edting the markdown and providing the responses for the control implementation requirements, the markdown can be assembled into a single json SSP file with:
 
@@ -1050,7 +1070,7 @@ As with all the `assemble` tools, you may optionally specify a `--name` for a co
 
 If you do not specify component-defintions during assembly, the markdown should not refer to any components other than `This System`.  Thus you may first generate markdown with `ssp-generate` and no component-definitions specified - and then you may assemble that ssp with `ssp-assemble` and no component-definitions specified - but only if there are no components other than `This System` referenced in the markdown.  You may add new component implementation details to the markdown later, but any new components must be defined in a component-defintion file, and that file must be specified when `ssp-assemble` is run.
 
-## Inheritance view
+`ssp inheritance view`
 
 The inheritance view is generated by setting the `--leveraged-ssp` flag with `trestle author ssp-generate`. It contains information relating to exported information such as inherited capabilities and customer responsibilities that can be used to populate the inheritance information in the assembled SSP. When used, a directory named "inheritance" is created within the markdown directory. This directory serves as a designated space for  mapping inherited capabilities and responsibilities onto components in the assemble SSP and authoring satisfied statements for responsibilities.
 
diff --git a/trestle/core/catalog/catalog_reader.py b/trestle/core/catalog/catalog_reader.py
index ed118d84..f887ea19 100644
--- a/trestle/core/catalog/catalog_reader.py
+++ b/trestle/core/catalog/catalog_reader.py
@@ -322,11 +322,37 @@ class CatalogReader():
         item.set_parameters = new_sp_list
         item.set_parameters.append(ossp.SetParameter(param_id=param_id, values=param_values))
 
+    @staticmethod
+    def _add_props_to_imp_req(
+        control_id: str,
+        part_id_map_by_label: Dict[str, Dict[str, str]],
+        yaml_header: Dict[str, Any],
+        imp_req: ossp.ImplementedRequirement
+    ) -> None:
+        """Add the props from the yaml header to the imp_req."""
+        control_part_id_map = part_id_map_by_label.get(control_id, {})
+        props, props_by_id = ControlReader.get_props_list(control_id, control_part_id_map, yaml_header)
+        # add the props at control level
+        if props:
+            imp_req.props = as_list(imp_req.props)
+            imp_req.props.extend(props)
+
+        # add the props at the part level
+        for label, part_id in control_part_id_map.items():
+            props = props_by_id.get(label, [])
+            if not props:
+                continue
+            for statement in as_list(imp_req.statements):
+                if statement.statement_id == part_id:
+                    statement.props = as_list(statement.props)
+                    statement.props.extend(props)
+
     @staticmethod
     def _update_ssp_with_md_header(
         ssp: ossp.SystemSecurityPlan,
         control_id: str,
         comp_dict: Dict[str, generic.GenericComponent],
+        part_label_to_id_map: Dict[str, Dict[str, str]],
         md_header: Dict[str, Dict[str, str]]
     ) -> None:
         """Update the ssp with info from the header of an ssp control markdown file."""
@@ -346,6 +372,8 @@ class CatalogReader():
             if const.SSP_VALUES in param_dict:
                 CatalogReader._add_set_params_to_item(param_dict, imp_req, param_id)
 
+        CatalogReader._add_props_to_imp_req(control_id, part_label_to_id_map, md_header, imp_req)
+
     @staticmethod
     def read_ssp_md_content(
         md_path: pathlib.Path,
@@ -398,4 +426,4 @@ class CatalogReader():
                     CatalogReader._update_ssp_with_comp_info(
                         ssp, control_id, comp_dict[comp_name], comp_info_dict, part_id_map_by_label
                     )
-                CatalogReader._update_ssp_with_md_header(ssp, control_id, comp_dict, md_header)
+                CatalogReader._update_ssp_with_md_header(ssp, control_id, comp_dict, part_id_map_by_label, md_header)
diff --git a/trestle/core/control_reader.py b/trestle/core/control_reader.py
index 571ad480..c1881c48 100644
--- a/trestle/core/control_reader.py
+++ b/trestle/core/control_reader.py
@@ -325,8 +325,8 @@ class ControlReader:
         return sort_id, imp_req
 
     @staticmethod
-    def _get_props_list(control_id: str, label_map: Dict[str, str],
-                        yaml_header: Dict[str, Any]) -> Tuple[List[common.Property], Dict[str, List[common.Property]]]:
+    def get_props_list(control_id: str, label_map: Dict[str, str],
+                       yaml_header: Dict[str, Any]) -> Tuple[List[common.Property], Dict[str, List[common.Property]]]:
         """Get the list of props in the yaml header of this control as separate lists with and without by_id."""
         prop_list = yaml_header.get(const.TRESTLE_ADD_PROPS_TAG, [])
         props = []
@@ -388,7 +388,7 @@ class ControlReader:
         if header_params:
             param_dict.update(header_params)
 
-        props, props_by_id = ControlReader._get_props_list(control_id, part_label_to_id_map, yaml_header)
+        props, props_by_id = ControlReader.get_props_list(control_id, part_label_to_id_map, yaml_header)
 
         # When adding props without by_id it can either be starting or ending and we default to ending
         # This is the default behavior as described for implicit binding in
diff --git a/trestle/core/control_writer.py b/trestle/core/control_writer.py
index a6e95cb7..8d9fbae3 100644
--- a/trestle/core/control_writer.py
+++ b/trestle/core/control_writer.py
@@ -487,6 +487,9 @@ class ControlWriter():
             elif context.purpose == ContextPurpose.SSP:
                 header_comment_dict[const.SET_PARAMS_TAG] = const.YAML_SSP_VALUES_COMMENT
                 header_comment_dict[const.COMP_DEF_RULES_PARAM_VALS_TAG] = const.YAML_RULE_PARAM_VALUES_SSP_COMMENT
+                # The add props information to SSP only. It is added to the profile later.
+                if const.TRESTLE_ADD_PROPS_TAG not in md_header:
+                    md_header[const.TRESTLE_ADD_PROPS_TAG] = []
             elif context.purpose == ContextPurpose.COMPONENT:
                 header_comment_dict[const.COMP_DEF_RULES_PARAM_VALS_TAG
                                     ] = const.YAML_RULE_PARAM_VALUES_COMPONENT_COMMENT


Test patch:
diff --git a/tests/trestle/core/commands/author/ssp_test.py b/tests/trestle/core/commands/author/ssp_test.py
index 8f9eaf55..b2afbfc9 100644
--- a/tests/trestle/core/commands/author/ssp_test.py
+++ b/tests/trestle/core/commands/author/ssp_test.py
@@ -15,6 +15,7 @@
 
 import argparse
 import pathlib
+from typing import Dict, List
 
 from _pytest.monkeypatch import MonkeyPatch
 
@@ -1113,3 +1114,57 @@ def test_ssp_gen_throw_exception_for_rep_comps(tmp_trestle_dir: pathlib.Path, mo
     # first create the markdown
     ssp_gen = SSPGenerate()
     assert ssp_gen._run(gen_args) == 1
+
+
+def test_ssp_gen_and_assemble_add_props(tmp_trestle_dir: pathlib.Path) -> None:
+    """Test ssp generate and assemble with additional properties processing."""
+    gen_args, _ = setup_for_ssp(tmp_trestle_dir, prof_name, ssp_name)
+    gen_args.yaml_header = None
+    ssp_cmd = SSPGenerate()
+
+    assert ssp_cmd._run(gen_args) == 0
+
+    md_path = tmp_trestle_dir / ssp_name / 'ac' / 'ac-1.md'
+    assert md_path.exists()
+
+    md_api = MarkdownAPI()
+    header, tree = md_api.processor.process_markdown(md_path)
+
+    # Create key in header for add props for now
+    ac_1_properties: Dict[str, str] = {
+        'name': 'prop_with_ns', 'value': 'prop with ns', 'ns': 'https://my_new_namespace'
+    }
+    ac_1_smt_properties: Dict[str, str] = {'name': 'smt_prop', 'value': 'smt prop', 'smt-part': 'a.'}
+    # Verify the add props header value is present
+    properties: List[Dict[str, str]] = header.get('x-trestle-add-props')
+    properties.extend([ac_1_properties, ac_1_smt_properties])
+
+    md_api.write_markdown_with_header(md_path, header, tree.content.raw_text)
+
+    args_compdefs = gen_args.compdefs
+    # now assemble controls into json ssp
+    ssp_assemble = SSPAssemble()
+    args = argparse.Namespace(
+        trestle_root=tmp_trestle_dir,
+        markdown=ssp_name,
+        output=ssp_name,
+        verbose=1,
+        name=None,
+        version=None,
+        compdefs=args_compdefs,
+        regenerate=False,
+    )
+    assert ssp_assemble._run(args) == 0
+
+    assem_ssp, _ = ModelUtils.load_model_for_class(tmp_trestle_dir, ssp_name, ossp.SystemSecurityPlan)
+    impl_reqs = assem_ssp.control_implementation.implemented_requirements
+    impl_req = next((i_req for i_req in impl_reqs if i_req.control_id == 'ac-1'), None)
+    assert len(impl_req.props) == 1
+    assert impl_req.props[0].name == 'prop_with_ns'
+    assert impl_req.props[0].value == 'prop with ns'
+    assert impl_req.props[0].ns == 'https://my_new_namespace'
+
+    smt_a = next((smt for smt in impl_req.statements if smt.statement_id == 'ac-1_smt.a'), None)
+    assert len(smt_a.props) == 1
+    assert smt_a.props[0].name == 'smt_prop'
+    assert smt_a.props[0].value == 'smt prop'