Version in base suite: 20.0.0-2 Base version: mistral_20.0.0-2 Target version: mistral_20.0.0-2+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/m/mistral/mistral_20.0.0-2.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/m/mistral/mistral_20.0.0-2+deb13u1.dsc changelog | 20 patches/OSSN-0098_Strip_sensitive_info_from_workflow_execution_context.patch | 116 patches/cve-2026-41283-stable-2025.1.patch | 2724 ++++++++++ patches/series | 2 4 files changed, 2862 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpq7bg95y0/mistral_20.0.0-2.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpq7bg95y0/mistral_20.0.0-2+deb13u1.dsc: no acceptable signature found diff -Nru mistral-20.0.0/debian/changelog mistral-20.0.0/debian/changelog --- mistral-20.0.0/debian/changelog 2025-07-12 09:27:02.000000000 +0000 +++ mistral-20.0.0/debian/changelog 2026-05-25 15:18:35.000000000 +0000 @@ -1,3 +1,23 @@ +mistral (20.0.0-2+deb13u1) trixie-security; urgency=medium + + * CVE-2026-41283: Mistral policy enforcement bypass allows unauthorized + public resource creation and arbitrary code execution. Applied upstream + patches: + - Restrict publicize policies to admin only + - Remove unnecessary expect_errors=True from policy tests + - Add code_sources publicize policy and enforcement + - Restrict code_sources and dynamic_actions policies to + - Add dynamic_actions publicize policy and enforcement + - Add workbooks publicize policy and enforcement + - Add cron_triggers publicize policy and enforcement + - Add environments publicize policy and enforcement + (Closes: #1138843) + * OSSN-0098: Mistral workflow execution context exposes Keystone auth token. + Applied upstream patch: "Strip sensitive info from workflow execution + context" (Closes: #1138849). + + -- Thomas Goirand Mon, 25 May 2026 17:18:35 +0200 + mistral (20.0.0-2) unstable; urgency=medium * Add export OS_OSLO_MESSAGING_RABBIT__PROCESSNAME to all daemons. diff -Nru mistral-20.0.0/debian/patches/OSSN-0098_Strip_sensitive_info_from_workflow_execution_context.patch mistral-20.0.0/debian/patches/OSSN-0098_Strip_sensitive_info_from_workflow_execution_context.patch --- mistral-20.0.0/debian/patches/OSSN-0098_Strip_sensitive_info_from_workflow_execution_context.patch 1970-01-01 00:00:00.000000000 +0000 +++ mistral-20.0.0/debian/patches/OSSN-0098_Strip_sensitive_info_from_workflow_execution_context.patch 2026-05-25 15:18:35.000000000 +0000 @@ -0,0 +1,116 @@ +Author: Arnaud Morin +Date: Mon, 30 Mar 2026 20:14:41 +0200 +Description: [PATCH] Strip sensitive info from workflow execution context + Remove the Keystone auth_token and service_catalog from the openstack + data stored in the workflow execution context. This prevents the token + from being persisted in the database, exposed to workflow authors via + $.openstack.auth_token YAQL expressions, and leaked in error log + messages. + . + Actions are not affected as they receive their token through the RPC context. +Bug: https://launchpad.net/bugs/2146554 +Bug-Debian: https://bugs.debian.org/1138849 +Co-Authored-By: Claude Opus 4.6 (1M context) +Signed-off-by: Arnaud Morin +Change-Id: Id339234da26c6fef8238bce6f0a6bc4acb284fb9 +Origin: upstream, https://review.opendev.org/c/openstack/mistral/+/991391 +Last-Update: 2026-06-05 + +Index: mistral/doc/source/user/wf_lang_v2.rst +=================================================================== +--- mistral.orig/doc/source/user/wf_lang_v2.rst ++++ mistral/doc/source/user/wf_lang_v2.rst +@@ -1500,9 +1500,12 @@ Workflow Language. + OpenStack context + ^^^^^^^^^^^^^^^^^ + +-OpenStack context is available by **$.openstack**. It contains **auth_token**, +-**project_id**, **user_id**, **service_catalog**, **user_name**, +-**project_name**, **roles**, **is_admin** properties. ++OpenStack context is available by **$.openstack**. It contains ++**project_id**, **user_id**, **user_name**, **project_name**, **roles**, ++**is_admin** properties. ++ ++Note that **auth_token** and **service_catalog** are masked (set to ``***``) ++in the context for security reasons. + + + Builtin functions in expressions +Index: mistral/mistral/tests/unit/engine/test_dataflow.py +=================================================================== +--- mistral.orig/mistral/tests/unit/engine/test_dataflow.py ++++ mistral/mistral/tests/unit/engine/test_dataflow.py +@@ -21,6 +21,8 @@ from mistral import exceptions as exc + from mistral import expressions as expr + from mistral.services import workbooks as wb_service + from mistral.services import workflows as wf_service ++from unittest import mock ++ + from mistral.tests.unit import base as test_base + from mistral.tests.unit.engine import base as engine_test_base + from mistral import utils +@@ -1472,3 +1474,30 @@ class DataFlowTest(test_base.BaseTest): + self.assertIn('"k1": "v1"', json_str) + self.assertIn('"k1": "v1"', json_str) + self.assertIn('"root"', json_str) ++ ++ @mock.patch('mistral.workflow.data_flow.CONF') ++ @mock.patch('mistral.workflow.data_flow.auth_ctx') ++ def test_add_openstack_data_masks_sensitive_fields( ++ self, mock_auth, mock_conf ++ ): ++ mock_conf.pecan.auth_enable = True ++ mock_auth.ctx.return_value.to_dict.return_value = { ++ 'auth_token': 'secret-token-value', ++ 'project_id': 'my-project', ++ 'user_id': 'my-user', ++ 'service_catalog': '[{"type": "compute"}]', ++ 'roles': 'admin', ++ 'is_admin': True ++ } ++ ++ wf_ex = models.WorkflowExecution() ++ wf_ex.context = {} ++ ++ data_flow.add_openstack_data_to_context(wf_ex) ++ ++ openstack_ctx = wf_ex.context['openstack'] ++ ++ self.assertEqual('***', openstack_ctx['auth_token']) ++ self.assertEqual('***', openstack_ctx['service_catalog']) ++ self.assertEqual('my-project', openstack_ctx['project_id']) ++ self.assertEqual('my-user', openstack_ctx['user_id']) +Index: mistral/mistral/workflow/data_flow.py +=================================================================== +--- mistral.orig/mistral/workflow/data_flow.py ++++ mistral/mistral/workflow/data_flow.py +@@ -302,7 +302,13 @@ def add_openstack_data_to_context(wf_ex) + exec_ctx = auth_ctx.ctx() + + if exec_ctx: +- wf_ex.context.update({'openstack': exec_ctx.to_dict()}) ++ openstack_ctx = exec_ctx.to_dict() ++ if 'auth_token' in openstack_ctx: ++ openstack_ctx['auth_token'] = '***' ++ if 'service_catalog' in openstack_ctx: ++ openstack_ctx['service_catalog'] = '***' ++ ++ wf_ex.context.update({'openstack': openstack_ctx}) + + + def add_execution_to_context(wf_ex): +Index: mistral/releasenotes/notes/strip-auth-token-from-workflow-context-e236283b18f547ff.yaml +=================================================================== +--- /dev/null ++++ mistral/releasenotes/notes/strip-auth-token-from-workflow-context-e236283b18f547ff.yaml +@@ -0,0 +1,10 @@ ++--- ++security: ++ - | ++ The Keystone ``auth_token`` and ``service_catalog`` are now masked in ++ the workflow execution context (replaced with ``***``). Previously, ++ their real values were persisted in the database and accessible to ++ workflow authors via ``$.openstack.auth_token`` and ++ ``$.openstack.service_catalog`` YAQL expressions, which could allow ++ token exfiltration. Actions are not affected as they receive their ++ authentication token through the RPC context. diff -Nru mistral-20.0.0/debian/patches/cve-2026-41283-stable-2025.1.patch mistral-20.0.0/debian/patches/cve-2026-41283-stable-2025.1.patch --- mistral-20.0.0/debian/patches/cve-2026-41283-stable-2025.1.patch 1970-01-01 00:00:00.000000000 +0000 +++ mistral-20.0.0/debian/patches/cve-2026-41283-stable-2025.1.patch 2026-05-25 15:18:35.000000000 +0000 @@ -0,0 +1,2724 @@ +From fee01ae8b7858af00ecb355617bf1bb10fd3ec24 Mon Sep 17 00:00:00 2001 +From: Arnaud Morin +Date: Mon, 30 Mar 2026 12:02:19 +0200 +Subject: [PATCH 1/8] Restrict publicize policies to admin only + +The publicize policy for workflows, actions, and event triggers +was using admin_or_owner which allowed any project member to make +resources public. This changes all publicize policies to admin_only. + +Also adds missing publicize enforcement on event triggers (update), +and introduces a consistent event_triggers:publicize policy replacing +the former event_triggers:create:public. + +Co-Authored-By: Claude Opus 4.6 (1M context) +Signed-off-by: Arnaud Morin +Change-Id: I9212528ff1e15a7a31a804733e79d92cd746c967 +--- + mistral/api/controllers/v2/event_trigger.py | 5 +- + mistral/policies/action.py | 2 +- + mistral/policies/event_trigger.py | 8 +- + mistral/policies/workflow.py | 2 +- + mistral/tests/unit/api/v2/test_actions.py | 10 + + mistral/tests/unit/api/v2/test_workflows.py | 10 + + mistral/tests/unit/policies/test_actions.py | 32 +- + .../unit/policies/test_event_triggers.py | 321 ++++++++++++++++++ + mistral/tests/unit/policies/test_workflows.py | 32 +- + ...licize-to-admin-only-a7f3c2e9d1b04e58.yaml | 9 + + 10 files changed, 392 insertions(+), 39 deletions(-) + create mode 100644 mistral/tests/unit/policies/test_event_triggers.py + create mode 100644 releasenotes/notes/restrict-publicize-to-admin-only-a7f3c2e9d1b04e58.yaml + +diff --git a/mistral/api/controllers/v2/event_trigger.py b/mistral/api/controllers/v2/event_trigger.py +index ecc98a41..eb5a72e9 100644 +--- a/mistral/api/controllers/v2/event_trigger.py ++++ b/mistral/api/controllers/v2/event_trigger.py +@@ -68,7 +68,7 @@ class EventTriggersController(rest.RestController): + ) + + if values.get('scope') == 'public': +- acl.enforce('event_triggers:create:public', auth_ctx.ctx()) ++ acl.enforce('event_triggers:publicize', auth_ctx.ctx()) + + LOG.debug('Create event trigger: %s', values) + +@@ -101,6 +101,9 @@ class EventTriggersController(rest.RestController): + + values = event_trigger.to_dict() + ++ if values.get('scope') == 'public': ++ acl.enforce('event_triggers:publicize', auth_ctx.ctx()) ++ + for field in UPDATE_NOT_ALLOWED: + if values.get(field): + raise exc.EventTriggerException( +diff --git a/mistral/policies/action.py b/mistral/policies/action.py +index e5ddf9b2..3448a393 100644 +--- a/mistral/policies/action.py ++++ b/mistral/policies/action.py +@@ -64,7 +64,7 @@ rules = [ + ), + policy.DocumentedRuleDefault( + name=ACTIONS % 'publicize', +- check_str=base.RULE_ADMIN_OR_OWNER, ++ check_str=base.RULE_ADMIN_ONLY, + description='Make an action publicly available', + operations=[ + { +diff --git a/mistral/policies/event_trigger.py b/mistral/policies/event_trigger.py +index 85e1255a..e5234924 100644 +--- a/mistral/policies/event_trigger.py ++++ b/mistral/policies/event_trigger.py +@@ -32,13 +32,17 @@ rules = [ + ] + ), + policy.DocumentedRuleDefault( +- name=EVENT_TRIGGERS % 'create:public', ++ name=EVENT_TRIGGERS % 'publicize', + check_str=base.RULE_ADMIN_ONLY, +- description='Create a new event trigger for public usage.', ++ description='Make an event trigger publicly available.', + operations=[ + { + 'path': '/v2/event_triggers', + 'method': 'POST' ++ }, ++ { ++ 'path': '/v2/event_triggers', ++ 'method': 'PUT' + } + ] + ), +diff --git a/mistral/policies/workflow.py b/mistral/policies/workflow.py +index 5f1651dc..a466aec5 100644 +--- a/mistral/policies/workflow.py ++++ b/mistral/policies/workflow.py +@@ -75,7 +75,7 @@ rules = [ + ), + policy.DocumentedRuleDefault( + name=WORKFLOWS % 'publicize', +- check_str=base.RULE_ADMIN_OR_OWNER, ++ check_str=base.RULE_ADMIN_ONLY, + description='Make a workflow publicly available', + operations=[ + { +diff --git a/mistral/tests/unit/api/v2/test_actions.py b/mistral/tests/unit/api/v2/test_actions.py +index a07f7d7a..e91586be 100644 +--- a/mistral/tests/unit/api/v2/test_actions.py ++++ b/mistral/tests/unit/api/v2/test_actions.py +@@ -239,6 +239,11 @@ class TestActionsController(base.APITest): + # Create an adhoc action for the purpose of the test. + adhoc_actions.create_actions(ADHOC_ACTION_YAML) + ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ + resp = self.app.put( + '/v2/actions?scope=public', + UPDATED_ADHOC_ACTION_YAML, +@@ -293,6 +298,11 @@ class TestActionsController(base.APITest): + self.check_adhoc_action_json(resp.json['actions'][0]) + + def test_post_public(self): ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ + resp = self.app.post( + '/v2/actions?scope=public', + ADHOC_ACTION_YAML, +diff --git a/mistral/tests/unit/api/v2/test_workflows.py b/mistral/tests/unit/api/v2/test_workflows.py +index 879e8089..8e6e7ba3 100644 +--- a/mistral/tests/unit/api/v2/test_workflows.py ++++ b/mistral/tests/unit/api/v2/test_workflows.py +@@ -383,6 +383,11 @@ class TestWorkflowsController(base.APITest): + def test_put_public(self, mock_update): + mock_update.return_value = UPDATED_WF_DB + ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ + resp = self.app.put( + '/v2/workflows?scope=public', + UPDATED_WF_DEFINITION, +@@ -512,6 +517,11 @@ class TestWorkflowsController(base.APITest): + def test_post_public(self, mock_mtd): + mock_mtd.return_value = WF_DB + ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ + resp = self.app.post( + '/v2/workflows?scope=public', + WF_DEFINITION, +diff --git a/mistral/tests/unit/policies/test_actions.py b/mistral/tests/unit/policies/test_actions.py +index 69a539a3..f9e064b9 100644 +--- a/mistral/tests/unit/policies/test_actions.py ++++ b/mistral/tests/unit/policies/test_actions.py +@@ -97,11 +97,9 @@ class TestActionPolicy(base.APITest): + + @mock.patch.object(db_api, "create_action_definition") + def test_action_create_public_not_allowed(self, mock_obj): +- self.policy.change_policy_definition({ +- "actions:create": "role:FAKE or rule:admin_or_owner", +- "actions:publicize": "role:FAKE" +- }) +- ++ # Default policy requires admin_only for publicize. ++ # The default test context has is_admin=False, so a regular user ++ # (project owner) should be denied. + resp = self.app.post( + '/v2/actions?scope=public', + ADHOC_ACTION_YAML, +@@ -113,10 +111,10 @@ class TestActionPolicy(base.APITest): + + @mock.patch.object(db_api, "create_action_definition") + def test_action_create_public_allowed(self, mock_obj): +- self.policy.change_policy_definition({ +- "actions:create": "role:FAKE or rule:admin_or_owner", +- "actions:publicize": "role:FAKE or rule:admin_or_owner" +- }) ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) + + resp = self.app.post( + '/v2/actions?scope=public', +@@ -220,11 +218,9 @@ class TestActionPolicy(base.APITest): + + @mock.patch.object(db_api, "update_action_definition") + def test_action_update_public_not_allowed(self, mock_obj): +- self.policy.change_policy_definition({ +- "actions:update": "role:FAKE or rule:admin_or_owner", +- "actions:publicize": "role:FAKE" +- }) +- ++ # Default policy requires admin_only for publicize. ++ # The default test context has is_admin=False, so a regular user ++ # (project owner) should be denied. + resp = self.app.put( + '/v2/actions?scope=public', + ADHOC_ACTION_YAML, +@@ -236,10 +232,10 @@ class TestActionPolicy(base.APITest): + + @mock.patch.object(db_api, "update_action_definition") + def test_action_update_public_allowed(self, mock_obj): +- self.policy.change_policy_definition({ +- "actions:update": "role:FAKE or rule:admin_or_owner", +- "actions:publicize": "role:FAKE or rule:admin_or_owner" +- }) ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) + + resp = self.app.put( + '/v2/actions?scope=public', +diff --git a/mistral/tests/unit/policies/test_event_triggers.py b/mistral/tests/unit/policies/test_event_triggers.py +new file mode 100644 +index 00000000..2d446fea +--- /dev/null ++++ b/mistral/tests/unit/policies/test_event_triggers.py +@@ -0,0 +1,321 @@ ++# Copyright 2026 - All rights reserved. ++# ++# Licensed under the Apache License, Version 2.0 (the "License"); you may ++# not use this file except in compliance with the License. You may obtain ++# a copy of the License at ++# ++# http://www.apache.org/licenses/LICENSE-2.0 ++# ++# Unless required by applicable law or agreed to in writing, software ++# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT ++# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the ++# License for the specific language governing permissions and limitations ++# under the License. ++ ++ ++import copy ++import json ++from unittest import mock ++ ++from mistral.db.v2 import api as db_api ++from mistral.db.v2.sqlalchemy import models ++from mistral.tests.unit.api import base ++from mistral.tests.unit.mstrlfixtures import policy_fixtures ++ ++TRIGGER_ID = '09cc56a9-d15e-4494-a6e2-c4ec8bdaacae' ++TRUST_ID = 'b1a78338-285e-4303-96aa-addadc87f054' ++TRIGGER = { ++ 'name': 'my_event_trigger', ++ 'workflow_id': '123e4567-e89b-12d3-a456-426655440000', ++ 'workflow_input': '{}', ++ 'workflow_params': '{}', ++ 'scope': 'private', ++ 'exchange': 'openstack', ++ 'topic': 'notification', ++ 'event': 'compute.instance.create.start' ++} ++ ++trigger_values = copy.deepcopy(TRIGGER) ++trigger_values['id'] = TRIGGER_ID ++trigger_values['trust_id'] = TRUST_ID ++trigger_values['workflow_input'] = json.loads( ++ trigger_values['workflow_input']) ++trigger_values['workflow_params'] = json.loads( ++ trigger_values['workflow_params']) ++ ++TRIGGER_DB = models.EventTrigger() ++TRIGGER_DB.update(trigger_values) ++ ++MOCK_TRIGGER = mock.MagicMock(return_value=TRIGGER_DB) ++MOCK_TRIGGERS = mock.MagicMock(return_value=[TRIGGER_DB]) ++MOCK_NONE = mock.MagicMock(return_value=None) ++MOCK_DELETE = mock.MagicMock(return_value=None) ++ ++ ++class TestEventTriggerPolicy(base.APITest): ++ """Test event trigger related policies ++ ++ Policies to test: ++ - event_triggers:create ++ - event_triggers:publicize (on POST & PUT) ++ - event_triggers:delete ++ - event_triggers:get ++ - event_triggers:list ++ - event_triggers:list:all_projects ++ - event_triggers:update ++ """ ++ ++ def setUp(self): ++ super(TestEventTriggerPolicy, self).setUp() ++ ++ self.policy = self.useFixture(policy_fixtures.PolicyFixture()) ++ ++ @mock.patch.object(db_api, "get_event_trigger", MOCK_TRIGGER) ++ def test_event_trigger_get_not_allowed(self): ++ self.policy.change_policy_definition( ++ {"event_triggers:get": "role:FAKE"} ++ ) ++ ++ resp = self.app.get( ++ '/v2/event_triggers/09cc56a9-d15e-4494-a6e2-c4ec8bdaacae', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, "get_event_trigger", MOCK_TRIGGER) ++ def test_event_trigger_get_allowed(self): ++ self.policy.change_policy_definition( ++ {"event_triggers:get": "role:FAKE or rule:admin_or_owner"} ++ ) ++ ++ resp = self.app.get( ++ '/v2/event_triggers/09cc56a9-d15e-4494-a6e2-c4ec8bdaacae' ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ def test_event_trigger_list_not_allowed(self): ++ self.policy.change_policy_definition( ++ {"event_triggers:list": "role:FAKE"} ++ ) ++ ++ resp = self.app.get( ++ '/v2/event_triggers', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_event_trigger_list_allowed(self): ++ self.policy.change_policy_definition( ++ {"event_triggers:list": "role:FAKE or rule:admin_or_owner"} ++ ) ++ ++ resp = self.app.get( ++ '/v2/event_triggers' ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ def test_event_trigger_list_all_not_allowed(self): ++ self.policy.change_policy_definition({ ++ "event_triggers:list": "role:FAKE or rule:admin_or_owner", ++ "event_triggers:list:all_projects": "role:FAKE" ++ }) ++ ++ resp = self.app.get( ++ '/v2/event_triggers?all_projects=1', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_event_trigger_list_all_allowed(self): ++ self.policy.change_policy_definition({ ++ "event_triggers:list": "role:FAKE or rule:admin_or_owner", ++ "event_triggers:list:all_projects": ++ "role:FAKE or rule:admin_or_owner" ++ }) ++ ++ resp = self.app.get( ++ '/v2/event_triggers?all_projects=1' ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ def test_event_trigger_create_not_allowed(self): ++ self.policy.change_policy_definition( ++ {"event_triggers:create": "role:FAKE"} ++ ) ++ ++ resp = self.app.post_json( ++ '/v2/event_triggers', ++ TRIGGER, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch('mistral.services.triggers.create_event_trigger', ++ return_value=TRIGGER_DB) ++ def test_event_trigger_create_allowed(self, mock_obj): ++ self.policy.change_policy_definition( ++ {"event_triggers:create": "role:FAKE or rule:admin_or_owner"} ++ ) ++ ++ resp = self.app.post_json( ++ '/v2/event_triggers', ++ TRIGGER ++ ) ++ ++ self.assertEqual(201, resp.status_int) ++ ++ def test_event_trigger_create_public_not_allowed(self): ++ # Default policy requires admin_only for event_triggers:publicize. ++ # The default test context has is_admin=False, so a regular user ++ # (project owner) should be denied. ++ trigger = copy.deepcopy(TRIGGER) ++ trigger['scope'] = 'public' ++ ++ resp = self.app.post_json( ++ '/v2/event_triggers', ++ trigger, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch('mistral.services.triggers.create_event_trigger', ++ return_value=TRIGGER_DB) ++ def test_event_trigger_create_public_allowed(self, mock_obj): ++ # Default policy requires admin_only for event_triggers:publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ trigger = copy.deepcopy(TRIGGER) ++ trigger['scope'] = 'public' ++ ++ resp = self.app.post_json( ++ '/v2/event_triggers', ++ trigger ++ ) ++ ++ self.assertEqual(201, resp.status_int) ++ ++ @mock.patch.object(db_api, 'get_event_trigger', MOCK_NONE) ++ @mock.patch('mistral.rpc.clients.get_event_engine_client') ++ @mock.patch('mistral.db.v2.api.update_event_trigger') ++ def test_event_trigger_update_not_allowed(self, mock_update, ++ mock_rpc_client): ++ self.policy.change_policy_definition( ++ {"event_triggers:update": "role:FAKE"} ++ ) ++ ++ resp = self.app.put_json( ++ '/v2/event_triggers/09cc56a9-d15e-4494-a6e2-c4ec8bdaacae', ++ {'name': 'new_name'}, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, 'get_event_trigger', MOCK_NONE) ++ @mock.patch('mistral.rpc.clients.get_event_engine_client') ++ @mock.patch('mistral.db.v2.api.update_event_trigger') ++ def test_event_trigger_update_allowed(self, mock_update, ++ mock_rpc_client): ++ client = mock.Mock() ++ mock_rpc_client.return_value = client ++ ++ UPDATED_TRIGGER = models.EventTrigger() ++ UPDATED_TRIGGER.update(trigger_values) ++ UPDATED_TRIGGER.update({'name': 'new_name'}) ++ mock_update.return_value = UPDATED_TRIGGER ++ ++ self.policy.change_policy_definition( ++ {"event_triggers:update": "role:FAKE or rule:admin_or_owner"} ++ ) ++ ++ resp = self.app.put_json( ++ '/v2/event_triggers/09cc56a9-d15e-4494-a6e2-c4ec8bdaacae', ++ {'name': 'new_name'} ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ @mock.patch.object(db_api, 'get_event_trigger', MOCK_NONE) ++ @mock.patch('mistral.rpc.clients.get_event_engine_client') ++ @mock.patch('mistral.db.v2.api.update_event_trigger') ++ def test_event_trigger_update_public_not_allowed(self, mock_update, ++ mock_rpc_client): ++ # Default policy requires admin_only for event_triggers:publicize. ++ # The default test context has is_admin=False, so a regular user ++ # (project owner) should be denied. ++ resp = self.app.put_json( ++ '/v2/event_triggers/09cc56a9-d15e-4494-a6e2-c4ec8bdaacae', ++ {'scope': 'public'}, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, 'get_event_trigger', MOCK_NONE) ++ @mock.patch('mistral.rpc.clients.get_event_engine_client') ++ @mock.patch('mistral.db.v2.api.update_event_trigger') ++ def test_event_trigger_update_public_allowed(self, mock_update, ++ mock_rpc_client): ++ # Default policy requires admin_only for event_triggers:publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ client = mock.Mock() ++ mock_rpc_client.return_value = client ++ ++ UPDATED_TRIGGER = models.EventTrigger() ++ UPDATED_TRIGGER.update(trigger_values) ++ UPDATED_TRIGGER.update({'scope': 'public'}) ++ mock_update.return_value = UPDATED_TRIGGER ++ ++ resp = self.app.put_json( ++ '/v2/event_triggers/09cc56a9-d15e-4494-a6e2-c4ec8bdaacae', ++ {'scope': 'public'} ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ @mock.patch.object(db_api, "get_event_trigger", MOCK_TRIGGER) ++ @mock.patch.object(db_api, "delete_event_trigger", MOCK_DELETE) ++ @mock.patch('mistral.services.security.delete_trust', MOCK_NONE) ++ @mock.patch('mistral.rpc.clients.get_event_engine_client') ++ def test_event_trigger_delete_not_allowed(self, mock_rpc_client): ++ self.policy.change_policy_definition( ++ {"event_triggers:delete": "role:FAKE"} ++ ) ++ ++ resp = self.app.delete( ++ '/v2/event_triggers/09cc56a9-d15e-4494-a6e2-c4ec8bdaacae', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, "get_event_trigger", MOCK_TRIGGER) ++ @mock.patch.object(db_api, "delete_event_trigger", MOCK_DELETE) ++ @mock.patch('mistral.services.security.delete_trust', MOCK_NONE) ++ @mock.patch('mistral.rpc.clients.get_event_engine_client') ++ def test_event_trigger_delete_allowed(self, mock_rpc_client): ++ client = mock.Mock() ++ mock_rpc_client.return_value = client ++ ++ self.policy.change_policy_definition( ++ {"event_triggers:delete": "role:FAKE or rule:admin_or_owner"} ++ ) ++ ++ resp = self.app.delete( ++ '/v2/event_triggers/09cc56a9-d15e-4494-a6e2-c4ec8bdaacae' ++ ) ++ ++ self.assertEqual(204, resp.status_int) +diff --git a/mistral/tests/unit/policies/test_workflows.py b/mistral/tests/unit/policies/test_workflows.py +index 2bfa094f..038ad0ac 100644 +--- a/mistral/tests/unit/policies/test_workflows.py ++++ b/mistral/tests/unit/policies/test_workflows.py +@@ -99,10 +99,9 @@ class TestWorkflowPolicy(base.APITest): + + @mock.patch.object(db_api, "create_workflow_definition") + def test_workflow_create_public_not_allowed(self, mock_obj): +- self.policy.change_policy_definition({ +- "workflows:create": "role:FAKE or rule:admin_or_owner", +- "workflows:publicize": "role:FAKE" +- }) ++ # Default policy requires admin_only for publicize. ++ # The default test context has is_admin=False, so a regular user ++ # (project owner) should be denied. + resp = self.app.post( + '/v2/workflows?scope=public', + WF_DEFINITION, +@@ -117,10 +116,11 @@ class TestWorkflowPolicy(base.APITest): + spec_mock = mock_obj.return_value.get.return_value + spec_mock.get.return_value = {} + +- self.policy.change_policy_definition({ +- "workflows:create": "role:FAKE or rule:admin_or_owner", +- "workflows:publicize": "role:FAKE or rule:admin_or_owner" +- }) ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ + resp = self.app.post( + '/v2/workflows?scope=public', + WF_DEFINITION, +@@ -259,10 +259,9 @@ class TestWorkflowPolicy(base.APITest): + + @mock.patch.object(db_api, "update_workflow_definition") + def test_workflow_update_public_not_allowed(self, mock_obj): +- self.policy.change_policy_definition({ +- "workflows:update": "role:FAKE or rule:admin_or_owner", +- "workflows:publicize": "role:FAKE" +- }) ++ # Default policy requires admin_only for publicize. ++ # The default test context has is_admin=False, so a regular user ++ # (project owner) should be denied. + resp = self.app.put( + '/v2/workflows?scope=public', + WF_DEFINITION, +@@ -277,10 +276,11 @@ class TestWorkflowPolicy(base.APITest): + spec_mock = mock_obj.return_value.get.return_value + spec_mock.get.return_value = {} + +- self.policy.change_policy_definition({ +- "workflows:update": "role:FAKE or rule:admin_or_owner", +- "workflows:publicize": "role:FAKE or rule:admin_or_owner" +- }) ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ + resp = self.app.put( + '/v2/workflows?scope=public', + WF_DEFINITION, +diff --git a/releasenotes/notes/restrict-publicize-to-admin-only-a7f3c2e9d1b04e58.yaml b/releasenotes/notes/restrict-publicize-to-admin-only-a7f3c2e9d1b04e58.yaml +new file mode 100644 +index 00000000..1ec04076 +--- /dev/null ++++ b/releasenotes/notes/restrict-publicize-to-admin-only-a7f3c2e9d1b04e58.yaml +@@ -0,0 +1,9 @@ ++--- ++security: ++ - | ++ The ``publicize`` policy for workflows, actions, and event triggers is now ++ restricted to admin users only (``admin_only``). Previously, any project ++ owner could make these resources public. A new ``code_sources:publicize`` ++ policy has also been added with the same ``admin_only`` default, and ++ publicize checks are now enforced on both create and update operations for ++ code sources and event triggers where they were previously missing. +-- +2.43.0 + + +From f0130cd1725731f8158f42fee87d118a3f3ee9a8 Mon Sep 17 00:00:00 2001 +From: Arnaud Morin +Date: Sun, 29 Mar 2026 19:03:51 +0000 +Subject: [PATCH 2/8] Remove unnecessary expect_errors=True from policy tests + +Tests asserting success status codes (200, 201, 204) should not +use expect_errors=True, as it is only needed when expecting error +responses. + +Co-Authored-By: Claude Opus 4.6 (1M context) +Signed-off-by: Arnaud Morin +Change-Id: Iee35c6cf9e33fd5a9428c4be2c289f0ac7a65ab4 +--- + mistral/tests/unit/policies/test_actions.py | 16 +++++-------- + mistral/tests/unit/policies/test_workflows.py | 24 +++++++------------ + 2 files changed, 14 insertions(+), 26 deletions(-) + +diff --git a/mistral/tests/unit/policies/test_actions.py b/mistral/tests/unit/policies/test_actions.py +index f9e064b9..bf315470 100644 +--- a/mistral/tests/unit/policies/test_actions.py ++++ b/mistral/tests/unit/policies/test_actions.py +@@ -89,8 +89,7 @@ class TestActionPolicy(base.APITest): + resp = self.app.post( + '/v2/actions', + ADHOC_ACTION_YAML, +- headers={'Content-Type': 'text/plain'}, +- expect_errors=True ++ headers={'Content-Type': 'text/plain'} + ) + + self.assertEqual(201, resp.status_int) +@@ -119,8 +118,7 @@ class TestActionPolicy(base.APITest): + resp = self.app.post( + '/v2/actions?scope=public', + ADHOC_ACTION_YAML, +- headers={'Content-Type': 'text/plain'}, +- expect_errors=True ++ headers={'Content-Type': 'text/plain'} + ) + + self.assertEqual(201, resp.status_int) +@@ -143,7 +141,7 @@ class TestActionPolicy(base.APITest): + {"actions:delete": "role:FAKE or rule:admin_or_owner"} + ) + +- resp = self.app.delete('/v2/actions/123', expect_errors=True) ++ resp = self.app.delete('/v2/actions/123') + + self.assertEqual(204, resp.status_int) + +@@ -182,7 +180,7 @@ class TestActionPolicy(base.APITest): + {"actions:list": "role:FAKE or rule:admin_or_owner"} + ) + +- resp = self.app.get('/v2/actions', expect_errors=True) ++ resp = self.app.get('/v2/actions') + + self.assertEqual(200, resp.status_int) + +@@ -210,8 +208,7 @@ class TestActionPolicy(base.APITest): + resp = self.app.put( + '/v2/actions', + ADHOC_ACTION_YAML, +- headers={'Content-Type': 'text/plain'}, +- expect_errors=True ++ headers={'Content-Type': 'text/plain'} + ) + + self.assertEqual(200, resp.status_int) +@@ -240,8 +237,7 @@ class TestActionPolicy(base.APITest): + resp = self.app.put( + '/v2/actions?scope=public', + ADHOC_ACTION_YAML, +- headers={'Content-Type': 'text/plain'}, +- expect_errors=True ++ headers={'Content-Type': 'text/plain'} + ) + + self.assertEqual(200, resp.status_int) +diff --git a/mistral/tests/unit/policies/test_workflows.py b/mistral/tests/unit/policies/test_workflows.py +index 038ad0ac..13faffdd 100644 +--- a/mistral/tests/unit/policies/test_workflows.py ++++ b/mistral/tests/unit/policies/test_workflows.py +@@ -91,8 +91,7 @@ class TestWorkflowPolicy(base.APITest): + resp = self.app.post( + '/v2/workflows', + WF_DEFINITION, +- headers={'Content-Type': 'text/plain'}, +- expect_errors=True ++ headers={'Content-Type': 'text/plain'} + ) + + self.assertEqual(201, resp.status_int) +@@ -124,8 +123,7 @@ class TestWorkflowPolicy(base.APITest): + resp = self.app.post( + '/v2/workflows?scope=public', + WF_DEFINITION, +- headers={'Content-Type': 'text/plain'}, +- expect_errors=True ++ headers={'Content-Type': 'text/plain'} + ) + + self.assertEqual(201, resp.status_int) +@@ -150,8 +148,7 @@ class TestWorkflowPolicy(base.APITest): + {"workflows:delete": "role:FAKE or rule:admin_or_owner"} + ) + resp = self.app.delete( +- '/v2/workflows/123', +- expect_errors=True ++ '/v2/workflows/123' + ) + + self.assertEqual(204, resp.status_int) +@@ -174,8 +171,7 @@ class TestWorkflowPolicy(base.APITest): + {"workflows:get": "role:FAKE or rule:admin_or_owner"} + ) + resp = self.app.get( +- '/v2/workflows/123', +- expect_errors=True ++ '/v2/workflows/123' + ) + + self.assertEqual(200, resp.status_int) +@@ -196,8 +192,7 @@ class TestWorkflowPolicy(base.APITest): + {"workflows:list": "role:FAKE or rule:admin_or_owner"} + ) + resp = self.app.get( +- '/v2/workflows', +- expect_errors=True ++ '/v2/workflows' + ) + + self.assertEqual(200, resp.status_int) +@@ -220,8 +215,7 @@ class TestWorkflowPolicy(base.APITest): + "workflows:list:all_projects": "role:FAKE or rule:admin_or_owner" + }) + resp = self.app.get( +- '/v2/workflows?all_projects=1', +- expect_errors=True ++ '/v2/workflows?all_projects=1' + ) + + self.assertEqual(200, resp.status_int) +@@ -251,8 +245,7 @@ class TestWorkflowPolicy(base.APITest): + resp = self.app.put( + '/v2/workflows', + WF_DEFINITION, +- headers={'Content-Type': 'text/plain'}, +- expect_errors=True ++ headers={'Content-Type': 'text/plain'} + ) + + self.assertEqual(200, resp.status_int) +@@ -284,8 +277,7 @@ class TestWorkflowPolicy(base.APITest): + resp = self.app.put( + '/v2/workflows?scope=public', + WF_DEFINITION, +- headers={'Content-Type': 'text/plain'}, +- expect_errors=True ++ headers={'Content-Type': 'text/plain'} + ) + + self.assertEqual(200, resp.status_int) +-- +2.43.0 + + +From b5f0e3cc045149f5aca25cddda86668a06868824 Mon Sep 17 00:00:00 2001 +From: Arnaud Morin +Date: Mon, 30 Mar 2026 12:02:34 +0200 +Subject: [PATCH 3/8] Add code_sources publicize policy and enforcement + +Add a new code_sources:publicize policy (admin_only) and enforce it +on both create and update operations when scope is public. Also adds +policy tests for code source create and publicize operations. + +Co-Authored-By: Claude Opus 4.6 (1M context) +Signed-off-by: Arnaud Morin +Change-Id: I889f0a7025d5dd6190b7a9a2528cb0d030bc082c +--- + mistral/api/controllers/v2/code_source.py | 6 + + mistral/policies/code_sources.py | 15 ++ + .../tests/unit/policies/test_code_sources.py | 146 ++++++++++++++++++ + 3 files changed, 167 insertions(+) + create mode 100644 mistral/tests/unit/policies/test_code_sources.py + +diff --git a/mistral/api/controllers/v2/code_source.py b/mistral/api/controllers/v2/code_source.py +index dda521a4..9b6c0807 100644 +--- a/mistral/api/controllers/v2/code_source.py ++++ b/mistral/api/controllers/v2/code_source.py +@@ -51,6 +51,9 @@ class CodeSourcesController(rest.RestController, hooks.HookController): + + acl.enforce('code_sources:create', context.ctx()) + ++ if scope == 'public': ++ acl.enforce('code_sources:publicize', context.ctx()) ++ + # Extract content directly from the request. + content = pecan.request.text + +@@ -87,6 +90,9 @@ class CodeSourcesController(rest.RestController, hooks.HookController): + """ + acl.enforce('code_sources:update', context.ctx()) + ++ if scope == 'public': ++ acl.enforce('code_sources:publicize', context.ctx()) ++ + LOG.debug( + 'Updating code source [identifier(name or id)=%s, scope=%s,' + ' namespace=%s]', +diff --git a/mistral/policies/code_sources.py b/mistral/policies/code_sources.py +index 39328988..66ec7be3 100644 +--- a/mistral/policies/code_sources.py ++++ b/mistral/policies/code_sources.py +@@ -65,6 +65,21 @@ rules = [ + } + ] + ), ++ policy.DocumentedRuleDefault( ++ name=CODE_SOURCES % 'publicize', ++ check_str=base.RULE_ADMIN_ONLY, ++ description='Make a code source publicly available.', ++ operations=[ ++ { ++ 'path': BASE_PATH, ++ 'method': 'POST' ++ }, ++ { ++ 'path': BASE_PATH, ++ 'method': 'PUT' ++ } ++ ] ++ ), + policy.DocumentedRuleDefault( + name=CODE_SOURCES % 'update', + check_str=base.RULE_ADMIN_OR_OWNER, +diff --git a/mistral/tests/unit/policies/test_code_sources.py b/mistral/tests/unit/policies/test_code_sources.py +new file mode 100644 +index 00000000..cf534c53 +--- /dev/null ++++ b/mistral/tests/unit/policies/test_code_sources.py +@@ -0,0 +1,146 @@ ++# Copyright 2026 - All rights reserved. ++# ++# Licensed under the Apache License, Version 2.0 (the "License"); you may ++# not use this file except in compliance with the License. You may obtain ++# a copy of the License at ++# ++# http://www.apache.org/licenses/LICENSE-2.0 ++# ++# Unless required by applicable law or agreed to in writing, software ++# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT ++# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the ++# License for the specific language governing permissions and limitations ++# under the License. ++ ++ ++from mistral.db.v2 import api as db_api ++from mistral.tests.unit.api import base ++from mistral.tests.unit.mstrlfixtures import policy_fixtures ++ ++FILE_CONTENT = "test file" ++MODULE_NAME = 'test_module' ++NAMESPACE = 'NS' ++ ++ ++class TestCodeSourcePolicy(base.APITest): ++ """Test code source related policies ++ ++ Policies to test: ++ - code_sources:create ++ - code_sources:publicize (on POST & PUT) ++ - code_sources:delete ++ - code_sources:get ++ - code_sources:list ++ - code_sources:update ++ """ ++ ++ def setUp(self): ++ super(TestCodeSourcePolicy, self).setUp() ++ ++ self.policy = self.useFixture(policy_fixtures.PolicyFixture()) ++ self.addCleanup(db_api.delete_code_sources) ++ ++ def test_code_source_create_not_allowed(self): ++ self.policy.change_policy_definition( ++ {"code_sources:create": "role:FAKE"} ++ ) ++ ++ resp = self.app.post( ++ '/v2/code_sources?name=%s&namespace=%s' % ++ (MODULE_NAME, NAMESPACE), ++ FILE_CONTENT, ++ headers={'Content-Type': 'text/plain'}, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_code_source_create_allowed(self): ++ self.policy.change_policy_definition( ++ {"code_sources:create": "role:FAKE or rule:admin_or_owner"} ++ ) ++ ++ resp = self.app.post( ++ '/v2/code_sources?name=%s&namespace=%s' % ++ (MODULE_NAME, NAMESPACE), ++ FILE_CONTENT, ++ headers={'Content-Type': 'text/plain'} ++ ) ++ ++ self.assertEqual(201, resp.status_int) ++ ++ def test_code_source_create_public_not_allowed(self): ++ # Default policy requires admin_only for publicize. ++ # The default test context has is_admin=False, so a regular user ++ # (project owner) should be denied. ++ resp = self.app.post( ++ '/v2/code_sources?name=%s&namespace=%s&scope=public' % ++ (MODULE_NAME, NAMESPACE), ++ FILE_CONTENT, ++ headers={'Content-Type': 'text/plain'}, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_code_source_create_public_allowed(self): ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ resp = self.app.post( ++ '/v2/code_sources?name=%s&namespace=%s&scope=public' % ++ (MODULE_NAME, NAMESPACE), ++ FILE_CONTENT, ++ headers={'Content-Type': 'text/plain'} ++ ) ++ ++ self.assertEqual(201, resp.status_int) ++ ++ def test_code_source_update_public_not_allowed(self): ++ # First create a private code source as admin (to have something ++ # to update). ++ self.ctx.is_admin = True ++ ++ self.app.post( ++ '/v2/code_sources?name=%s&namespace=%s' % ++ (MODULE_NAME, NAMESPACE), ++ FILE_CONTENT, ++ headers={'Content-Type': 'text/plain'} ++ ) ++ ++ # Now switch back to non-admin and try to update to public. ++ self.ctx.is_admin = False ++ ++ resp = self.app.put( ++ '/v2/code_sources?identifier=%s&namespace=%s&scope=public' % ++ (MODULE_NAME, NAMESPACE), ++ FILE_CONTENT, ++ headers={'Content-Type': 'text/plain'}, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_code_source_update_public_allowed(self): ++ # Create a private code source first. ++ self.app.post( ++ '/v2/code_sources?name=%s&namespace=%s' % ++ (MODULE_NAME, NAMESPACE), ++ FILE_CONTENT, ++ headers={'Content-Type': 'text/plain'} ++ ) ++ ++ # Update to public as admin. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ resp = self.app.put( ++ '/v2/code_sources?identifier=%s&namespace=%s&scope=public' % ++ (MODULE_NAME, NAMESPACE), ++ FILE_CONTENT, ++ headers={'Content-Type': 'text/plain'} ++ ) ++ ++ self.assertEqual(200, resp.status_int) +-- +2.43.0 + + +From bbb78234d21f267334ddff4e58dfd52e1ebbd6ed Mon Sep 17 00:00:00 2001 +From: Arnaud Morin +Date: Mon, 30 Mar 2026 11:57:27 +0200 +Subject: [PATCH 4/8] Restrict code_sources and dynamic_actions policies to + admin only + +These APIs previously defaulted to admin_or_owner, allowing any project +owner to manage code sources and dynamic actions. Restrict all operations +to admin_only to reduce the attack surface for arbitrary code execution. + +Co-Authored-By: Claude Opus 4.6 (1M context) +Signed-off-by: Arnaud Morin +Change-Id: Ic01a06f2bd588f7b0ade74403393f52e96f25432 +--- + mistral/policies/code_sources.py | 10 +- + mistral/policies/dynamic_actions.py | 10 +- + .../tests/unit/api/v2/test_code_sources.py | 4 + + .../tests/unit/api/v2/test_dynamic_actions.py | 5 + + .../tests/unit/policies/test_code_sources.py | 20 +- + .../unit/policies/test_dynamic_actions.py | 202 ++++++++++++++++++ + ...mic-actions-to-admin-c0cff611f60a47e9.yaml | 9 + + 7 files changed, 239 insertions(+), 21 deletions(-) + create mode 100644 mistral/tests/unit/policies/test_dynamic_actions.py + create mode 100644 releasenotes/notes/restrict-code-sources-dynamic-actions-to-admin-c0cff611f60a47e9.yaml + +diff --git a/mistral/policies/code_sources.py b/mistral/policies/code_sources.py +index 66ec7be3..59798c8f 100644 +--- a/mistral/policies/code_sources.py ++++ b/mistral/policies/code_sources.py +@@ -23,7 +23,7 @@ BASE_PATH = '/v2/code_sources' + rules = [ + policy.DocumentedRuleDefault( + name=CODE_SOURCES % 'create', +- check_str=base.RULE_ADMIN_OR_OWNER, ++ check_str=base.RULE_ADMIN_ONLY, + description='Create a new code source.', + operations=[ + { +@@ -34,7 +34,7 @@ rules = [ + ), + policy.DocumentedRuleDefault( + name=CODE_SOURCES % 'delete', +- check_str=base.RULE_ADMIN_OR_OWNER, ++ check_str=base.RULE_ADMIN_ONLY, + description='Delete the named code source.', + operations=[ + { +@@ -45,7 +45,7 @@ rules = [ + ), + policy.DocumentedRuleDefault( + name=CODE_SOURCES % 'get', +- check_str=base.RULE_ADMIN_OR_OWNER, ++ check_str=base.RULE_ADMIN_ONLY, + description='Return the named code source.', + operations=[ + { +@@ -56,7 +56,7 @@ rules = [ + ), + policy.DocumentedRuleDefault( + name=CODE_SOURCES % 'list', +- check_str=base.RULE_ADMIN_OR_OWNER, ++ check_str=base.RULE_ADMIN_ONLY, + description='Return all code sources.', + operations=[ + { +@@ -82,7 +82,7 @@ rules = [ + ), + policy.DocumentedRuleDefault( + name=CODE_SOURCES % 'update', +- check_str=base.RULE_ADMIN_OR_OWNER, ++ check_str=base.RULE_ADMIN_ONLY, + description='Update one or more code source.', + operations=[ + { +diff --git a/mistral/policies/dynamic_actions.py b/mistral/policies/dynamic_actions.py +index e976672e..fabada7c 100644 +--- a/mistral/policies/dynamic_actions.py ++++ b/mistral/policies/dynamic_actions.py +@@ -23,7 +23,7 @@ BASE_PATH = '/v2/dynamic_actions' + rules = [ + policy.DocumentedRuleDefault( + name=ACTIONS % 'create', +- check_str=base.RULE_ADMIN_OR_OWNER, ++ check_str=base.RULE_ADMIN_ONLY, + description='Create a new dynamic action.', + operations=[ + { +@@ -34,7 +34,7 @@ rules = [ + ), + policy.DocumentedRuleDefault( + name=ACTIONS % 'delete', +- check_str=base.RULE_ADMIN_OR_OWNER, ++ check_str=base.RULE_ADMIN_ONLY, + description='Delete the named dynamic action.', + operations=[ + { +@@ -45,7 +45,7 @@ rules = [ + ), + policy.DocumentedRuleDefault( + name=ACTIONS % 'get', +- check_str=base.RULE_ADMIN_OR_OWNER, ++ check_str=base.RULE_ADMIN_ONLY, + description='Return the named dynamic action.', + operations=[ + { +@@ -56,7 +56,7 @@ rules = [ + ), + policy.DocumentedRuleDefault( + name=ACTIONS % 'list', +- check_str=base.RULE_ADMIN_OR_OWNER, ++ check_str=base.RULE_ADMIN_ONLY, + description='Return all dynamic actions.', + operations=[ + { +@@ -67,7 +67,7 @@ rules = [ + ), + policy.DocumentedRuleDefault( + name=ACTIONS % 'update', +- check_str=base.RULE_ADMIN_OR_OWNER, ++ check_str=base.RULE_ADMIN_ONLY, + description='Update one or more dynamic actions.', + operations=[ + { +diff --git a/mistral/tests/unit/api/v2/test_code_sources.py b/mistral/tests/unit/api/v2/test_code_sources.py +index a5214e27..feaabcd4 100644 +--- a/mistral/tests/unit/api/v2/test_code_sources.py ++++ b/mistral/tests/unit/api/v2/test_code_sources.py +@@ -42,6 +42,10 @@ class TestCodeSourcesController(base.APITest): + def setUp(self): + super(TestCodeSourcesController, self).setUp() + ++ # Default policies restrict all code_source operations to admin. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ + self.addCleanup(db_api.delete_code_sources) + + def test_post(self): +diff --git a/mistral/tests/unit/api/v2/test_dynamic_actions.py b/mistral/tests/unit/api/v2/test_dynamic_actions.py +index 803d6d37..e1a1ea68 100644 +--- a/mistral/tests/unit/api/v2/test_dynamic_actions.py ++++ b/mistral/tests/unit/api/v2/test_dynamic_actions.py +@@ -39,6 +39,11 @@ class TestDynamicActionsController(base.APITest): + def setUp(self): + super(TestDynamicActionsController, self).setUp() + ++ # Default policies restrict all code_source and dynamic_action ++ # operations to admin. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ + resp = self.app.post( + '/v2/code_sources?name=test_dummy_module', + TEST_MODULE_TEXT +diff --git a/mistral/tests/unit/policies/test_code_sources.py b/mistral/tests/unit/policies/test_code_sources.py +index cf534c53..72a9bb28 100644 +--- a/mistral/tests/unit/policies/test_code_sources.py ++++ b/mistral/tests/unit/policies/test_code_sources.py +@@ -41,10 +41,8 @@ class TestCodeSourcePolicy(base.APITest): + self.addCleanup(db_api.delete_code_sources) + + def test_code_source_create_not_allowed(self): +- self.policy.change_policy_definition( +- {"code_sources:create": "role:FAKE"} +- ) +- ++ # Default policy requires admin_only for create. ++ # A non-admin user should be denied. + resp = self.app.post( + '/v2/code_sources?name=%s&namespace=%s' % + (MODULE_NAME, NAMESPACE), +@@ -56,9 +54,9 @@ class TestCodeSourcePolicy(base.APITest): + self.assertEqual(403, resp.status_int) + + def test_code_source_create_allowed(self): +- self.policy.change_policy_definition( +- {"code_sources:create": "role:FAKE or rule:admin_or_owner"} +- ) ++ # Default policy requires admin_only. An admin should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) + + resp = self.app.post( + '/v2/code_sources?name=%s&namespace=%s' % +@@ -124,7 +122,10 @@ class TestCodeSourcePolicy(base.APITest): + self.assertEqual(403, resp.status_int) + + def test_code_source_update_public_allowed(self): +- # Create a private code source first. ++ # Create a private code source as admin first. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ + self.app.post( + '/v2/code_sources?name=%s&namespace=%s' % + (MODULE_NAME, NAMESPACE), +@@ -133,9 +134,6 @@ class TestCodeSourcePolicy(base.APITest): + ) + + # Update to public as admin. +- self.ctx.is_admin = True +- self.addCleanup(setattr, self.ctx, 'is_admin', False) +- + resp = self.app.put( + '/v2/code_sources?identifier=%s&namespace=%s&scope=public' % + (MODULE_NAME, NAMESPACE), +diff --git a/mistral/tests/unit/policies/test_dynamic_actions.py b/mistral/tests/unit/policies/test_dynamic_actions.py +new file mode 100644 +index 00000000..432754a0 +--- /dev/null ++++ b/mistral/tests/unit/policies/test_dynamic_actions.py +@@ -0,0 +1,202 @@ ++# Copyright 2026 - All rights reserved. ++# ++# Licensed under the Apache License, Version 2.0 (the "License"); you may ++# not use this file except in compliance with the License. You may obtain ++# a copy of the License at ++# ++# http://www.apache.org/licenses/LICENSE-2.0 ++# ++# Unless required by applicable law or agreed to in writing, software ++# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT ++# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the ++# License for the specific language governing permissions and limitations ++# under the License. ++ ++ ++from mistral.db.v2 import api as db_api ++from mistral.tests.unit.api import base ++from mistral.tests.unit.mstrlfixtures import policy_fixtures ++ ++TEST_MODULE_TEXT = """ ++from mistral_lib import actions ++ ++class DummyAction(actions.Action): ++ def run(self, context): ++ return "Hello from the dummy action 1!" ++ ++ def test(self, context): ++ return None ++""" ++ ++ ++class TestDynamicActionPolicy(base.APITest): ++ """Test dynamic action related policies ++ ++ Policies to test: ++ - dynamic_actions:create ++ - dynamic_actions:delete ++ - dynamic_actions:get ++ - dynamic_actions:list ++ - dynamic_actions:update ++ """ ++ ++ def setUp(self): ++ super(TestDynamicActionPolicy, self).setUp() ++ ++ self.policy = self.useFixture(policy_fixtures.PolicyFixture()) ++ ++ # Create a code source as admin so dynamic action tests have ++ # something to reference. ++ self.ctx.is_admin = True ++ ++ resp = self.app.post( ++ '/v2/code_sources?name=test_dummy_module', ++ TEST_MODULE_TEXT, ++ headers={'Content-Type': 'text/plain'} ++ ) ++ ++ self.code_source_id = resp.json['id'] ++ ++ self.ctx.is_admin = False ++ ++ self.addCleanup(db_api.delete_code_sources) ++ self.addCleanup(db_api.delete_dynamic_action_definitions) ++ ++ def _create_dynamic_action_as_admin(self): ++ self.ctx.is_admin = True ++ ++ resp = self.app.post_json( ++ '/v2/dynamic_actions', ++ { ++ 'name': 'dummy_action', ++ 'class_name': 'DummyAction', ++ 'code_source_id': self.code_source_id ++ } ++ ) ++ ++ self.ctx.is_admin = False ++ ++ return resp ++ ++ def test_dynamic_action_create_not_allowed(self): ++ # Default policy requires admin_only for create. ++ # A non-admin user should be denied. ++ resp = self.app.post_json( ++ '/v2/dynamic_actions', ++ { ++ 'name': 'dummy_action', ++ 'class_name': 'DummyAction', ++ 'code_source_id': self.code_source_id ++ }, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_dynamic_action_create_allowed(self): ++ # Default policy requires admin_only. An admin should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ resp = self.app.post_json( ++ '/v2/dynamic_actions', ++ { ++ 'name': 'dummy_action', ++ 'class_name': 'DummyAction', ++ 'code_source_id': self.code_source_id ++ } ++ ) ++ ++ self.assertEqual(201, resp.status_int) ++ ++ def test_dynamic_action_get_not_allowed(self): ++ # Create as admin first, then try to get as non-admin. ++ self._create_dynamic_action_as_admin() ++ ++ resp = self.app.get( ++ '/v2/dynamic_actions/dummy_action', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_dynamic_action_get_allowed(self): ++ self._create_dynamic_action_as_admin() ++ ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ resp = self.app.get('/v2/dynamic_actions/dummy_action') ++ ++ self.assertEqual(200, resp.status_int) ++ ++ def test_dynamic_action_list_not_allowed(self): ++ resp = self.app.get( ++ '/v2/dynamic_actions', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_dynamic_action_list_allowed(self): ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ resp = self.app.get('/v2/dynamic_actions') ++ ++ self.assertEqual(200, resp.status_int) ++ ++ def test_dynamic_action_update_not_allowed(self): ++ self._create_dynamic_action_as_admin() ++ ++ resp = self.app.put_json( ++ '/v2/dynamic_actions', ++ { ++ 'name': 'dummy_action', ++ 'class_name': 'NewDummyAction' ++ }, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_dynamic_action_update_allowed(self): ++ self._create_dynamic_action_as_admin() ++ ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ resp = self.app.put_json( ++ '/v2/dynamic_actions', ++ { ++ 'name': 'dummy_action', ++ 'class_name': 'NewDummyAction' ++ } ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ def test_dynamic_action_delete_not_allowed(self): ++ self._create_dynamic_action_as_admin() ++ ++ resp = self.app.delete( ++ '/v2/dynamic_actions/dummy_action', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_dynamic_action_delete_allowed(self): ++ self._create_dynamic_action_as_admin() ++ ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ self.app.delete('/v2/dynamic_actions/dummy_action') ++ ++ resp = self.app.get( ++ '/v2/dynamic_actions/dummy_action', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(404, resp.status_int) +diff --git a/releasenotes/notes/restrict-code-sources-dynamic-actions-to-admin-c0cff611f60a47e9.yaml b/releasenotes/notes/restrict-code-sources-dynamic-actions-to-admin-c0cff611f60a47e9.yaml +new file mode 100644 +index 00000000..655d5b9a +--- /dev/null ++++ b/releasenotes/notes/restrict-code-sources-dynamic-actions-to-admin-c0cff611f60a47e9.yaml +@@ -0,0 +1,9 @@ ++--- ++security: ++ - | ++ All ``code_sources`` and ``dynamic_actions`` API policies are now restricted ++ to admin users only (``admin_only``). Previously, these policies defaulted ++ to ``admin_or_owner``, allowing any project owner to create, read, update, ++ and delete code sources and dynamic actions. Operators who need to restore ++ the previous behavior can override the relevant policies in their ++ ``policy.yaml``. +-- +2.43.0 + + +From 13114dd99cb867d598d8339b7f5c44dcc1115076 Mon Sep 17 00:00:00 2001 +From: Arnaud Morin +Date: Mon, 30 Mar 2026 12:17:42 +0200 +Subject: [PATCH 5/8] Add dynamic_actions publicize policy and enforcement + +Add a new dynamic_actions:publicize policy (admin_only) and enforce it +on both create and update operations when scope is public. The POST +endpoint was also missing the scope field in the DB insert. + +Co-Authored-By: Claude Opus 4.6 (1M context) +Signed-off-by: Arnaud Morin +Change-Id: I3dd3c2d68fd1e945c2639c20b4fe1fd745877c7c +--- + mistral/api/controllers/v2/dynamic_action.py | 7 ++ + mistral/policies/dynamic_actions.py | 15 ++++ + .../unit/policies/test_dynamic_actions.py | 68 +++++++++++++++++++ + ...ons-publicize-policy-00fc39d80e0541b8.yaml | 8 +++ + 4 files changed, 98 insertions(+) + create mode 100644 releasenotes/notes/add-dynamic-actions-publicize-policy-00fc39d80e0541b8.yaml + +diff --git a/mistral/api/controllers/v2/dynamic_action.py b/mistral/api/controllers/v2/dynamic_action.py +index 44f98cc2..59078122 100644 +--- a/mistral/api/controllers/v2/dynamic_action.py ++++ b/mistral/api/controllers/v2/dynamic_action.py +@@ -50,6 +50,9 @@ class DynamicActionsController(rest.RestController, hooks.HookController): + """ + acl.enforce('dynamic_actions:create', context.ctx()) + ++ if dyn_action.scope == 'public': ++ acl.enforce('dynamic_actions:publicize', context.ctx()) ++ + LOG.debug('Creating dynamic action [action=%s]', dyn_action) + + if not dyn_action.code_source_id and not dyn_action.code_source_name: +@@ -74,6 +77,7 @@ class DynamicActionsController(rest.RestController, hooks.HookController): + 'name': dyn_action.name, + 'namespace': dyn_action.namespace, + 'class_name': dyn_action.class_name, ++ 'scope': dyn_action.scope or 'private', + 'code_source_id': code_source.id, + 'code_source_name': code_source.name + } +@@ -93,6 +97,9 @@ class DynamicActionsController(rest.RestController, hooks.HookController): + """ + acl.enforce('dynamic_actions:update', context.ctx()) + ++ if dyn_action.scope == 'public': ++ acl.enforce('dynamic_actions:publicize', context.ctx()) ++ + LOG.debug('Updating dynamic action [action=%s]', dyn_action) + + if not dyn_action.id and not dyn_action.name: +diff --git a/mistral/policies/dynamic_actions.py b/mistral/policies/dynamic_actions.py +index fabada7c..f7b44e5f 100644 +--- a/mistral/policies/dynamic_actions.py ++++ b/mistral/policies/dynamic_actions.py +@@ -65,6 +65,21 @@ rules = [ + } + ] + ), ++ policy.DocumentedRuleDefault( ++ name=ACTIONS % 'publicize', ++ check_str=base.RULE_ADMIN_ONLY, ++ description='Make a dynamic action publicly available.', ++ operations=[ ++ { ++ 'path': BASE_PATH, ++ 'method': 'POST' ++ }, ++ { ++ 'path': BASE_PATH, ++ 'method': 'PUT' ++ } ++ ] ++ ), + policy.DocumentedRuleDefault( + name=ACTIONS % 'update', + check_str=base.RULE_ADMIN_ONLY, +diff --git a/mistral/tests/unit/policies/test_dynamic_actions.py b/mistral/tests/unit/policies/test_dynamic_actions.py +index 432754a0..6ee07081 100644 +--- a/mistral/tests/unit/policies/test_dynamic_actions.py ++++ b/mistral/tests/unit/policies/test_dynamic_actions.py +@@ -34,6 +34,7 @@ class TestDynamicActionPolicy(base.APITest): + + Policies to test: + - dynamic_actions:create ++ - dynamic_actions:publicize (on POST & PUT) + - dynamic_actions:delete + - dynamic_actions:get + - dynamic_actions:list +@@ -109,6 +110,73 @@ class TestDynamicActionPolicy(base.APITest): + + self.assertEqual(201, resp.status_int) + ++ def test_dynamic_action_create_public_not_allowed(self): ++ # Default policy requires admin_only for publicize. ++ # A non-admin user should be denied. ++ resp = self.app.post_json( ++ '/v2/dynamic_actions', ++ { ++ 'name': 'dummy_action', ++ 'class_name': 'DummyAction', ++ 'code_source_id': self.code_source_id, ++ 'scope': 'public' ++ }, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_dynamic_action_create_public_allowed(self): ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ resp = self.app.post_json( ++ '/v2/dynamic_actions', ++ { ++ 'name': 'dummy_action', ++ 'class_name': 'DummyAction', ++ 'code_source_id': self.code_source_id, ++ 'scope': 'public' ++ } ++ ) ++ ++ self.assertEqual(201, resp.status_int) ++ ++ def test_dynamic_action_update_public_not_allowed(self): ++ # Create as admin first, then try to update to public as non-admin. ++ self._create_dynamic_action_as_admin() ++ ++ resp = self.app.put_json( ++ '/v2/dynamic_actions', ++ { ++ 'name': 'dummy_action', ++ 'class_name': 'DummyAction', ++ 'scope': 'public' ++ }, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_dynamic_action_update_public_allowed(self): ++ self._create_dynamic_action_as_admin() ++ ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ resp = self.app.put_json( ++ '/v2/dynamic_actions', ++ { ++ 'name': 'dummy_action', ++ 'class_name': 'DummyAction', ++ 'scope': 'public' ++ } ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ + def test_dynamic_action_get_not_allowed(self): + # Create as admin first, then try to get as non-admin. + self._create_dynamic_action_as_admin() +diff --git a/releasenotes/notes/add-dynamic-actions-publicize-policy-00fc39d80e0541b8.yaml b/releasenotes/notes/add-dynamic-actions-publicize-policy-00fc39d80e0541b8.yaml +new file mode 100644 +index 00000000..0a42385c +--- /dev/null ++++ b/releasenotes/notes/add-dynamic-actions-publicize-policy-00fc39d80e0541b8.yaml +@@ -0,0 +1,8 @@ ++--- ++security: ++ - | ++ Added a new ``dynamic_actions:publicize`` policy (``admin_only``) and ++ enforcement on both create and update operations when scope is public. ++ Previously, the dynamic actions POST endpoint did not persist the scope ++ field and neither POST nor PUT enforced a publicize policy, allowing ++ any user with create or update access to make dynamic actions public. +-- +2.43.0 + + +From 3492e92b45043e4f0958184aa82ef34c648c7215 Mon Sep 17 00:00:00 2001 +From: Arnaud Morin +Date: Mon, 30 Mar 2026 12:28:11 +0200 +Subject: [PATCH 6/8] Add workbooks publicize policy and enforcement + +Add a new workbooks:publicize policy (admin_only) and enforce it on +both create and update operations when scope is public. Previously, +any project owner could make workbooks public without restriction. + +Co-Authored-By: Claude Opus 4.6 (1M context) +Signed-off-by: Arnaud Morin +Change-Id: I51d19d3034f1e86f668d680a03492abb1b7bad41 +--- + mistral/api/controllers/v2/workbook.py | 6 + + mistral/policies/workbook.py | 15 ++ + mistral/tests/unit/api/v2/test_workbooks.py | 10 + + mistral/tests/unit/policies/test_workbooks.py | 254 ++++++++++++++++++ + ...oks-publicize-policy-147989c836b94729.yaml | 7 + + 5 files changed, 292 insertions(+) + create mode 100644 mistral/tests/unit/policies/test_workbooks.py + create mode 100644 releasenotes/notes/add-workbooks-publicize-policy-147989c836b94729.yaml + +diff --git a/mistral/api/controllers/v2/workbook.py b/mistral/api/controllers/v2/workbook.py +index c3f3a67e..df83fffd 100644 +--- a/mistral/api/controllers/v2/workbook.py ++++ b/mistral/api/controllers/v2/workbook.py +@@ -88,6 +88,9 @@ class WorkbooksController(rest.RestController, hooks.HookController): + definition = pecan.request.text + scope = pecan.request.GET.get('scope', 'private') + ++ if scope == 'public': ++ acl.enforce('workbooks:publicize', context.ctx()) ++ + # If "skip_validation" is present in the query string parameters + # then workflow language validation will be disabled. + skip_validation = 'skip_validation' in pecan.request.GET +@@ -120,6 +123,9 @@ class WorkbooksController(rest.RestController, hooks.HookController): + definition = pecan.request.text + scope = pecan.request.GET.get('scope', 'private') + ++ if scope == 'public': ++ acl.enforce('workbooks:publicize', context.ctx()) ++ + # If "skip_validation" is present in the query string parameters + # then workflow language validation will be disabled. + skip_validation = 'skip_validation' in pecan.request.GET +diff --git a/mistral/policies/workbook.py b/mistral/policies/workbook.py +index 81f583a2..694c74bb 100644 +--- a/mistral/policies/workbook.py ++++ b/mistral/policies/workbook.py +@@ -62,6 +62,21 @@ rules = [ + } + ] + ), ++ policy.DocumentedRuleDefault( ++ name=WORKBOOKS % 'publicize', ++ check_str=base.RULE_ADMIN_ONLY, ++ description='Make a workbook publicly available.', ++ operations=[ ++ { ++ 'path': '/v2/workbooks', ++ 'method': 'POST' ++ }, ++ { ++ 'path': '/v2/workbooks', ++ 'method': 'PUT' ++ } ++ ] ++ ), + policy.DocumentedRuleDefault( + name=WORKBOOKS % 'update', + check_str=base.RULE_ADMIN_OR_OWNER, +diff --git a/mistral/tests/unit/api/v2/test_workbooks.py b/mistral/tests/unit/api/v2/test_workbooks.py +index 93081596..d6293be7 100644 +--- a/mistral/tests/unit/api/v2/test_workbooks.py ++++ b/mistral/tests/unit/api/v2/test_workbooks.py +@@ -269,6 +269,11 @@ class TestWorkbooksController(base.APITest): + mock_wf.return_value = WF_DB + mock_action.return_value = ACTION_DB + ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ + resp = self.app.put( + '/v2/workbooks?scope=public', + UPDATED_WORKBOOK_DEF, +@@ -359,6 +364,11 @@ class TestWorkbooksController(base.APITest): + mock_wf.return_value = WF_DB + mock_action.return_value = ACTION_DB + ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ + resp = self.app.post( + '/v2/workbooks?scope=public', + WORKBOOK_DEF, +diff --git a/mistral/tests/unit/policies/test_workbooks.py b/mistral/tests/unit/policies/test_workbooks.py +new file mode 100644 +index 00000000..ded10b61 +--- /dev/null ++++ b/mistral/tests/unit/policies/test_workbooks.py +@@ -0,0 +1,254 @@ ++# Copyright 2026 - All rights reserved. ++# ++# Licensed under the Apache License, Version 2.0 (the "License"); you may ++# not use this file except in compliance with the License. You may obtain ++# a copy of the License at ++# ++# http://www.apache.org/licenses/LICENSE-2.0 ++# ++# Unless required by applicable law or agreed to in writing, software ++# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT ++# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the ++# License for the specific language governing permissions and limitations ++# under the License. ++ ++ ++import datetime ++from unittest import mock ++ ++from mistral.db.v2 import api as db_api ++from mistral.db.v2.sqlalchemy import models ++from mistral.services import workbooks ++from mistral.tests.unit.api import base ++from mistral.tests.unit.mstrlfixtures import policy_fixtures ++ ++MOCK_DELETE = mock.MagicMock(return_value=None) ++ ++WB_DEFINITION = """ ++--- ++version: '2.0' ++name: 'test' ++ ++workflows: ++ flow: ++ type: direct ++ tasks: ++ task1: ++ action: std.echo output="Hi" ++""" ++ ++WB_DB = models.Workbook( ++ id='123e4567-e89b-12d3-a456-426655440000', ++ name='test', ++ definition=WB_DEFINITION, ++ created_at=datetime.datetime(1970, 1, 1), ++ updated_at=datetime.datetime(1970, 1, 1), ++ spec={'name': 'test'} ++) ++MOCK_WB = mock.MagicMock(return_value=WB_DB) ++ ++ ++class TestWorkbookPolicy(base.APITest): ++ """Test workbook related policies ++ ++ Policies to test: ++ - workbooks:create ++ - workbooks:publicize (on POST & PUT) ++ - workbooks:delete ++ - workbooks:get ++ - workbooks:list ++ - workbooks:update ++ """ ++ ++ def setUp(self): ++ self.policy = self.useFixture(policy_fixtures.PolicyFixture()) ++ super(TestWorkbookPolicy, self).setUp() ++ ++ @mock.patch.object(workbooks, "create_workbook_v2") ++ def test_workbook_create_not_allowed(self, mock_obj): ++ self.policy.change_policy_definition( ++ {"workbooks:create": "role:FAKE"} ++ ) ++ resp = self.app.post( ++ '/v2/workbooks', ++ WB_DEFINITION, ++ headers={'Content-Type': 'text/plain'}, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(workbooks, "create_workbook_v2") ++ def test_workbook_create_allowed(self, mock_obj): ++ mock_obj.return_value = WB_DB ++ ++ self.policy.change_policy_definition( ++ {"workbooks:create": "role:FAKE or rule:admin_or_owner"} ++ ) ++ resp = self.app.post( ++ '/v2/workbooks', ++ WB_DEFINITION, ++ headers={'Content-Type': 'text/plain'} ++ ) ++ ++ self.assertEqual(201, resp.status_int) ++ ++ @mock.patch.object(workbooks, "create_workbook_v2") ++ def test_workbook_create_public_not_allowed(self, mock_obj): ++ # Default policy requires admin_only for publicize. ++ # The default test context has is_admin=False, so a regular user ++ # (project owner) should be denied. ++ resp = self.app.post( ++ '/v2/workbooks?scope=public', ++ WB_DEFINITION, ++ headers={'Content-Type': 'text/plain'}, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(workbooks, "create_workbook_v2") ++ def test_workbook_create_public_allowed(self, mock_obj): ++ mock_obj.return_value = WB_DB ++ ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ resp = self.app.post( ++ '/v2/workbooks?scope=public', ++ WB_DEFINITION, ++ headers={'Content-Type': 'text/plain'} ++ ) ++ ++ self.assertEqual(201, resp.status_int) ++ ++ @mock.patch.object(db_api, "delete_workbook", MOCK_DELETE) ++ @mock.patch.object(db_api, "get_workbook", MOCK_WB) ++ def test_workbook_delete_not_allowed(self): ++ self.policy.change_policy_definition( ++ {"workbooks:delete": "role:FAKE"} ++ ) ++ resp = self.app.delete( ++ '/v2/workbooks/test', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, "delete_workbook", MOCK_DELETE) ++ @mock.patch.object(db_api, "get_workbook", MOCK_WB) ++ def test_workbook_delete_allowed(self): ++ self.policy.change_policy_definition( ++ {"workbooks:delete": "role:FAKE or rule:admin_or_owner"} ++ ) ++ resp = self.app.delete( ++ '/v2/workbooks/test' ++ ) ++ ++ self.assertEqual(204, resp.status_int) ++ ++ @mock.patch.object(db_api, "get_workbook", MOCK_WB) ++ def test_workbook_get_not_allowed(self): ++ self.policy.change_policy_definition( ++ {"workbooks:get": "role:FAKE"} ++ ) ++ resp = self.app.get( ++ '/v2/workbooks/test', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, "get_workbook", MOCK_WB) ++ def test_workbook_get_allowed(self): ++ self.policy.change_policy_definition( ++ {"workbooks:get": "role:FAKE or rule:admin_or_owner"} ++ ) ++ resp = self.app.get( ++ '/v2/workbooks/test' ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ def test_workbook_list_not_allowed(self): ++ self.policy.change_policy_definition( ++ {"workbooks:list": "role:FAKE"} ++ ) ++ resp = self.app.get( ++ '/v2/workbooks', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_workbook_list_allowed(self): ++ self.policy.change_policy_definition( ++ {"workbooks:list": "role:FAKE or rule:admin_or_owner"} ++ ) ++ resp = self.app.get( ++ '/v2/workbooks' ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ @mock.patch.object(workbooks, "update_workbook_v2") ++ def test_workbook_update_not_allowed(self, mock_obj): ++ self.policy.change_policy_definition( ++ {"workbooks:update": "role:FAKE"} ++ ) ++ resp = self.app.put( ++ '/v2/workbooks', ++ WB_DEFINITION, ++ headers={'Content-Type': 'text/plain'}, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(workbooks, "update_workbook_v2") ++ def test_workbook_update_allowed(self, mock_obj): ++ mock_obj.return_value = WB_DB ++ ++ self.policy.change_policy_definition( ++ {"workbooks:update": "role:FAKE or rule:admin_or_owner"} ++ ) ++ resp = self.app.put( ++ '/v2/workbooks', ++ WB_DEFINITION, ++ headers={'Content-Type': 'text/plain'} ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ @mock.patch.object(workbooks, "update_workbook_v2") ++ def test_workbook_update_public_not_allowed(self, mock_obj): ++ # Default policy requires admin_only for publicize. ++ # The default test context has is_admin=False, so a regular user ++ # (project owner) should be denied. ++ resp = self.app.put( ++ '/v2/workbooks?scope=public', ++ WB_DEFINITION, ++ headers={'Content-Type': 'text/plain'}, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(workbooks, "update_workbook_v2") ++ def test_workbook_update_public_allowed(self, mock_obj): ++ mock_obj.return_value = WB_DB ++ ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ resp = self.app.put( ++ '/v2/workbooks?scope=public', ++ WB_DEFINITION, ++ headers={'Content-Type': 'text/plain'} ++ ) ++ ++ self.assertEqual(200, resp.status_int) +diff --git a/releasenotes/notes/add-workbooks-publicize-policy-147989c836b94729.yaml b/releasenotes/notes/add-workbooks-publicize-policy-147989c836b94729.yaml +new file mode 100644 +index 00000000..391ae4bc +--- /dev/null ++++ b/releasenotes/notes/add-workbooks-publicize-policy-147989c836b94729.yaml +@@ -0,0 +1,7 @@ ++--- ++security: ++ - | ++ Added a new ``workbooks:publicize`` policy (``admin_only``) and ++ enforcement on both create and update operations when scope is public. ++ Previously, any project owner could make workbooks public as there was ++ no publicize policy check on the workbook endpoints. +-- +2.43.0 + + +From 4dce6ce81a726368b713aa0555b7e9502297b8f1 Mon Sep 17 00:00:00 2001 +From: Arnaud Morin +Date: Mon, 30 Mar 2026 13:57:18 +0200 +Subject: [PATCH 7/8] Add cron_triggers publicize policy and enforcement + +Add a new cron_triggers:publicize policy (admin_only) and enforce it +on create when scope is public. The scope field from the request body +is now properly passed through to the service layer and persisted in +the database, instead of being hardcoded to 'private'. + +Co-Authored-By: Claude Opus 4.6 (1M context) +Signed-off-by: Arnaud Morin +Change-Id: I39744c00fc155b39b3afb8bcdeb95098b80060f1 +--- + mistral/api/controllers/v2/cron_trigger.py | 7 +- + mistral/policies/cron_trigger.py | 11 + + mistral/services/triggers.py | 5 +- + .../tests/unit/policies/test_cron_triggers.py | 237 ++++++++++++++++++ + ...ers-publicize-policy-54e4ce90d7fc46b8.yaml | 8 + + 5 files changed, 265 insertions(+), 3 deletions(-) + create mode 100644 mistral/tests/unit/policies/test_cron_triggers.py + create mode 100644 releasenotes/notes/add-cron-triggers-publicize-policy-54e4ce90d7fc46b8.yaml + +diff --git a/mistral/api/controllers/v2/cron_trigger.py b/mistral/api/controllers/v2/cron_trigger.py +index eb17e5c4..b4d10ffa 100644 +--- a/mistral/api/controllers/v2/cron_trigger.py ++++ b/mistral/api/controllers/v2/cron_trigger.py +@@ -72,6 +72,10 @@ class CronTriggersController(rest.RestController): + LOG.debug('Create cron trigger: %s', cron_trigger) + + values = cron_trigger.to_dict() ++ scope = values.get('scope', 'private') ++ ++ if scope == 'public': ++ acl.enforce('cron_triggers:publicize', context.ctx()) + + db_model = rest_utils.rest_retry_on_db_error( + triggers.create_cron_trigger +@@ -83,7 +87,8 @@ class CronTriggersController(rest.RestController): + pattern=values.get('pattern'), + first_time=values.get('first_execution_time'), + count=values.get('remaining_executions'), +- workflow_id=values.get('workflow_id') ++ workflow_id=values.get('workflow_id'), ++ scope=scope + ) + + return resources.CronTrigger.from_db_model(db_model) +diff --git a/mistral/policies/cron_trigger.py b/mistral/policies/cron_trigger.py +index 742df53e..c6c6cc31 100644 +--- a/mistral/policies/cron_trigger.py ++++ b/mistral/policies/cron_trigger.py +@@ -62,6 +62,17 @@ rules = [ + } + ] + ), ++ policy.DocumentedRuleDefault( ++ name=CRON_TRIGGERS % 'publicize', ++ check_str=base.RULE_ADMIN_ONLY, ++ description='Make a cron trigger publicly available.', ++ operations=[ ++ { ++ 'path': '/v2/cron_triggers', ++ 'method': 'POST' ++ } ++ ] ++ ), + policy.DocumentedRuleDefault( + name=CRON_TRIGGERS % 'list:all_projects', + check_str=base.RULE_ADMIN_ONLY, +diff --git a/mistral/services/triggers.py b/mistral/services/triggers.py +index 8ee43ab3..0ccc9455 100644 +--- a/mistral/services/triggers.py ++++ b/mistral/services/triggers.py +@@ -72,7 +72,8 @@ def validate_cron_trigger_input(pattern, first_time, count): + + def create_cron_trigger(name, workflow_name, workflow_input, + workflow_params=None, pattern=None, first_time=None, +- count=None, start_time=None, workflow_id=None): ++ count=None, start_time=None, workflow_id=None, ++ scope='private'): + if not start_time: + start_time = timeutils.utcnow() + +@@ -123,7 +124,7 @@ def create_cron_trigger(name, workflow_name, workflow_input, + 'workflow_id': wf_def.id, + 'workflow_input': workflow_input or {}, + 'workflow_params': workflow_params or {}, +- 'scope': 'private' ++ 'scope': scope + } + + security.add_trust_id(trigger_parameters) +diff --git a/mistral/tests/unit/policies/test_cron_triggers.py b/mistral/tests/unit/policies/test_cron_triggers.py +new file mode 100644 +index 00000000..100ad332 +--- /dev/null ++++ b/mistral/tests/unit/policies/test_cron_triggers.py +@@ -0,0 +1,237 @@ ++# Copyright 2026 - All rights reserved. ++# ++# Licensed under the Apache License, Version 2.0 (the "License"); you may ++# not use this file except in compliance with the License. You may obtain ++# a copy of the License at ++# ++# http://www.apache.org/licenses/LICENSE-2.0 ++# ++# Unless required by applicable law or agreed to in writing, software ++# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT ++# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the ++# License for the specific language governing permissions and limitations ++# under the License. ++ ++ ++from unittest import mock ++ ++from mistral.db.v2 import api as db_api ++from mistral.db.v2.sqlalchemy import models ++from mistral.tests.unit.api import base ++from mistral.tests.unit.mstrlfixtures import policy_fixtures ++ ++WF = models.WorkflowDefinition( ++ spec={ ++ 'version': '2.0', ++ 'name': 'my_wf', ++ 'tasks': { ++ 'task1': { ++ 'action': 'std.noop' ++ } ++ } ++ } ++) ++WF.update({'id': '123e4567-e89b-12d3-a456-426655440000', 'name': 'my_wf'}) ++ ++TRIGGER = { ++ 'name': 'my_cron_trigger', ++ 'pattern': '* * * * *', ++ 'workflow_name': 'my_wf', ++ 'workflow_id': '123e4567-e89b-12d3-a456-426655440000', ++ 'workflow_input': '{}', ++ 'workflow_params': '{}', ++ 'scope': 'private', ++ 'remaining_executions': 42 ++} ++ ++TRIGGER_DB = models.CronTrigger() ++TRIGGER_DB.update({ ++ 'id': '02abb422-55ef-4bb2-8cb9-217a583a6a3f', ++ 'name': 'my_cron_trigger', ++ 'pattern': '* * * * *', ++ 'workflow_name': 'my_wf', ++ 'workflow_id': '123e4567-e89b-12d3-a456-426655440000', ++ 'workflow_input': {}, ++ 'workflow_params': {}, ++ 'scope': 'private', ++ 'remaining_executions': 42 ++}) ++ ++MOCK_WF = mock.MagicMock(return_value=WF) ++MOCK_TRIGGER = mock.MagicMock(return_value=TRIGGER_DB) ++MOCK_TRIGGERS = mock.MagicMock(return_value=[TRIGGER_DB]) ++MOCK_DELETE = mock.MagicMock(return_value=1) ++ ++ ++class TestCronTriggerPolicy(base.APITest): ++ """Test cron trigger related policies ++ ++ Policies to test: ++ - cron_triggers:create ++ - cron_triggers:publicize (on POST) ++ - cron_triggers:delete ++ - cron_triggers:get ++ - cron_triggers:list ++ - cron_triggers:list:all_projects ++ """ ++ ++ def setUp(self): ++ self.policy = self.useFixture(policy_fixtures.PolicyFixture()) ++ super(TestCronTriggerPolicy, self).setUp() ++ ++ @mock.patch.object(db_api, "get_workflow_definition", MOCK_WF) ++ @mock.patch.object(db_api, "create_cron_trigger") ++ def test_cron_trigger_create_not_allowed(self, mock_obj): ++ self.policy.change_policy_definition( ++ {"cron_triggers:create": "role:FAKE"} ++ ) ++ resp = self.app.post_json( ++ '/v2/cron_triggers', ++ TRIGGER, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, "get_workflow_definition", MOCK_WF) ++ @mock.patch.object(db_api, "create_cron_trigger") ++ def test_cron_trigger_create_allowed(self, mock_obj): ++ mock_obj.return_value = TRIGGER_DB ++ ++ self.policy.change_policy_definition( ++ {"cron_triggers:create": "role:FAKE or rule:admin_or_owner"} ++ ) ++ resp = self.app.post_json( ++ '/v2/cron_triggers', ++ TRIGGER ++ ) ++ ++ self.assertEqual(201, resp.status_int) ++ ++ @mock.patch.object(db_api, "get_workflow_definition", MOCK_WF) ++ @mock.patch.object(db_api, "create_cron_trigger") ++ def test_cron_trigger_create_public_not_allowed(self, mock_obj): ++ # Default policy requires admin_only for publicize. ++ # The default test context has is_admin=False, so a regular user ++ # (project owner) should be denied. ++ trigger = dict(TRIGGER, scope='public') ++ ++ resp = self.app.post_json( ++ '/v2/cron_triggers', ++ trigger, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, "get_workflow_definition", MOCK_WF) ++ @mock.patch.object(db_api, "create_cron_trigger") ++ def test_cron_trigger_create_public_allowed(self, mock_obj): ++ mock_obj.return_value = TRIGGER_DB ++ ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ trigger = dict(TRIGGER, scope='public') ++ ++ resp = self.app.post_json( ++ '/v2/cron_triggers', ++ trigger ++ ) ++ ++ self.assertEqual(201, resp.status_int) ++ ++ @mock.patch.object(db_api, "delete_cron_trigger", MOCK_DELETE) ++ @mock.patch.object(db_api, "get_cron_trigger", MOCK_TRIGGER) ++ def test_cron_trigger_delete_not_allowed(self): ++ self.policy.change_policy_definition( ++ {"cron_triggers:delete": "role:FAKE"} ++ ) ++ resp = self.app.delete( ++ '/v2/cron_triggers/my_cron_trigger', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, "delete_cron_trigger", MOCK_DELETE) ++ @mock.patch.object(db_api, "get_cron_trigger", MOCK_TRIGGER) ++ def test_cron_trigger_delete_allowed(self): ++ self.policy.change_policy_definition( ++ {"cron_triggers:delete": "role:FAKE or rule:admin_or_owner"} ++ ) ++ resp = self.app.delete( ++ '/v2/cron_triggers/my_cron_trigger' ++ ) ++ ++ self.assertEqual(204, resp.status_int) ++ ++ @mock.patch.object(db_api, "get_cron_trigger", MOCK_TRIGGER) ++ def test_cron_trigger_get_not_allowed(self): ++ self.policy.change_policy_definition( ++ {"cron_triggers:get": "role:FAKE"} ++ ) ++ resp = self.app.get( ++ '/v2/cron_triggers/my_cron_trigger', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, "get_cron_trigger", MOCK_TRIGGER) ++ def test_cron_trigger_get_allowed(self): ++ self.policy.change_policy_definition( ++ {"cron_triggers:get": "role:FAKE or rule:admin_or_owner"} ++ ) ++ resp = self.app.get( ++ '/v2/cron_triggers/my_cron_trigger' ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ def test_cron_trigger_list_not_allowed(self): ++ self.policy.change_policy_definition( ++ {"cron_triggers:list": "role:FAKE"} ++ ) ++ resp = self.app.get( ++ '/v2/cron_triggers', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_cron_trigger_list_allowed(self): ++ self.policy.change_policy_definition( ++ {"cron_triggers:list": "role:FAKE or rule:admin_or_owner"} ++ ) ++ resp = self.app.get( ++ '/v2/cron_triggers' ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ def test_cron_trigger_list_all_not_allowed(self): ++ self.policy.change_policy_definition({ ++ "cron_triggers:list": "role:FAKE or rule:admin_or_owner", ++ "cron_triggers:list:all_projects": "role:FAKE" ++ }) ++ resp = self.app.get( ++ '/v2/cron_triggers?all_projects=1', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_cron_trigger_list_all_allowed(self): ++ self.policy.change_policy_definition({ ++ "cron_triggers:list": "role:FAKE or rule:admin_or_owner", ++ "cron_triggers:list:all_projects": ++ "role:FAKE or rule:admin_or_owner" ++ }) ++ resp = self.app.get( ++ '/v2/cron_triggers?all_projects=1' ++ ) ++ ++ self.assertEqual(200, resp.status_int) +diff --git a/releasenotes/notes/add-cron-triggers-publicize-policy-54e4ce90d7fc46b8.yaml b/releasenotes/notes/add-cron-triggers-publicize-policy-54e4ce90d7fc46b8.yaml +new file mode 100644 +index 00000000..a4d4c097 +--- /dev/null ++++ b/releasenotes/notes/add-cron-triggers-publicize-policy-54e4ce90d7fc46b8.yaml +@@ -0,0 +1,8 @@ ++--- ++security: ++ - | ++ Added a new ``cron_triggers:publicize`` policy (``admin_only``) and ++ enforcement on create when scope is public. Previously, the cron trigger ++ POST endpoint hardcoded the scope to ``private`` and ignored the scope ++ field from the request body, so cron triggers could never be created as ++ public. The scope field is now properly passed through to the database. +-- +2.43.0 + + +From f9f1afa0adef132a4978a1223e302ac8e04bd727 Mon Sep 17 00:00:00 2001 +From: Arnaud Morin +Date: Mon, 30 Mar 2026 14:52:28 +0200 +Subject: [PATCH 8/8] Add environments publicize policy and enforcement + +Add a new environments:publicize policy (admin_only) and enforce it on +both create and update operations when scope is public. The POST endpoint +now also accepts the scope field in the request body, which was previously +rejected by input validation. + +Co-Authored-By: Claude Opus 4.6 (1M context) +Signed-off-by: Arnaud Morin +Change-Id: I44bb9db4aa8011452711410662f557f68eaadcb0 +--- + mistral/api/controllers/v2/environment.py | 8 +- + mistral/policies/environment.py | 15 ++ + .../tests/unit/policies/test_environments.py | 253 ++++++++++++++++++ + ...nts-publicize-policy-50570611d6d24ede.yaml | 8 + + 4 files changed, 283 insertions(+), 1 deletion(-) + create mode 100644 mistral/tests/unit/policies/test_environments.py + create mode 100644 releasenotes/notes/add-environments-publicize-policy-50570611d6d24ede.yaml + +diff --git a/mistral/api/controllers/v2/environment.py b/mistral/api/controllers/v2/environment.py +index 07258d37..df3f0425 100644 +--- a/mistral/api/controllers/v2/environment.py ++++ b/mistral/api/controllers/v2/environment.py +@@ -141,9 +141,12 @@ class EnvironmentController(rest.RestController): + + self._validate_environment( + json.loads(wsme_pecan.pecan.request.body.decode()), +- ['name', 'description', 'variables'] ++ ['name', 'description', 'variables', 'scope'] + ) + ++ if env.scope == 'public': ++ acl.enforce('environments:publicize', context.ctx()) ++ + db_model = rest_utils.rest_retry_on_db_error( + db_api.create_environment + )(env.to_dict()) +@@ -174,6 +177,9 @@ class EnvironmentController(rest.RestController): + ['description', 'variables', 'scope'] + ) + ++ if env.scope == 'public': ++ acl.enforce('environments:publicize', context.ctx()) ++ + db_model = rest_utils.rest_retry_on_db_error( + db_api.update_environment + )(env.name, env.to_dict()) +diff --git a/mistral/policies/environment.py b/mistral/policies/environment.py +index e7476941..f30a3b21 100644 +--- a/mistral/policies/environment.py ++++ b/mistral/policies/environment.py +@@ -62,6 +62,21 @@ rules = [ + } + ] + ), ++ policy.DocumentedRuleDefault( ++ name=ENVIRONMENTS % 'publicize', ++ check_str=base.RULE_ADMIN_ONLY, ++ description='Make an environment publicly available.', ++ operations=[ ++ { ++ 'path': '/v2/environments', ++ 'method': 'POST' ++ }, ++ { ++ 'path': '/v2/environments', ++ 'method': 'PUT' ++ } ++ ] ++ ), + policy.DocumentedRuleDefault( + name=ENVIRONMENTS % 'update', + check_str=base.RULE_ADMIN_OR_OWNER, +diff --git a/mistral/tests/unit/policies/test_environments.py b/mistral/tests/unit/policies/test_environments.py +new file mode 100644 +index 00000000..451ab820 +--- /dev/null ++++ b/mistral/tests/unit/policies/test_environments.py +@@ -0,0 +1,253 @@ ++# Copyright 2026 - All rights reserved. ++# ++# Licensed under the Apache License, Version 2.0 (the "License"); you may ++# not use this file except in compliance with the License. You may obtain ++# a copy of the License at ++# ++# http://www.apache.org/licenses/LICENSE-2.0 ++# ++# Unless required by applicable law or agreed to in writing, software ++# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT ++# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the ++# License for the specific language governing permissions and limitations ++# under the License. ++ ++ ++from unittest import mock ++ ++from mistral.db.v2 import api as db_api ++from mistral.db.v2.sqlalchemy import models ++from mistral.tests.unit.api import base ++from mistral.tests.unit.mstrlfixtures import policy_fixtures ++ ++ENVIRONMENT = { ++ 'name': 'test', ++ 'description': 'my test settings', ++ 'variables': { ++ 'server': 'localhost', ++ 'database': 'test', ++ 'timeout': 600, ++ 'verbose': True ++ } ++} ++ ++ENVIRONMENT_DB = models.Environment( ++ id='123e4567-e89b-12d3-a456-426655440000', ++ name='test', ++ description='my test settings', ++ variables={ ++ 'server': 'localhost', ++ 'database': 'test', ++ 'timeout': 600, ++ 'verbose': True ++ }, ++ scope='private' ++) ++ ++MOCK_ENVIRONMENT = mock.MagicMock(return_value=ENVIRONMENT_DB) ++MOCK_ENVIRONMENTS = mock.MagicMock(return_value=[ENVIRONMENT_DB]) ++MOCK_DELETE = mock.MagicMock(return_value=None) ++ ++ ++class TestEnvironmentPolicy(base.APITest): ++ """Test environment related policies ++ ++ Policies to test: ++ - environments:create ++ - environments:publicize (on POST & PUT) ++ - environments:delete ++ - environments:get ++ - environments:list ++ - environments:update ++ """ ++ ++ def setUp(self): ++ self.policy = self.useFixture(policy_fixtures.PolicyFixture()) ++ super(TestEnvironmentPolicy, self).setUp() ++ ++ @mock.patch.object(db_api, "create_environment") ++ def test_environment_create_not_allowed(self, mock_obj): ++ self.policy.change_policy_definition( ++ {"environments:create": "role:FAKE"} ++ ) ++ resp = self.app.post_json( ++ '/v2/environments', ++ ENVIRONMENT, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, "create_environment") ++ def test_environment_create_allowed(self, mock_obj): ++ mock_obj.return_value = ENVIRONMENT_DB ++ ++ self.policy.change_policy_definition( ++ {"environments:create": "role:FAKE or rule:admin_or_owner"} ++ ) ++ resp = self.app.post_json( ++ '/v2/environments', ++ ENVIRONMENT ++ ) ++ ++ self.assertEqual(201, resp.status_int) ++ ++ @mock.patch.object(db_api, "create_environment") ++ def test_environment_create_public_not_allowed(self, mock_obj): ++ # Default policy requires admin_only for publicize. ++ # The default test context has is_admin=False, so a regular user ++ # (project owner) should be denied. ++ env = dict(ENVIRONMENT, scope='public') ++ ++ resp = self.app.post_json( ++ '/v2/environments', ++ env, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, "create_environment") ++ def test_environment_create_public_allowed(self, mock_obj): ++ mock_obj.return_value = ENVIRONMENT_DB ++ ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ env = dict(ENVIRONMENT, scope='public') ++ ++ resp = self.app.post_json( ++ '/v2/environments', ++ env ++ ) ++ ++ self.assertEqual(201, resp.status_int) ++ ++ @mock.patch.object(db_api, "delete_environment", MOCK_DELETE) ++ def test_environment_delete_not_allowed(self): ++ self.policy.change_policy_definition( ++ {"environments:delete": "role:FAKE"} ++ ) ++ resp = self.app.delete( ++ '/v2/environments/test', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, "delete_environment", MOCK_DELETE) ++ def test_environment_delete_allowed(self): ++ self.policy.change_policy_definition( ++ {"environments:delete": "role:FAKE or rule:admin_or_owner"} ++ ) ++ resp = self.app.delete( ++ '/v2/environments/test' ++ ) ++ ++ self.assertEqual(204, resp.status_int) ++ ++ @mock.patch.object(db_api, "get_environment", MOCK_ENVIRONMENT) ++ def test_environment_get_not_allowed(self): ++ self.policy.change_policy_definition( ++ {"environments:get": "role:FAKE"} ++ ) ++ resp = self.app.get( ++ '/v2/environments/test', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, "get_environment", MOCK_ENVIRONMENT) ++ def test_environment_get_allowed(self): ++ self.policy.change_policy_definition( ++ {"environments:get": "role:FAKE or rule:admin_or_owner"} ++ ) ++ resp = self.app.get( ++ '/v2/environments/test' ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ def test_environment_list_not_allowed(self): ++ self.policy.change_policy_definition( ++ {"environments:list": "role:FAKE"} ++ ) ++ resp = self.app.get( ++ '/v2/environments', ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_environment_list_allowed(self): ++ self.policy.change_policy_definition( ++ {"environments:list": "role:FAKE or rule:admin_or_owner"} ++ ) ++ resp = self.app.get( ++ '/v2/environments' ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ @mock.patch.object(db_api, "update_environment") ++ def test_environment_update_not_allowed(self, mock_obj): ++ self.policy.change_policy_definition( ++ {"environments:update": "role:FAKE"} ++ ) ++ resp = self.app.put_json( ++ '/v2/environments', ++ ENVIRONMENT, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, "update_environment") ++ def test_environment_update_allowed(self, mock_obj): ++ mock_obj.return_value = ENVIRONMENT_DB ++ ++ self.policy.change_policy_definition( ++ {"environments:update": "role:FAKE or rule:admin_or_owner"} ++ ) ++ resp = self.app.put_json( ++ '/v2/environments', ++ ENVIRONMENT ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ @mock.patch.object(db_api, "update_environment") ++ def test_environment_update_public_not_allowed(self, mock_obj): ++ # Default policy requires admin_only for publicize. ++ # The default test context has is_admin=False, so a regular user ++ # (project owner) should be denied. ++ env = dict(ENVIRONMENT, scope='public') ++ ++ resp = self.app.put_json( ++ '/v2/environments', ++ env, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ @mock.patch.object(db_api, "update_environment") ++ def test_environment_update_public_allowed(self, mock_obj): ++ mock_obj.return_value = ENVIRONMENT_DB ++ ++ # Default policy requires admin_only for publicize. ++ # An admin user should be allowed. ++ self.ctx.is_admin = True ++ self.addCleanup(setattr, self.ctx, 'is_admin', False) ++ ++ env = dict(ENVIRONMENT, scope='public') ++ ++ resp = self.app.put_json( ++ '/v2/environments', ++ env ++ ) ++ ++ self.assertEqual(200, resp.status_int) +diff --git a/releasenotes/notes/add-environments-publicize-policy-50570611d6d24ede.yaml b/releasenotes/notes/add-environments-publicize-policy-50570611d6d24ede.yaml +new file mode 100644 +index 00000000..0ce1e220 +--- /dev/null ++++ b/releasenotes/notes/add-environments-publicize-policy-50570611d6d24ede.yaml +@@ -0,0 +1,8 @@ ++--- ++security: ++ - | ++ Added a new ``environments:publicize`` policy (``admin_only``) and ++ enforcement on both create and update operations when scope is public. ++ Previously, the environment POST endpoint did not accept the scope field ++ in the request body and neither POST nor PUT enforced a publicize policy, ++ allowing any user with update access to make environments public. +-- +2.43.0 + diff -Nru mistral-20.0.0/debian/patches/series mistral-20.0.0/debian/patches/series --- mistral-20.0.0/debian/patches/series 2025-07-12 09:27:02.000000000 +0000 +++ mistral-20.0.0/debian/patches/series 2026-05-25 15:18:35.000000000 +0000 @@ -1 +1,3 @@ install-missing-files.patch +cve-2026-41283-stable-2025.1.patch +OSSN-0098_Strip_sensitive_info_from_workflow_execution_context.patch