diff --git a/docs/source/usage.md b/docs/source/usage.md index 98fc7bd..dc6185a 100644 --- a/docs/source/usage.md +++ b/docs/source/usage.md @@ -218,8 +218,8 @@ The full set of configuration options are: - `password` - str: The IMAP password - `msgraph` - `auth_method` - str: Authentication method, valid types are - `UsernamePassword`, `DeviceCode`, `ClientSecret`, or `Certificate` - (Default: `UsernamePassword`). + `UsernamePassword`, `DeviceCode`, `ClientSecret`, `Certificate`, or + `ClientAssertion` (Default: `UsernamePassword`). - `user` - str: The M365 user, required when the auth method is UsernamePassword - `password` - str: The user password, required when the auth @@ -231,6 +231,17 @@ The full set of configuration options are: `Certificate` - `certificate_password` - str: Optional password for the certificate file when using `Certificate` auth + - `client_assertion` - str: A signed JWT client assertion, required + when the auth method is `ClientAssertion` + + :::{note} + `client_assertion` is a static value, so it is only usable for as + long as the JWT it contains remains unexpired (assertions are + typically short-lived, on the order of minutes). It is best suited + to short one-shot runs; for long-running `watch` mode, prefer + `Certificate` or `ClientSecret` auth, which can refresh + indefinitely. + ::: - `tenant_id` - str: The Azure AD tenant ID. This is required for all auth methods except UsernamePassword. - `mailbox` - str: The mailbox name. This defaults to the @@ -269,7 +280,7 @@ The full set of configuration options are: ``` The same application permission and mailbox scoping guidance - applies to the `Certificate` auth method. + applies to the `Certificate` and `ClientAssertion` auth methods. ::: - `elasticsearch` diff --git a/parsedmarc/cli.py b/parsedmarc/cli.py index bfec01a..02d73c7 100644 --- a/parsedmarc/cli.py +++ b/parsedmarc/cli.py @@ -82,6 +82,26 @@ class ConfigurationError(Exception): pass +def _normalize_graph_auth_method(value: str) -> str: + """Return the canonical :class:`AuthMethod` member name for *value*. + + Matching is case-insensitive so config values like ``certificate`` are + accepted alongside the canonical ``Certificate``. + + Raises: + ConfigurationError: When *value* does not match a known auth method. + """ + value_lower = value.lower() + for method in AuthMethod: + if method.name.lower() == value_lower: + return method.name + raise ConfigurationError( + "Invalid msgraph auth_method: {0!r}. Valid values are: {1}".format( + value, ", ".join(m.name for m in AuthMethod) + ) + ) + + def _str_to_list(s): """Converts a comma separated string to a list""" _list = s.split(",") @@ -635,7 +655,9 @@ def _parse_config(config: ConfigParser, opts): ) opts.graph_auth_method = AuthMethod.UsernamePassword.name else: - opts.graph_auth_method = graph_config["auth_method"] + opts.graph_auth_method = _normalize_graph_auth_method( + graph_config["auth_method"] + ) if opts.graph_auth_method == AuthMethod.UsernamePassword.name: if "user" in graph_config: @@ -689,6 +711,14 @@ def _parse_config(config: ConfigParser, opts): if "certificate_password" in graph_config: opts.graph_certificate_password = graph_config["certificate_password"] + if opts.graph_auth_method == AuthMethod.ClientAssertion.name: + if "client_assertion" in graph_config: + opts.graph_client_assertion = graph_config["client_assertion"] + else: + raise ConfigurationError( + "client_assertion setting missing from the msgraph config section" + ) + if "client_id" in graph_config: opts.graph_client_id = graph_config["client_id"] else: @@ -1922,6 +1952,7 @@ def _main(): graph_client_secret=None, graph_certificate_path=None, graph_certificate_password=None, + graph_client_assertion=None, graph_tenant_id=None, graph_mailbox=None, graph_allow_unencrypted_storage=False, @@ -2360,6 +2391,7 @@ def _main(): if opts.graph_client_id: try: mailbox = opts.graph_mailbox or opts.graph_user + logger.info("Connecting to Microsoft Graph mailbox %s", mailbox) mailbox_connection = MSGraphConnection( auth_method=opts.graph_auth_method, mailbox=mailbox, @@ -2368,6 +2400,7 @@ def _main(): client_secret=opts.graph_client_secret, certificate_path=opts.graph_certificate_path, certificate_password=opts.graph_certificate_password, + client_assertion=opts.graph_client_assertion, username=opts.graph_user, password=opts.graph_password, token_file=opts.graph_token_file, diff --git a/tests/test_cli.py b/tests/test_cli.py index 940faa2..1825ebe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1431,6 +1431,272 @@ certificate_path = /tmp/msgraph-cert.pem mock_graph_connection.assert_not_called() mock_get_mailbox_reports.assert_not_called() + @patch("parsedmarc.cli.get_dmarc_reports_from_mailbox") + @patch("parsedmarc.cli.MSGraphConnection") + def testCliPassesMsGraphClientAssertionAuthSettings( + self, mock_graph_connection, mock_get_mailbox_reports + ): + mock_graph_connection.return_value = object() + mock_get_mailbox_reports.return_value = { + "aggregate_reports": [], + "failure_reports": [], + "smtp_tls_reports": [], + } + + config_text = """[general] +silent = true + +[msgraph] +auth_method = ClientAssertion +client_id = client-id +client_assertion = signed-jwt-assertion +tenant_id = tenant-id +mailbox = shared@example.com +""" + + with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg: + cfg.write(config_text) + cfg_path = cfg.name + self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path)) + + with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]): + parsedmarc.cli._main() + + self.assertEqual( + mock_graph_connection.call_args.kwargs.get("auth_method"), + "ClientAssertion", + ) + self.assertEqual( + mock_graph_connection.call_args.kwargs.get("client_assertion"), + "signed-jwt-assertion", + ) + self.assertEqual( + mock_graph_connection.call_args.kwargs.get("tenant_id"), "tenant-id" + ) + self.assertEqual( + mock_graph_connection.call_args.kwargs.get("mailbox"), + "shared@example.com", + ) + + @patch("parsedmarc.cli.get_dmarc_reports_from_mailbox") + @patch("parsedmarc.cli.MSGraphConnection") + @patch("parsedmarc.cli.logger") + def testCliRequiresMsGraphClientAssertionForClientAssertionAuth( + self, mock_logger, mock_graph_connection, mock_get_mailbox_reports + ): + config_text = """[general] +silent = true + +[msgraph] +auth_method = ClientAssertion +client_id = client-id +tenant_id = tenant-id +mailbox = shared@example.com +""" + + with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg: + cfg.write(config_text) + cfg_path = cfg.name + self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path)) + + with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]): + with self.assertRaises(SystemExit) as system_exit: + parsedmarc.cli._main() + + self.assertEqual(system_exit.exception.code, -1) + mock_logger.critical.assert_called_once_with( + "client_assertion setting missing from the msgraph config section" + ) + mock_graph_connection.assert_not_called() + mock_get_mailbox_reports.assert_not_called() + + @patch("parsedmarc.cli.get_dmarc_reports_from_mailbox") + @patch("parsedmarc.cli.MSGraphConnection") + @patch("parsedmarc.cli.logger") + def testCliRequiresMsGraphTenantIdForClientAssertionAuth( + self, mock_logger, mock_graph_connection, mock_get_mailbox_reports + ): + config_text = """[general] +silent = true + +[msgraph] +auth_method = ClientAssertion +client_id = client-id +client_assertion = signed-jwt-assertion +mailbox = shared@example.com +""" + + with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg: + cfg.write(config_text) + cfg_path = cfg.name + self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path)) + + with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]): + with self.assertRaises(SystemExit) as system_exit: + parsedmarc.cli._main() + + self.assertEqual(system_exit.exception.code, -1) + mock_logger.critical.assert_called_once_with( + "tenant_id setting missing from the msgraph config section" + ) + mock_graph_connection.assert_not_called() + mock_get_mailbox_reports.assert_not_called() + + @patch("parsedmarc.cli.get_dmarc_reports_from_mailbox") + @patch("parsedmarc.cli.MSGraphConnection") + @patch("parsedmarc.cli.logger") + def testCliRequiresMsGraphMailboxForClientAssertionAuth( + self, mock_logger, mock_graph_connection, mock_get_mailbox_reports + ): + config_text = """[general] +silent = true + +[msgraph] +auth_method = ClientAssertion +client_id = client-id +client_assertion = signed-jwt-assertion +tenant_id = tenant-id +""" + + with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg: + cfg.write(config_text) + cfg_path = cfg.name + self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path)) + + with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]): + with self.assertRaises(SystemExit) as system_exit: + parsedmarc.cli._main() + + self.assertEqual(system_exit.exception.code, -1) + mock_logger.critical.assert_called_once_with( + "mailbox setting missing from the msgraph config section" + ) + mock_graph_connection.assert_not_called() + mock_get_mailbox_reports.assert_not_called() + + @patch("parsedmarc.cli.get_dmarc_reports_from_mailbox") + @patch("parsedmarc.cli.MSGraphConnection") + def testCliAcceptsLowercaseMsGraphCertificateAuthMethod( + self, mock_graph_connection, mock_get_mailbox_reports + ): + """auth_method values are case-insensitive (e.g. ``certificate``).""" + mock_graph_connection.return_value = object() + mock_get_mailbox_reports.return_value = { + "aggregate_reports": [], + "failure_reports": [], + "smtp_tls_reports": [], + } + + config_text = """[general] +silent = true + +[msgraph] +auth_method = certificate +client_id = client-id +tenant_id = tenant-id +mailbox = shared@example.com +certificate_path = /tmp/msgraph-cert.pem +certificate_password = cert-pass +""" + + with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg: + cfg.write(config_text) + cfg_path = cfg.name + self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path)) + + with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]): + parsedmarc.cli._main() + + self.assertEqual( + mock_graph_connection.call_args.kwargs.get("auth_method"), "Certificate" + ) + self.assertEqual( + mock_graph_connection.call_args.kwargs.get("mailbox"), + "shared@example.com", + ) + self.assertEqual( + mock_graph_connection.call_args.kwargs.get("certificate_path"), + "/tmp/msgraph-cert.pem", + ) + self.assertEqual( + mock_graph_connection.call_args.kwargs.get("certificate_password"), + "cert-pass", + ) + + @patch("parsedmarc.cli.get_dmarc_reports_from_mailbox") + @patch("parsedmarc.cli.MSGraphConnection") + @patch("parsedmarc.cli.logger") + def testCliRejectsInvalidMsGraphAuthMethod( + self, mock_logger, mock_graph_connection, mock_get_mailbox_reports + ): + config_text = """[general] +silent = true + +[msgraph] +auth_method = NotARealMethod +client_id = client-id +tenant_id = tenant-id +mailbox = shared@example.com +certificate_path = /tmp/msgraph-cert.pem +""" + + with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg: + cfg.write(config_text) + cfg_path = cfg.name + self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path)) + + with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]): + with self.assertRaises(SystemExit) as system_exit: + parsedmarc.cli._main() + + self.assertEqual(system_exit.exception.code, -1) + mock_logger.critical.assert_called_once() + critical_message = mock_logger.critical.call_args.args[0] + self.assertIn("Invalid msgraph auth_method", critical_message) + self.assertIn("NotARealMethod", critical_message) + mock_graph_connection.assert_not_called() + mock_get_mailbox_reports.assert_not_called() + + @patch("parsedmarc.cli.get_dmarc_reports_from_mailbox") + @patch("parsedmarc.cli.MSGraphConnection") + def testCliLogsMsGraphConnectionAttempt( + self, mock_graph_connection, mock_get_mailbox_reports + ): + """An INFO log is emitted when parsedmarc starts a Graph connection.""" + mock_graph_connection.return_value = object() + mock_get_mailbox_reports.return_value = { + "aggregate_reports": [], + "failure_reports": [], + "smtp_tls_reports": [], + } + + config_text = """[general] +silent = true + +[msgraph] +auth_method = Certificate +client_id = client-id +tenant_id = tenant-id +mailbox = shared@example.com +certificate_path = /tmp/msgraph-cert.pem +""" + + with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg: + cfg.write(config_text) + cfg_path = cfg.name + self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path)) + + with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path, "--verbose"]): + with self.assertLogs("parsedmarc.log", level="INFO") as cm: + parsedmarc.cli._main() + + self.assertTrue( + any( + "Connecting to Microsoft Graph mailbox shared@example.com" in message + for message in cm.output + ) + ) + class TestSighupReload(unittest.TestCase): """Tests for SIGHUP-driven configuration reload in watch mode."""