Version in base suite: 15.0.0-1 Base version: mistral_15.0.0-1 Target version: mistral_15.0.0-1+deb12u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/m/mistral/mistral_15.0.0-1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/m/mistral/mistral_15.0.0-1+deb12u1.dsc changelog | 17 patches/cve-2026-41283-stable-2025.1.patch | 2415 +++++++++++++++++++++++++++++ patches/series | 1 3 files changed, 2433 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp0lf4nq8r/mistral_15.0.0-1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp0lf4nq8r/mistral_15.0.0-1+deb12u1.dsc: no acceptable signature found diff -Nru mistral-15.0.0/debian/changelog mistral-15.0.0/debian/changelog --- mistral-15.0.0/debian/changelog 2022-10-05 20:53:28.000000000 +0000 +++ mistral-15.0.0/debian/changelog 2026-05-25 15:20:47.000000000 +0000 @@ -1,3 +1,20 @@ +mistral (15.0.0-1+deb12u1) bookworm-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) + + -- Thomas Goirand Mon, 25 May 2026 17:20:47 +0200 + mistral (15.0.0-1) unstable; urgency=medium * New upstream release. diff -Nru mistral-15.0.0/debian/patches/cve-2026-41283-stable-2025.1.patch mistral-15.0.0/debian/patches/cve-2026-41283-stable-2025.1.patch --- mistral-15.0.0/debian/patches/cve-2026-41283-stable-2025.1.patch 1970-01-01 00:00:00.000000000 +0000 +++ mistral-15.0.0/debian/patches/cve-2026-41283-stable-2025.1.patch 2026-05-25 15:20:47.000000000 +0000 @@ -0,0 +1,2415 @@ +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 + +Index: mistral/mistral/api/controllers/v2/event_trigger.py +=================================================================== +--- mistral.orig/mistral/api/controllers/v2/event_trigger.py ++++ mistral/mistral/api/controllers/v2/event_trigger.py +@@ -64,7 +64,7 @@ class EventTriggersController(rest.RestC + ) + + 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) + +@@ -97,6 +97,9 @@ class EventTriggersController(rest.RestC + + 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( +Index: mistral/mistral/policies/action.py +=================================================================== +--- mistral.orig/mistral/policies/action.py ++++ mistral/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=[ + { +Index: mistral/mistral/policies/event_trigger.py +=================================================================== +--- mistral.orig/mistral/policies/event_trigger.py ++++ mistral/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' + } + ] + ), +Index: mistral/mistral/policies/workflow.py +=================================================================== +--- mistral.orig/mistral/policies/workflow.py ++++ mistral/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=[ + { +Index: mistral/mistral/tests/unit/api/v2/test_actions.py +=================================================================== +--- mistral.orig/mistral/tests/unit/api/v2/test_actions.py ++++ mistral/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, +Index: mistral/mistral/tests/unit/api/v2/test_workflows.py +=================================================================== +--- mistral.orig/mistral/tests/unit/api/v2/test_workflows.py ++++ mistral/mistral/tests/unit/api/v2/test_workflows.py +@@ -355,6 +355,11 @@ class TestWorkflowsController(base.APITe + 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, +@@ -484,6 +489,11 @@ class TestWorkflowsController(base.APITe + 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, +Index: mistral/mistral/tests/unit/policies/test_actions.py +=================================================================== +--- mistral.orig/mistral/tests/unit/policies/test_actions.py ++++ mistral/mistral/tests/unit/policies/test_actions.py +@@ -89,19 +89,16 @@ 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) + + @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,16 +110,15 @@ 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', + ADHOC_ACTION_YAML, +- headers={'Content-Type': 'text/plain'}, +- expect_errors=True ++ headers={'Content-Type': 'text/plain'} + ) + + self.assertEqual(201, resp.status_int) +@@ -145,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) + +@@ -184,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) + +@@ -212,19 +208,16 @@ 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) + + @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,16 +229,15 @@ 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', + ADHOC_ACTION_YAML, +- headers={'Content-Type': 'text/plain'}, +- expect_errors=True ++ headers={'Content-Type': 'text/plain'} + ) + + self.assertEqual(200, resp.status_int) +Index: mistral/mistral/tests/unit/policies/test_event_triggers.py +=================================================================== +--- /dev/null ++++ mistral/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) +Index: mistral/mistral/tests/unit/policies/test_workflows.py +=================================================================== +--- mistral.orig/mistral/tests/unit/policies/test_workflows.py ++++ mistral/mistral/tests/unit/policies/test_workflows.py +@@ -91,18 +91,16 @@ 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) + + @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,15 +115,15 @@ 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, +- 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,18 +245,16 @@ 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) + + @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,15 +269,15 @@ 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, +- headers={'Content-Type': 'text/plain'}, +- expect_errors=True ++ headers={'Content-Type': 'text/plain'} + ) + + self.assertEqual(200, resp.status_int) +Index: mistral/releasenotes/notes/restrict-publicize-to-admin-only-a7f3c2e9d1b04e58.yaml +=================================================================== +--- /dev/null ++++ mistral/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. +Index: mistral/mistral/api/controllers/v2/code_source.py +=================================================================== +--- mistral.orig/mistral/api/controllers/v2/code_source.py ++++ mistral/mistral/api/controllers/v2/code_source.py +@@ -51,6 +51,9 @@ class CodeSourcesController(rest.RestCon + + 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.RestCon + """ + 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]', +Index: mistral/mistral/policies/code_sources.py +=================================================================== +--- mistral.orig/mistral/policies/code_sources.py ++++ mistral/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=[ + { +@@ -66,8 +66,23 @@ 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, ++ check_str=base.RULE_ADMIN_ONLY, + description='Update one or more code source.', + operations=[ + { +Index: mistral/mistral/tests/unit/policies/test_code_sources.py +=================================================================== +--- /dev/null ++++ mistral/mistral/tests/unit/policies/test_code_sources.py +@@ -0,0 +1,144 @@ ++# 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): ++ # 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), ++ FILE_CONTENT, ++ headers={'Content-Type': 'text/plain'}, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ ++ def test_code_source_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( ++ '/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 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), ++ FILE_CONTENT, ++ headers={'Content-Type': 'text/plain'} ++ ) ++ ++ # Update to public as admin. ++ 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) +Index: mistral/mistral/policies/dynamic_actions.py +=================================================================== +--- mistral.orig/mistral/policies/dynamic_actions.py ++++ mistral/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=[ + { +@@ -66,8 +66,23 @@ 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_OR_OWNER, ++ check_str=base.RULE_ADMIN_ONLY, + description='Update one or more dynamic actions.', + operations=[ + { +Index: mistral/mistral/tests/unit/api/v2/test_code_sources.py +=================================================================== +--- mistral.orig/mistral/tests/unit/api/v2/test_code_sources.py ++++ mistral/mistral/tests/unit/api/v2/test_code_sources.py +@@ -42,6 +42,10 @@ class TestCodeSourcesController(base.API + 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): +Index: mistral/mistral/tests/unit/api/v2/test_dynamic_actions.py +=================================================================== +--- mistral.orig/mistral/tests/unit/api/v2/test_dynamic_actions.py ++++ mistral/mistral/tests/unit/api/v2/test_dynamic_actions.py +@@ -39,6 +39,11 @@ class TestDynamicActionsController(base. + 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 +Index: mistral/mistral/tests/unit/policies/test_dynamic_actions.py +=================================================================== +--- /dev/null ++++ mistral/mistral/tests/unit/policies/test_dynamic_actions.py +@@ -0,0 +1,270 @@ ++# 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:publicize (on POST & PUT) ++ - 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_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() ++ ++ 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) +Index: mistral/releasenotes/notes/restrict-code-sources-dynamic-actions-to-admin-c0cff611f60a47e9.yaml +=================================================================== +--- /dev/null ++++ mistral/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``. +Index: mistral/mistral/api/controllers/v2/dynamic_action.py +=================================================================== +--- mistral.orig/mistral/api/controllers/v2/dynamic_action.py ++++ mistral/mistral/api/controllers/v2/dynamic_action.py +@@ -50,6 +50,9 @@ class DynamicActionsController(rest.Rest + """ + 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.Rest + '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.Rest + """ + 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: +Index: mistral/releasenotes/notes/add-dynamic-actions-publicize-policy-00fc39d80e0541b8.yaml +=================================================================== +--- /dev/null ++++ mistral/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. +Index: mistral/mistral/api/controllers/v2/workbook.py +=================================================================== +--- mistral.orig/mistral/api/controllers/v2/workbook.py ++++ mistral/mistral/api/controllers/v2/workbook.py +@@ -79,6 +79,9 @@ class WorkbooksController(rest.RestContr + 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 +@@ -111,6 +114,9 @@ class WorkbooksController(rest.RestContr + 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 +Index: mistral/mistral/policies/workbook.py +=================================================================== +--- mistral.orig/mistral/policies/workbook.py ++++ mistral/mistral/policies/workbook.py +@@ -63,6 +63,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, + description='Update an workbook.', +Index: mistral/mistral/tests/unit/api/v2/test_workbooks.py +=================================================================== +--- mistral.orig/mistral/tests/unit/api/v2/test_workbooks.py ++++ mistral/mistral/tests/unit/api/v2/test_workbooks.py +@@ -257,6 +257,11 @@ class TestWorkbooksController(base.APITe + 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, +@@ -347,6 +352,11 @@ class TestWorkbooksController(base.APITe + 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, +Index: mistral/mistral/tests/unit/policies/test_workbooks.py +=================================================================== +--- /dev/null ++++ mistral/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) +Index: mistral/releasenotes/notes/add-workbooks-publicize-policy-147989c836b94729.yaml +=================================================================== +--- /dev/null ++++ mistral/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. +Index: mistral/mistral/api/controllers/v2/cron_trigger.py +=================================================================== +--- mistral.orig/mistral/api/controllers/v2/cron_trigger.py ++++ mistral/mistral/api/controllers/v2/cron_trigger.py +@@ -64,6 +64,10 @@ class CronTriggersController(rest.RestCo + 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 +@@ -75,7 +79,8 @@ class CronTriggersController(rest.RestCo + 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) +Index: mistral/mistral/policies/cron_trigger.py +=================================================================== +--- mistral.orig/mistral/policies/cron_trigger.py ++++ mistral/mistral/policies/cron_trigger.py +@@ -63,6 +63,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, + description='Return all cron triggers of all projects.', +Index: mistral/mistral/services/triggers.py +=================================================================== +--- mistral.orig/mistral/services/triggers.py ++++ mistral/mistral/services/triggers.py +@@ -72,7 +72,8 @@ def validate_cron_trigger_input(pattern, + + 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 = datetime.datetime.utcnow() + +@@ -123,7 +124,7 @@ def create_cron_trigger(name, workflow_n + '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) +Index: mistral/mistral/tests/unit/policies/test_cron_triggers.py +=================================================================== +--- /dev/null ++++ mistral/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) +Index: mistral/releasenotes/notes/add-cron-triggers-publicize-policy-54e4ce90d7fc46b8.yaml +=================================================================== +--- /dev/null ++++ mistral/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. +Index: mistral/mistral/api/controllers/v2/environment.py +=================================================================== +--- mistral.orig/mistral/api/controllers/v2/environment.py ++++ mistral/mistral/api/controllers/v2/environment.py +@@ -134,9 +134,12 @@ class EnvironmentController(rest.RestCon + + 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()) +@@ -167,6 +170,9 @@ class EnvironmentController(rest.RestCon + ['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()) +Index: mistral/mistral/policies/environment.py +=================================================================== +--- mistral.orig/mistral/policies/environment.py ++++ mistral/mistral/policies/environment.py +@@ -63,6 +63,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, + description='Update an environment.', +Index: mistral/mistral/tests/unit/policies/test_environments.py +=================================================================== +--- /dev/null ++++ mistral/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) +Index: mistral/releasenotes/notes/add-environments-publicize-policy-50570611d6d24ede.yaml +=================================================================== +--- /dev/null ++++ mistral/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. diff -Nru mistral-15.0.0/debian/patches/series mistral-15.0.0/debian/patches/series --- mistral-15.0.0/debian/patches/series 2022-10-05 20:53:28.000000000 +0000 +++ mistral-15.0.0/debian/patches/series 2026-05-25 15:20:47.000000000 +0000 @@ -1,2 +1,3 @@ install-missing-files.patch repoducible-build.patch +cve-2026-41283-stable-2025.1.patch