Version in base suite: 20.0.0-2+deb13u1 Base version: mistral_20.0.0-2+deb13u1 Target version: mistral_20.0.0-2+deb13u2 Base file: /srv/ftp-master.debian.org/ftp/pool/main/m/mistral/mistral_20.0.0-2+deb13u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/m/mistral/mistral_20.0.0-2+deb13u2.dsc changelog | 44 + patches/CVE-2026-93858_Fix_RCE_via_std.ssh_proxied_proxy_command.patch | 141 +++++ patches/CVE-2026-93861_Only_allow_the_owner_of_a_resource_to_share_it.patch | 139 +++++ patches/CVE-2026-97147_Enforce_access_control_when_updating_action_definitions.patch | 196 +++++++ patches/CVE-2026-97147_Enforce_access_control_when_updating_workbooks_and_environments.patch | 265 ++++++++++ patches/CVE-2026-97147_Fix_create-or-update_of_definitions_hijacking_other_projects_rows.patch | 243 +++++++++ patches/CVE-2026-97147_Resolve_own-project_cron_trigger_in_create_or_update_cron_trigger.patch | 114 ++++ patches/CVE-2026-97147_Resolve_own-project_object_Fix-test-DB-cleanup-for-code-sources-and-dynamic-actions.patch | 29 + patches/CVE-2026-97147_Resolve_own-project_object_when_updating_code_sources_dynamic_actions.patch | 205 +++++++ patches/series | 8 10 files changed, 1384 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp93py08ro/mistral_20.0.0-2+deb13u1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp93py08ro/mistral_20.0.0-2+deb13u2.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 2026-05-25 15:18:35.000000000 +0000 +++ mistral-20.0.0/debian/changelog 2026-10-15 07:30:01.000000000 +0000 @@ -1,3 +1,47 @@ +mistral (20.0.0-2+deb13u2) trixie; urgency=medium + + * CVE-2026-97147: several of Mistral's v2 API write paths resolve the target + object with a query that can return another project's resource, then write + to it (CVE-2026-97147). An authenticated project member can use this to + rewrite and un-publish another project's public action definitions and + environments. A project administrator can create a workbook whose embedded + ad-hoc action or workflow name collides with a resource of another project, + which moves that resource into the caller's project and causes the original + owner's subsequent updates of it to fail with server errors. All + deployments exposing the Mistral API are affected. Applied upstream + patches: "Fix create-or-update of definitions hijacking other projects' + rows", "Enforce access control when updating action definitions", "Enforce + access control when updating workbooks and environments", "Resolve + own-project object when updating code sources / dynamic actions", "Resolve + own-project cron trigger in create_or_update_cron_trigger": + - CVE-2026-97147_Fix_create-or-update_of_definitions_hijacking_oth....patch + - CVE-2026-97147_Enforce_access_control_when_updating_action_defin....patch + - CVE-2026-97147_Enforce_access_control_when_updating_workbooks_an....patch + - CVE-2026-97147_Resolve_own-project_object_when_updating_code_sou....patch + - CVE-2026-97147_Resolve_own-project_cron_trigger_in_create_or_upd....patch + Added my own patch to fix unit test: + - CVE-2026-97147_Resolve_own-project_object_Fix-test-DB-cleanup-fo....patch + * CVE-2026-93861: Mistral's workflow membership API lets a project that has + accepted a share of another project's private workflow create a further + membership naming a third project (CVE-2026-93861). The new membership row + is created with its project_id defaulted to the accepting project rather + than the original workflow owner, so the owner can neither see nor delete + it. The third project can accept this membership it was never actually + granted by the owner, then read and execute the owner's private workflow; + only the accepting (not the owning) project can later revoke that access. + Applied upstream patch: "Only allow the owner of a resource to share it": + - CVE-2026-93861_Only_allow_the_owner_of_a_resource_to_share_it.patch + * CVE-2026-93858: Mistral's ssh_proxied action provider (CVE-2026-93858). By + supplying a specially-crafted action payload, an unprivileged authenticated + user may override paramiko's proxy_command resulting in execution of + arbitrary code on the executor host operating system. Only Mistral + deployments allowing the std.ssh_proxied action provider (the default) are + affected. Applied upstream patch: "Fix RCE via std.ssh_proxied + proxy_command": + - CVE-2026-93858_Fix_RCE_via_std.ssh_proxied_proxy_command.patch + + -- Thomas Goirand Thu, 15 Oct 2026 09:30:01 +0200 + mistral (20.0.0-2+deb13u1) trixie-security; urgency=medium * CVE-2026-41283: Mistral policy enforcement bypass allows unauthorized diff -Nru mistral-20.0.0/debian/patches/CVE-2026-93858_Fix_RCE_via_std.ssh_proxied_proxy_command.patch mistral-20.0.0/debian/patches/CVE-2026-93858_Fix_RCE_via_std.ssh_proxied_proxy_command.patch --- mistral-20.0.0/debian/patches/CVE-2026-93858_Fix_RCE_via_std.ssh_proxied_proxy_command.patch 1970-01-01 00:00:00.000000000 +0000 +++ mistral-20.0.0/debian/patches/CVE-2026-93858_Fix_RCE_via_std.ssh_proxied_proxy_command.patch 2026-10-15 07:30:01.000000000 +0000 @@ -0,0 +1,141 @@ +Description: Fix RCE via std.ssh_proxied proxy_command + The std.ssh_proxied action passed a caller-supplied proxy_command + straight to paramiko.ProxyCommand in execute_command_via_gateway. + paramiko.ProxyCommand runs the string as a local subprocess on the + executor host from its constructor - before any SSH authentication and + independently of the SSH credentials, gateway or target supplied (a bad + or empty key does not stop it). std.ssh_proxied is a built-in action, so + any authenticated, project-scoped user could run arbitrary commands on + every Mistral executor by submitting it through the action-execution + API. This is a remote code execution vulnerability. + . + Gate proxy_command behind a new '[action_std_ssh] allowed_proxy_commands' + allow-list (empty by default). execute_command_via_gateway now rejects, + with an ActionException, any proxy_command whose exact value is not + listed, before paramiko is invoked - so by default proxy_command is + disabled and no local subprocess can be spawned. Operators who rely on + it must add the exact command string to the option. The rejection reason + reaches the caller thanks to the preceding change. + . +Author: Arnaud Morin +Date: Wed, 26 Aug 2026 10:36:06 +0200 +Bug: https://bugs.launchpad.net/mistral/+bug/2162100 +Signed-off-by: Arnaud Morin +Change-Id: Id749206e5581037721c23609770dc390cb5b4b59 +Origin: upstream, pre-OSSA mailing list +Last-Update: 2026-10-01 + +Index: mistral/mistral/config.py +=================================================================== +--- mistral.orig/mistral/config.py ++++ mistral/mistral/config.py +@@ -656,6 +656,22 @@ keycloak_oidc_opts = [ + ) + ] + ++action_std_ssh_opts = [ ++ cfg.ListOpt( ++ 'allowed_proxy_commands', ++ default=[], ++ help=_('Allow-list of SSH ProxyCommand strings that the ' ++ 'std.ssh_proxied action may use. The proxy_command is run ' ++ 'as a local subprocess on the executor host before SSH ' ++ 'authentication, so a caller-supplied value is a remote ' ++ 'code execution vector. The action input is matched against ' ++ 'this list by exact string; anything not listed is ' ++ 'rejected. Empty (the default) disables proxy_command ' ++ 'entirely, so an ordinary user cannot run commands on the ' ++ 'executor. Only add entries you fully trust and that do not ' ++ 'take attacker-controllable arguments.') ++ ), ++] + + yaql_opts = [ + cfg.IntOpt( +@@ -772,6 +788,7 @@ CONTEXT_VERSIONING_GROUP = 'context_vers + PROFILER_GROUP = profiler.list_opts()[0][0] + KEYCLOAK_OIDC_GROUP = "keycloak_oidc" + YAQL_GROUP = "yaql" ++ACTION_STD_SSH_GROUP = 'action_std_ssh' + HEALTHCHECK_GROUP = 'healthcheck' + KEYSTONE_GROUP = "keystone" + +@@ -811,6 +828,7 @@ CONF.register_opts(coordination_opts, gr + CONF.register_opts(profiler_opts, group=PROFILER_GROUP) + CONF.register_opts(keycloak_oidc_opts, group=KEYCLOAK_OIDC_GROUP) + CONF.register_opts(yaql_opts, group=YAQL_GROUP) ++CONF.register_opts(action_std_ssh_opts, group=ACTION_STD_SSH_GROUP) + CONF.register_opts(healthcheck_opts, group=HEALTHCHECK_GROUP) + loading.register_session_conf_options(CONF, KEYSTONE_GROUP) + +@@ -855,6 +873,7 @@ def list_opts(): + (PROFILER_GROUP, profiler_opts), + (KEYCLOAK_OIDC_GROUP, keycloak_oidc_opts), + (YAQL_GROUP, yaql_opts), ++ (ACTION_STD_SSH_GROUP, action_std_ssh_opts), + (HEALTHCHECK_GROUP, healthcheck_opts), + (ACTION_HEARTBEAT_GROUP, action_heartbeat_opts), + (ACTION_LOGGING_GROUP, action_logging_opts), +Index: mistral/mistral/utils/ssh_utils.py +=================================================================== +--- mistral.orig/mistral/utils/ssh_utils.py ++++ mistral/mistral/utils/ssh_utils.py +@@ -16,6 +16,7 @@ from os import path + + import io + ++from oslo_config import cfg + from oslo_log import log as logging + import paramiko + +@@ -121,6 +122,22 @@ def execute_command_via_gateway(cmd, hos + proxy = None + + if proxy_command: ++ # SECURITY: paramiko.ProxyCommand runs the given string as a local ++ # subprocess on the executor host (before any SSH authentication), ++ # so a caller-supplied proxy_command is a remote code execution ++ # vector. Only run commands an operator has explicitly allow-listed ++ # via [action_std_ssh] allowed_proxy_commands (empty by default, ++ # which rejects proxy_command entirely). Match by exact string to ++ # avoid argument smuggling. ++ allowed = cfg.CONF.action_std_ssh.allowed_proxy_commands ++ ++ if proxy_command not in allowed: ++ raise exc.ActionException( ++ "proxy_command is not permitted. An administrator must add " ++ "the exact command to the [action_std_ssh] " ++ "allowed_proxy_commands configuration option to enable it." ++ ) ++ + LOG.debug('Creating proxy using command: %s', proxy_command) + + proxy = paramiko.ProxyCommand(proxy_command) +Index: mistral/releasenotes/notes/ssh-proxied-proxy-command-allowlist-2e8a1c74f0b96d53.yaml +=================================================================== +--- /dev/null ++++ mistral/releasenotes/notes/ssh-proxied-proxy-command-allowlist-2e8a1c74f0b96d53.yaml +@@ -0,0 +1,23 @@ ++--- ++security: ++ - | ++ Fixed a remote code execution vulnerability in the ``std.ssh_proxied`` ++ action. The action passed a caller-supplied ``proxy_command`` straight ++ to ``paramiko.ProxyCommand``, which runs it as a local subprocess on ++ the executor host - before any SSH authentication and regardless of ++ the SSH credentials or gateway supplied. Any authenticated, ++ project-scoped user could therefore run arbitrary commands on every ++ Mistral executor by submitting the action through the standard ++ action-execution API. ++ ++ ``proxy_command`` is now rejected unless its exact value appears in the ++ new ``[action_std_ssh] allowed_proxy_commands`` allow-list, which is ++ empty by default. Existing deployments that rely on a ``proxy_command`` ++ must add the exact command string to this option; otherwise the action ++ fails with a clear error instead of executing it. ++upgrade: ++ - | ++ The ``std.ssh_proxied`` action now refuses any ``proxy_command`` that is ++ not listed in the new ``[action_std_ssh] allowed_proxy_commands`` config ++ option (empty by default). If you use ``proxy_command``, add its exact ++ value to this option. diff -Nru mistral-20.0.0/debian/patches/CVE-2026-93861_Only_allow_the_owner_of_a_resource_to_share_it.patch mistral-20.0.0/debian/patches/CVE-2026-93861_Only_allow_the_owner_of_a_resource_to_share_it.patch --- mistral-20.0.0/debian/patches/CVE-2026-93861_Only_allow_the_owner_of_a_resource_to_share_it.patch 1970-01-01 00:00:00.000000000 +0000 +++ mistral-20.0.0/debian/patches/CVE-2026-93861_Only_allow_the_owner_of_a_resource_to_share_it.patch 2026-10-15 07:30:01.000000000 +0000 @@ -0,0 +1,139 @@ +Description: Only allow the owner of a resource to share it + Sharing a workflow (POST /v2/workflows//members) only verified that + the caller could fetch the workflow definition and that its scope was + private. The secure query that fetches the definition also returns + resources shared with the caller through an accepted membership, so a + project that a workflow was shared with - once it accepted the share - + could re-share the owner's private workflow with an arbitrary third + project. + . + The resulting ResourceMember row is stamped with the re-sharing + project's id (the caller), not the owner's, so: + . + * the real owner can neither list nor delete that membership; + * the third project can accept it and thereby read and execute the + owner's private workflow, which the owner never shared with it; + * only the re-sharing project, not the owner, can revoke it. + . + Reject the share unless the caller is the owner of the resource + (wf_db.project_id == security.get_project_id()), with a 403. This does + not affect the previously reported "pending" case, where the member + cannot see the workflow yet and the fetch already fails with 404. + . +Author: Arnaud Morin +Date: Wed, 26 Aug 2026 21:29:26 +0200 +Bug: https://bugs.launchpad.net/mistral/+bug/2161277 +Signed-off-by: Arnaud Morin +Change-Id: Iea86d4a8162ae87aee2ae3f3cece29ee66cbd01b +Origin: upstream, pre-OSSA mailing list +Last-Update: 2026-10-01 + +diff --git a/mistral/api/controllers/v2/member.py b/mistral/api/controllers/v2/member.py +index 7dc0c805..1e22e7ba 100644 +--- a/mistral/api/controllers/v2/member.py ++++ b/mistral/api/controllers/v2/member.py +@@ -25,6 +25,7 @@ from mistral.api.controllers.v2 import resources + from mistral import context + from mistral.db.v2 import api as db_api + from mistral import exceptions as exc ++from mistral.services import security + from mistral.utils import rest_utils + + +@@ -131,6 +132,18 @@ class MembersController(rest.RestController): + with db_api.transaction(): + wf_db = db_api.get_workflow_definition(self.resource_id) + ++ # Only the owner of a resource may share it. A project that ++ # merely has the resource shared with it (an accepted ++ # member) can read the definition through the shared-resource ++ # query, but it must not be able to re-share it with a third ++ # project - doing so would grant access without the owner's ++ # consent and create a membership the owner cannot see or ++ # revoke. ++ if wf_db.project_id != security.get_project_id(): ++ raise exc.NotAllowedException( ++ "Only the owner of a resource can share it." ++ ) ++ + if wf_db.scope != 'private': + raise exc.WorkflowException( + "Only private resource could be shared." +diff --git a/mistral/tests/unit/api/v2/test_members.py b/mistral/tests/unit/api/v2/test_members.py +index 75eb3165..f3a73c84 100644 +--- a/mistral/tests/unit/api/v2/test_members.py ++++ b/mistral/tests/unit/api/v2/test_members.py +@@ -151,6 +151,49 @@ class TestMembersController(base.APITest): + + self.assertEqual(404, resp.status_int) + ++ @mock.patch('mistral.context.AuthHook.before') ++ def test_accepted_member_cannot_reshare(self, auth_mock): ++ # Owner (project A) shares the private workflow with member B. ++ resp = self.app.post_json(MEMBER_URL, {'member_id': '11-22-33'}) ++ ++ self.assertEqual(201, resp.status_int) ++ ++ # Switch to member B and accept the share. Once accepted, B can ++ # read A's private workflow through the shared-resource query. ++ get_b = mock.MagicMock(return_value='11-22-33') ++ ++ with mock.patch(GET_PROJECT_PATH, get_b): ++ resp = self.app.put_json( ++ '%s/11-22-33' % MEMBER_URL, ++ {'status': 'accepted'} ++ ) ++ ++ self.assertEqual(200, resp.status_int) ++ ++ # B (an accepted member, not the owner) must not be able to ++ # re-share A's workflow with a third project C. ++ resp = self.app.post_json( ++ MEMBER_URL, ++ {'member_id': 'third-project-C'}, ++ expect_errors=True ++ ) ++ ++ self.assertEqual(403, resp.status_int) ++ self.assertIn( ++ "Only the owner of a resource can share it", ++ resp.body.decode() ++ ) ++ ++ # And no membership for C leaked into the database. ++ member_c = db_api.get_resource_members( ++ WORKFLOW_MEMBER_PENDING['resource_id'], 'workflow' ++ ) ++ ++ self.assertNotIn( ++ 'third-project-C', ++ [m.member_id for m in member_c] ++ ) ++ + @mock.patch('mistral.context.AuthHook.before') + def test_get_other_memberships(self, auth_mock): + resp = self.app.post_json(MEMBER_URL, {'member_id': '11-22-33'}) +diff --git a/releasenotes/notes/only-owner-can-share-resource-3f6b1d0a92c74e58.yaml b/releasenotes/notes/only-owner-can-share-resource-3f6b1d0a92c74e58.yaml +new file mode 100644 +index 00000000..7eb31949 +--- /dev/null ++++ b/releasenotes/notes/only-owner-can-share-resource-3f6b1d0a92c74e58.yaml +@@ -0,0 +1,15 @@ ++--- ++security: ++ - | ++ Fixed a resource-sharing privilege escalation. Only the owner of a ++ workflow may now share it (create a membership for it). Previously the ++ share endpoint (``POST /v2/workflows//members``) only checked that ++ the caller could see the workflow and that it was private. A project ++ that had a workflow shared with it and accepted the share can read the ++ definition through the shared-resource query, and could therefore ++ re-share the owner's private workflow with a third project without the ++ owner's consent. The derived membership was owned by the re-sharing ++ project, so the real owner could neither see nor revoke it, while the ++ third project could accept it and read and execute the owner's private ++ workflow. The share operation is now rejected with 403 unless the ++ caller is the resource owner. +-- +2.43.0 + diff -Nru mistral-20.0.0/debian/patches/CVE-2026-97147_Enforce_access_control_when_updating_action_definitions.patch mistral-20.0.0/debian/patches/CVE-2026-97147_Enforce_access_control_when_updating_action_definitions.patch --- mistral-20.0.0/debian/patches/CVE-2026-97147_Enforce_access_control_when_updating_action_definitions.patch 1970-01-01 00:00:00.000000000 +0000 +++ mistral-20.0.0/debian/patches/CVE-2026-97147_Enforce_access_control_when_updating_action_definitions.patch 2026-10-15 07:30:01.000000000 +0000 @@ -0,0 +1,196 @@ +Description: Enforce access control when updating action definitions + update_action_definition() resolved the target by name with a query + that, for a non-admin caller, also matches public definitions of other + projects (the secure query includes scope=='public'). It then rewrote + project_id to the caller on write, so a non-admin could modify and + effectively steal another project's public ad-hoc action, and it had no + guard against modifying a system action at the DB layer -- unlike + update_workflow_definition(), which calls check_db_obj_access(). + . + Look the definition up in the caller's own project first so that, when + several projects share a name, the caller's own object is updated, and + call check_db_obj_access() to reject cross-project and system-action + modifications by non-admins. The check is skipped when there is no + authentication context, since 'mistral-db-manage populate' re-registers + preinstalled ad-hoc actions through this path without one. + . +Author: Arnaud Morin +Date: Thu, 9 Jul 2026 21:34:58 +0200 +Bug: https://bugs.launchpad.net/mistral/+bug/2160267 +Assisted-By: Claude Fable 5 +Change-Id: I98129d2079f1e0a565af89f76e7a8152a1beba61 +Signed-off-by: Arnaud Morin +Origin: upstream, pre-OSSA mailing list +Last-Update: 2026-10-01 + +diff --git a/mistral/db/v2/sqlalchemy/api.py b/mistral/db/v2/sqlalchemy/api.py +index 1aa4fc0f..9352de4c 100644 +--- a/mistral/db/v2/sqlalchemy/api.py ++++ b/mistral/db/v2/sqlalchemy/api.py +@@ -929,7 +929,23 @@ def create_action_definition(values, session=None): + @b.session_aware() + def update_action_definition(identifier, values, session=None): + namespace = values.get('namespace', '') +- a_def = get_action_definition(identifier, namespace=namespace) ++ ++ # If several projects have an action definition with the same name, ++ # update the one of the current project, not an arbitrary one. ++ a_def = _get_db_object_by_name_namespace_and_project( ++ models.ActionDefinition, ++ identifier, ++ namespace, ++ security.get_project_id() ++ ) ++ ++ if not a_def: ++ a_def = get_action_definition(identifier, namespace=namespace) ++ ++ # The check requires an authentication context, which internal ++ # maintenance calls (e.g. 'mistral-db-manage populate') don't have. ++ if context.has_ctx(): ++ m_dbutils.check_db_obj_access(a_def) + + a_def.update(values.copy()) + +diff --git a/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py b/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py +index 54fdb5cc..c59c2435 100644 +--- a/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py ++++ b/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py +@@ -21,6 +21,7 @@ import datetime + import time + + from oslo_config import cfg ++import testtools + + from mistral import context as auth_context + from mistral.db.v2.sqlalchemy import api as db_api +@@ -1372,7 +1373,7 @@ class ActionDefinitionTest(SQLAlchemyTest): + self.assertEqual(3, len(fetched)) + + def test_update_action_definition_with_name(self): +- created = db_api.create_action_definition(ACTION_DEFINITIONS[0]) ++ created = db_api.create_action_definition(ACTION_DEFINITIONS[2]) + + self.assertIsNone(created.updated_at) + +@@ -1389,7 +1390,7 @@ class ActionDefinitionTest(SQLAlchemyTest): + self.assertIsNotNone(fetched.updated_at) + + def test_update_action_definition_with_uuid(self): +- created = db_api.create_action_definition(ACTION_DEFINITIONS[0]) ++ created = db_api.create_action_definition(ACTION_DEFINITIONS[2]) + + self.assertIsNone(created.updated_at) + +@@ -1404,6 +1405,82 @@ class ActionDefinitionTest(SQLAlchemyTest): + + self.assertEqual(updated, fetched) + ++ def test_update_other_project_action_definition(self): ++ values = copy.deepcopy(ACTION_DEFINITIONS[2]) ++ values['scope'] = 'public' ++ ++ created = db_api.create_action_definition(values) ++ ++ # Switch to another project. ++ auth_context.set_ctx(USER_CTX) ++ ++ self.assertRaises( ++ exc.NotAllowedException, ++ db_api.update_action_definition, ++ created.name, ++ {'description': 'my new desc'} ++ ) ++ ++ # NOTE(amorin) in this mistral release, we are missing some commits ++ # to let an admin list all action defs, so this test cannot be ++ # executed ++ @testtools.skip('Skip because admin cannot list all action defs.') ++ def test_update_other_project_action_definition_by_admin(self): ++ created = db_api.create_action_definition(ACTION_DEFINITIONS[2]) ++ ++ # Switch to admin in another project. ++ auth_context.set_ctx(ADM_CTX) ++ ++ updated = db_api.update_action_definition( ++ created.id, ++ {'description': 'my new desc'} ++ ) ++ ++ self.assertEqual('my new desc', updated.description) ++ ++ def test_update_system_action_definition(self): ++ created = db_api.create_action_definition(ACTION_DEFINITIONS[0]) ++ ++ self.assertRaises( ++ exc.InvalidActionException, ++ db_api.update_action_definition, ++ created.name, ++ {'description': 'my new desc'} ++ ) ++ ++ def test_update_action_definition_with_same_name_in_other_project(self): ++ # Create a public action definition in another project. ++ auth_context.set_ctx(USER_CTX) ++ ++ values = copy.deepcopy(ACTION_DEFINITIONS[2]) ++ values['scope'] = 'public' ++ ++ other = db_api.create_action_definition(values) ++ ++ # Create an action definition with the same name in this project. ++ auth_context.set_ctx(DEFAULT_CTX) ++ ++ created = db_api.create_action_definition( ++ copy.deepcopy(ACTION_DEFINITIONS[2]) ++ ) ++ ++ # The update must touch the action definition of the current ++ # project, not the one of another project with the same name. ++ updated = db_api.update_action_definition( ++ created.name, ++ {'description': 'my new desc'} ++ ) ++ ++ self.assertEqual(created.id, updated.id) ++ self.assertEqual('my new desc', updated.description) ++ ++ # The action definition of the other project must be intact. ++ auth_context.set_ctx(USER_CTX) ++ ++ fetched = db_api.get_action_definition(other.id) ++ ++ self.assertEqual('Action #3', fetched.description) ++ + def test_create_or_update_action_definition(self): + name = 'not-existing-id' + +@@ -1411,7 +1488,7 @@ class ActionDefinitionTest(SQLAlchemyTest): + + created = db_api.create_or_update_action_definition( + name, +- ACTION_DEFINITIONS[0] ++ ACTION_DEFINITIONS[2] + ) + + self.assertIsNotNone(created) +diff --git a/releasenotes/notes/fix-update-action-definition-access-check-c1f5be3ad2e2a666.yaml b/releasenotes/notes/fix-update-action-definition-access-check-c1f5be3ad2e2a666.yaml +new file mode 100644 +index 00000000..5fb1322d +--- /dev/null ++++ b/releasenotes/notes/fix-update-action-definition-access-check-c1f5be3ad2e2a666.yaml +@@ -0,0 +1,11 @@ ++--- ++fixes: ++ - | ++ Updating an ad-hoc action definition now verifies that the caller is ++ allowed to modify it, consistently with workflow definitions: a non-admin ++ request can no longer modify a public action definition of another ++ project, and system action definitions are protected from modification by ++ non-admin users at the database layer. In addition, when several projects ++ have an action definition with the same name, an update now always ++ targets the definition of the caller's project instead of an arbitrary ++ one. +-- +2.43.0 + diff -Nru mistral-20.0.0/debian/patches/CVE-2026-97147_Enforce_access_control_when_updating_workbooks_and_environments.patch mistral-20.0.0/debian/patches/CVE-2026-97147_Enforce_access_control_when_updating_workbooks_and_environments.patch --- mistral-20.0.0/debian/patches/CVE-2026-97147_Enforce_access_control_when_updating_workbooks_and_environments.patch 1970-01-01 00:00:00.000000000 +0000 +++ mistral-20.0.0/debian/patches/CVE-2026-97147_Enforce_access_control_when_updating_workbooks_and_environments.patch 2026-10-15 07:30:01.000000000 +0000 @@ -0,0 +1,265 @@ +Description: Enforce access control when updating workbooks and environments + update_workbook() and update_environment()/create_or_update_environment() + resolved the target with a query that returns public resources of other + projects, and wrote to it without an ownership check. A non-admin project + member could therefore modify (and, since the update sends scope, unpublish) + another project's public workbook or environment. + . + Resolve the object within the caller's own project first and call + check_db_obj_access() before writing, consistently with workflow and action + definitions. The check is skipped when there is no authentication context. + . + update_workbook() was previously protected only transitively: a workbook + update cascades into update_workflow_definition(), which is guarded, so the + whole transaction rolled back. Making the workflow/action lookup + project-scoped (previous change) removes that incidental protection, so the + guard is now applied to the workbook row itself. + . +Author: Arnaud Morin +Date: Thu, 9 Jul 2026 22:52:43 +0200 +Bug: https://bugs.launchpad.net/mistral/+bug/2160267 +Assisted-By: Claude Fable 5 +Change-Id: I1dc5c62d4795f12813d5957f92b897bf8d4a0bf2 +Signed-off-by: Arnaud Morin +Origin: upstream, pre-OSSA mailing list +Last-Update: 2026-10-01 + +diff --git a/mistral/db/v2/sqlalchemy/api.py b/mistral/db/v2/sqlalchemy/api.py +index 9352de4c..55233c61 100644 +--- a/mistral/db/v2/sqlalchemy/api.py ++++ b/mistral/db/v2/sqlalchemy/api.py +@@ -395,6 +395,19 @@ def _get_db_object_by_name_namespace_and_project(model, name, namespace, + return query.first() + + ++def _get_db_object_by_name_and_project(model, name, project_id, columns=()): ++ query = b.model_query(model, columns=columns) ++ ++ query = query.filter( ++ sa.and_( ++ model.name == name, ++ model.project_id == project_id ++ ) ++ ) ++ ++ return query.first() ++ ++ + # Workbook definitions. + + @b.session_aware() +@@ -450,7 +463,24 @@ def create_workbook(values, session=None): + @b.session_aware() + def update_workbook(name, values, session=None): + namespace = values.get('namespace') +- wb = get_workbook(name, namespace=namespace) ++ ++ # If several projects have a workbook with the same name, update the one ++ # of the current project, not a public one of another project. See the ++ # comment in create_or_update_workflow_definition(). ++ wb = _get_db_object_by_name_namespace_and_project( ++ models.Workbook, ++ name, ++ namespace, ++ security.get_project_id() ++ ) ++ ++ if not wb: ++ wb = get_workbook(name, namespace=namespace) ++ ++ # The check requires an authentication context, which internal ++ # maintenance calls don't have. ++ if context.has_ctx(): ++ m_dbutils.check_db_obj_access(wb) + + wb.update(values.copy()) + +@@ -459,7 +489,10 @@ def update_workbook(name, values, session=None): + + @b.session_aware() + def create_or_update_workbook(name, values, session=None): +- if not _get_db_object_by_name(models.Workbook, name): ++ namespace = values.get('namespace') ++ ++ if not _get_db_object_by_name_namespace_and_project( ++ models.Workbook, name, namespace, security.get_project_id()): + return create_workbook(values) + else: + return update_workbook(name, values) +@@ -1853,7 +1886,20 @@ def create_environment(values, session=None): + + @b.session_aware() + def update_environment(name, values, session=None): +- env = get_environment(name) ++ # If several projects have an environment with the same name, update the ++ # one of the current project, not a public one of another project. See ++ # the comment in create_or_update_workflow_definition(). ++ env = _get_db_object_by_name_and_project( ++ models.Environment, ++ name, ++ security.get_project_id() ++ ) ++ ++ if not env: ++ env = get_environment(name) ++ ++ if context.has_ctx(): ++ m_dbutils.check_db_obj_access(env) + + env.update(values) + +@@ -1862,7 +1908,8 @@ def update_environment(name, values, session=None): + + @b.session_aware() + def create_or_update_environment(name, values, session=None): +- env = _get_db_object_by_name(models.Environment, name) ++ env = _get_db_object_by_name_and_project( ++ models.Environment, name, security.get_project_id()) + + if not env: + return create_environment(values) +diff --git a/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py b/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py +index c59c2435..c934c616 100644 +--- a/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py ++++ b/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py +@@ -163,6 +163,56 @@ class WorkbookTest(SQLAlchemyTest): + self.assertEqual(updated, fetched) + self.assertIsNotNone(fetched.updated_at) + ++ def test_update_other_project_workbook(self): ++ # A public workbook owned by another project. ++ auth_context.set_ctx(USER_CTX) ++ ++ created = db_api.create_workbook(WORKBOOKS[0]) ++ ++ auth_context.set_ctx(DEFAULT_CTX) ++ ++ self.assertRaises( ++ exc.NotAllowedException, ++ db_api.update_workbook, ++ created.name, ++ {'definition': 'my new definition', 'namespace': 'test'} ++ ) ++ ++ auth_context.set_ctx(USER_CTX) ++ ++ fetched = db_api.get_workbook(created.name, namespace='test') ++ ++ self.assertEqual('empty', fetched.definition) ++ ++ def test_update_workbook_prefers_own_project(self): ++ # Same-named public workbook in another project. ++ auth_context.set_ctx(USER_CTX) ++ ++ other = db_api.create_workbook(WORKBOOKS[0]) ++ ++ # This project has its own workbook with the same name. ++ auth_context.set_ctx(DEFAULT_CTX) ++ ++ mine = db_api.create_workbook(WORKBOOKS[0]) ++ ++ self.assertNotEqual(other.id, mine.id) ++ ++ updated = db_api.update_workbook( ++ mine.name, ++ {'definition': 'my new definition', 'namespace': 'test'} ++ ) ++ ++ self.assertEqual(mine.id, updated.id) ++ self.assertEqual('my new definition', updated.definition) ++ ++ # The other project's workbook must be intact. ++ auth_context.set_ctx(USER_CTX) ++ ++ fetched = db_api.get_workbook(other.name, namespace='test') ++ ++ self.assertEqual(other.id, fetched.id) ++ self.assertEqual('empty', fetched.definition) ++ + def test_create_or_update_workbook(self): + name = WORKBOOKS[0]['name'] + namespace = WORKBOOKS[0]['namespace'] +@@ -3048,6 +3098,56 @@ class EnvironmentTest(SQLAlchemyTest): + self.assertEqual(updated, fetched) + self.assertIsNotNone(fetched.updated_at) + ++ def test_update_other_project_environment(self): ++ # A public environment owned by another project. ++ auth_context.set_ctx(USER_CTX) ++ ++ created = db_api.create_environment(ENVIRONMENTS[1]) ++ ++ auth_context.set_ctx(DEFAULT_CTX) ++ ++ self.assertRaises( ++ exc.NotAllowedException, ++ db_api.update_environment, ++ created.name, ++ {'description': 'my new desc'} ++ ) ++ ++ auth_context.set_ctx(USER_CTX) ++ ++ fetched = db_api.get_environment(created.name) ++ ++ self.assertEqual('Test Environment #2', fetched.description) ++ ++ def test_update_environment_prefers_own_project(self): ++ # Same-named public environment in another project. ++ auth_context.set_ctx(USER_CTX) ++ ++ other = db_api.create_environment(ENVIRONMENTS[1]) ++ ++ # This project has its own environment with the same name. ++ auth_context.set_ctx(DEFAULT_CTX) ++ ++ mine = db_api.create_environment(ENVIRONMENTS[1]) ++ ++ self.assertNotEqual(other.id, mine.id) ++ ++ updated = db_api.update_environment( ++ mine.name, ++ {'description': 'my new desc'} ++ ) ++ ++ self.assertEqual(mine.id, updated.id) ++ self.assertEqual('my new desc', updated.description) ++ ++ # The other project's environment must be intact. ++ auth_context.set_ctx(USER_CTX) ++ ++ fetched = db_api.get_environment(other.name) ++ ++ self.assertEqual(other.id, fetched.id) ++ self.assertEqual('Test Environment #2', fetched.description) ++ + def test_create_or_update_environment(self): + name = 'not-existing-id' + +diff --git a/releasenotes/notes/fix-update-workbook-environment-access-check-2be4c4d9919f5fa1.yaml b/releasenotes/notes/fix-update-workbook-environment-access-check-2be4c4d9919f5fa1.yaml +new file mode 100644 +index 00000000..9826d562 +--- /dev/null ++++ b/releasenotes/notes/fix-update-workbook-environment-access-check-2be4c4d9919f5fa1.yaml +@@ -0,0 +1,18 @@ ++--- ++security: ++ - | ++ Fixed a cross-project isolation flaw when updating workbooks and ++ environments. A regular (non-admin) project member could modify — and ++ unpublish — a **public** workbook or environment belonging to another ++ project, because the update looked the object up with a query that ++ includes other projects' public resources and performed no ownership ++ check before writing. Updates now resolve the object within the caller's ++ own project and enforce ``check_db_obj_access()``, consistently with ++ workflow and action definitions. ++fixes: ++ - | ++ ``update_workbook`` now verifies ownership before writing. Previously it ++ was only protected indirectly (a workbook update cascades into the ++ workflow-definition update, which was guarded); making the workflow ++ lookup project-scoped removed that incidental protection, so the guard is ++ now applied to the workbook itself. +-- +2.43.0 + diff -Nru mistral-20.0.0/debian/patches/CVE-2026-97147_Fix_create-or-update_of_definitions_hijacking_other_projects_rows.patch mistral-20.0.0/debian/patches/CVE-2026-97147_Fix_create-or-update_of_definitions_hijacking_other_projects_rows.patch --- mistral-20.0.0/debian/patches/CVE-2026-97147_Fix_create-or-update_of_definitions_hijacking_other_projects_rows.patch 1970-01-01 00:00:00.000000000 +0000 +++ mistral-20.0.0/debian/patches/CVE-2026-97147_Fix_create-or-update_of_definitions_hijacking_other_projects_rows.patch 2026-10-15 07:30:01.000000000 +0000 @@ -0,0 +1,243 @@ +Description: Fix create-or-update of definitions hijacking other projects' rows + When several projects have a workflow or ad-hoc action definition with + the same name, create_or_update_workflow_definition() and + create_or_update_action_definition() looked the existing object up with + an unordered first() over a query that isn't scoped to a single project + (admin requests see all projects). The lookup could therefore return + another project's definition, and since project_id is always rewritten + to the current project on write (see + model_base.register_secure_model_hooks), updating that object moved it + into the caller's project. If the caller's project already had its own + definition with that name, the move violated the (name, namespace, + project_id) unique constraint and the resulting DBDuplicateEntry was + raised at commit time, surfacing as an HTTP 500. + . + This intermittently broke the python-mistralclient functional job: + the tests leaked 'wb.ac1' action definitions in two projects, after + which every 'workbook-create' of a definition containing actions failed + with a 500, depending on which project's row the DB returned first. + . + Look the existing definition up by the full unique key (name, namespace + and project id of the current security context), which is the only row + a subsequent write may collide with. Definitions of other projects are + no longer touched, and the update targets the definition by id so the + inner lookup is unambiguous. + . +Author: Arnaud Morin +Date: Thu, 9 Jul 2026 21:16:53 +0200 +Bug: https://bugs.launchpad.net/mistral/+bug/2160267 +Assisted-By: Claude Fable 5 +Change-Id: I02ff0eb095ea653ae0e201058547befe8c4d6ea1 +Signed-off-by: Arnaud Morin +Origin: upstream, pre-OSSA mailing list +Last-Update: 2026-10-01 + +diff --git a/mistral/db/v2/sqlalchemy/api.py b/mistral/db/v2/sqlalchemy/api.py +index bec4e37b..1aa4fc0f 100644 +--- a/mistral/db/v2/sqlalchemy/api.py ++++ b/mistral/db/v2/sqlalchemy/api.py +@@ -377,6 +377,24 @@ def _get_db_object_by_name_and_namespace(model, name, + return query.first() + + ++def _get_db_object_by_name_namespace_and_project(model, name, namespace, ++ project_id, columns=()): ++ query = b.model_query(model, columns=columns) ++ ++ if namespace is None: ++ namespace = '' ++ ++ query = query.filter( ++ sa.and_( ++ model.name == name, ++ model.namespace == namespace, ++ model.project_id == project_id ++ ) ++ ) ++ ++ return query.first() ++ ++ + # Workbook definitions. + + @b.session_aware() +@@ -606,12 +624,24 @@ def update_workflow_definition(identifier, values, session=None): + + @b.session_aware() + def create_or_update_workflow_definition(name, values, session=None): +- namespace = values.get('namespace') +- if _get_db_object_by_name_and_namespace_or_id( +- models.WorkflowDefinition, +- name, +- namespace=namespace): +- return update_workflow_definition(name, values) ++ # NOTE(amorin): A written object always ends up in the project of the ++ # current security context (see model_base.register_secure_model_hooks), ++ # so the object to update must be looked up by the same unique key ++ # (name, namespace, project id) the write would produce. A broader ++ # lookup (e.g. across all projects for an admin) may return an object ++ # of another project with the same name; updating it would move it ++ # into the current project and violate the unique constraint if the ++ # current project already has its own object under that name. ++ wf_def = _get_db_object_by_name_namespace_and_project( ++ models.WorkflowDefinition, ++ name, ++ values.get('namespace'), ++ security.get_project_id() ++ ) ++ ++ if wf_def: ++ return update_workflow_definition(wf_def.id, values) ++ + return create_workflow_definition(values) + + +@@ -908,14 +938,20 @@ def update_action_definition(identifier, values, session=None): + + @b.session_aware() + def create_or_update_action_definition(name, values, session=None): +- namespace = values.get('namespace', '') +- if not _get_db_object_by_name_and_namespace( +- models.ActionDefinition, +- name, +- namespace=namespace): +- return create_action_definition(values) +- else: +- return update_action_definition(name, values) ++ # NOTE(amorin): The object to update must be looked up by the same ++ # unique key (name, namespace, project id) the write would produce. ++ # See the comment in create_or_update_workflow_definition(). ++ a_def = _get_db_object_by_name_namespace_and_project( ++ models.ActionDefinition, ++ name, ++ values.get('namespace', ''), ++ security.get_project_id() ++ ) ++ ++ if a_def: ++ return update_action_definition(a_def.id, values) ++ ++ return create_action_definition(values) + + + @b.session_aware() +diff --git a/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py b/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py +index c8b61068..54fdb5cc 100644 +--- a/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py ++++ b/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py +@@ -875,6 +875,47 @@ class WorkflowDefinitionTest(SQLAlchemyTest): + + self.assertEqual(updated, fetched) + ++ def test_create_or_update_workflow_definition_in_other_project(self): ++ # Create a workflow definition in another project. ++ auth_context.set_ctx(USER_CTX) ++ ++ other = db_api.create_workflow_definition(WF_DEFINITIONS[1]) ++ ++ # Create a workflow definition with the same name in this project. ++ auth_context.set_ctx(DEFAULT_CTX) ++ ++ created = db_api.create_or_update_workflow_definition( ++ other.name, ++ copy.deepcopy(WF_DEFINITIONS[1]) ++ ) ++ ++ self.assertNotEqual(other.id, created.id) ++ self.assertNotEqual(other.project_id, created.project_id) ++ ++ # An admin sees the objects of all projects but a create-or-update ++ # request must only ever touch the object of its own project, not ++ # the one of another project that happens to have the same name. ++ auth_context.set_ctx(test_base.get_context(admin=True)) ++ ++ values = copy.deepcopy(WF_DEFINITIONS[1]) ++ values['definition'] = 'my new definition' ++ ++ updated = db_api.create_or_update_workflow_definition( ++ created.name, ++ values ++ ) ++ ++ self.assertEqual(created.id, updated.id) ++ self.assertEqual('my new definition', updated.definition) ++ ++ # The workflow definition of the other project must be intact. ++ auth_context.set_ctx(USER_CTX) ++ ++ fetched = db_api.get_workflow_definition(other.id) ++ ++ self.assertEqual(other.project_id, fetched.project_id) ++ self.assertEqual('empty', fetched.definition) ++ + def test_update_wf_scope_cron_trigger_associated_in_diff_tenant(self): + created = db_api.create_workflow_definition(WF_DEFINITIONS[0]) + +@@ -1391,6 +1432,47 @@ class ActionDefinitionTest(SQLAlchemyTest): + + self.assertEqual(updated, fetched) + ++ def test_create_or_update_action_definition_in_other_project(self): ++ # Create an action definition in another project. ++ auth_context.set_ctx(USER_CTX) ++ ++ other = db_api.create_action_definition(ACTION_DEFINITIONS[2]) ++ ++ # Create an action definition with the same name in this project. ++ auth_context.set_ctx(DEFAULT_CTX) ++ ++ created = db_api.create_or_update_action_definition( ++ other.name, ++ copy.deepcopy(ACTION_DEFINITIONS[2]) ++ ) ++ ++ self.assertNotEqual(other.id, created.id) ++ self.assertNotEqual(other.project_id, created.project_id) ++ ++ # An admin sees the objects of all projects but a create-or-update ++ # request must only ever touch the object of its own project, not ++ # the one of another project that happens to have the same name. ++ auth_context.set_ctx(test_base.get_context(admin=True)) ++ ++ values = copy.deepcopy(ACTION_DEFINITIONS[2]) ++ values['description'] = 'my new desc' ++ ++ updated = db_api.create_or_update_action_definition( ++ created.name, ++ values ++ ) ++ ++ self.assertEqual(created.id, updated.id) ++ self.assertEqual('my new desc', updated.description) ++ ++ # The action definition of the other project must be intact. ++ auth_context.set_ctx(USER_CTX) ++ ++ fetched = db_api.get_action_definition(other.id) ++ ++ self.assertEqual(other.project_id, fetched.project_id) ++ self.assertEqual('Action #3', fetched.description) ++ + def test_get_action_definitions(self): + created0 = db_api.create_action_definition(ACTION_DEFINITIONS[0]) + created1 = db_api.create_action_definition(ACTION_DEFINITIONS[1]) +diff --git a/releasenotes/notes/fix-create-or-update-definition-cross-project-a9b94242b8b66139.yaml b/releasenotes/notes/fix-create-or-update-definition-cross-project-a9b94242b8b66139.yaml +new file mode 100644 +index 00000000..cb3f551d +--- /dev/null ++++ b/releasenotes/notes/fix-create-or-update-definition-cross-project-a9b94242b8b66139.yaml +@@ -0,0 +1,11 @@ ++--- ++fixes: ++ - | ++ Creating or updating a workflow or ad-hoc action definition (including ++ implicitly through a workbook) no longer touches a definition of another ++ project that happens to have the same name. Previously, when several ++ projects had a definition with the same name, an admin request could pick ++ an arbitrary one of them, move it into the admin's project and fail with ++ an unhandled duplicate entry error (HTTP 500). The existing definition to ++ update is now looked up by its full unique key (name, namespace and ++ project id). +-- +2.43.0 + diff -Nru mistral-20.0.0/debian/patches/CVE-2026-97147_Resolve_own-project_cron_trigger_in_create_or_update_cron_trigger.patch mistral-20.0.0/debian/patches/CVE-2026-97147_Resolve_own-project_cron_trigger_in_create_or_update_cron_trigger.patch --- mistral-20.0.0/debian/patches/CVE-2026-97147_Resolve_own-project_cron_trigger_in_create_or_update_cron_trigger.patch 1970-01-01 00:00:00.000000000 +0000 +++ mistral-20.0.0/debian/patches/CVE-2026-97147_Resolve_own-project_cron_trigger_in_create_or_update_cron_trigger.patch 2026-10-15 07:30:01.000000000 +0000 @@ -0,0 +1,114 @@ +Description: Resolve own-project cron trigger in create_or_update_cron_trigger + create_or_update_cron_trigger() looked the existing trigger up with a + cross-project-capable query (public triggers are visible to any caller, + admins get an insecure lookup), so it could update another project's trigger + and move it into the caller's project. Resolve it within the caller's own + project first, consistently with the other resources. + . + update_cron_trigger() is deliberately left unchanged: it is called by the + periodic scheduler to update next-execution bookkeeping of triggers across + all projects, and the cron trigger REST API exposes no update operation. + . +Author: Arnaud Morin +Date: Thu, 9 Jul 2026 22:59:53 +0200 +Bug: https://bugs.launchpad.net/mistral/+bug/2160267 +Assisted-By: Claude Fable 5 +Change-Id: I19ea9c8e0a22c43039609d76e377d8b51101c1c1 +Signed-off-by: Arnaud Morin +Origin: upstream, pre-OSSA mailing list +Last-Update: 2026-10-01 + +diff --git a/mistral/db/v2/sqlalchemy/api.py b/mistral/db/v2/sqlalchemy/api.py +index 298a4dac..e4088419 100644 +--- a/mistral/db/v2/sqlalchemy/api.py ++++ b/mistral/db/v2/sqlalchemy/api.py +@@ -1809,6 +1809,10 @@ def create_cron_trigger(values, session=None): + + @b.session_aware() + def update_cron_trigger(identifier, values, session=None, query_filter=None): ++ # NOTE: no cross-project guard here on purpose. This is called by the ++ # periodic scheduler (mistral.services.periodic), which legitimately ++ # updates the next-execution bookkeeping of cron triggers across all ++ # projects. The cron trigger REST API exposes no update operation. + cron_trigger = get_cron_trigger(identifier) + + if query_filter: +@@ -1842,9 +1846,14 @@ def update_cron_trigger(identifier, values, session=None, query_filter=None): + + @b.session_aware() + def create_or_update_cron_trigger(identifier, values, session=None): +- cron_trigger = _get_db_object_by_name_and_namespace_or_id( ++ # Resolve within the caller's own project so that, when several projects ++ # have a cron trigger with the same name, the caller's own one is updated ++ # rather than another project's. See ++ # create_or_update_workflow_definition(). ++ cron_trigger = _get_db_object_by_name_and_project( + models.CronTrigger, +- identifier ++ identifier, ++ security.get_project_id() + ) + + if not cron_trigger: +diff --git a/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py b/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py +index e06d3b8b..56d0c3f2 100644 +--- a/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py ++++ b/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py +@@ -2810,6 +2810,38 @@ class CronTriggerTest(SQLAlchemyTest): + + self.assertEqual(updated, fetched) + ++ def test_create_or_update_cron_trigger_prefers_own_project(self): ++ # A public cron trigger owned by another project. ++ auth_context.set_ctx(USER_CTX) ++ ++ other_vals = copy.deepcopy(CRON_TRIGGERS[0]) ++ other_vals['id'] = 'aaaaaaaa-1111-1111-1111-111111111111' ++ other_vals['scope'] = 'public' ++ other_vals['workflow_id'] = self.wf.id ++ other = db_api.create_cron_trigger(other_vals) ++ ++ # This project has no cron trigger with that name. ++ auth_context.set_ctx(DEFAULT_CTX) ++ ++ my_vals = copy.deepcopy(CRON_TRIGGERS[0]) ++ my_vals['id'] = 'bbbbbbbb-2222-2222-2222-222222222222' ++ my_vals['workflow_id'] = self.wf.id ++ my_vals['remaining_executions'] = 7 ++ ++ result = db_api.create_or_update_cron_trigger('trigger1', my_vals) ++ ++ # A new trigger must be created in this project, not the other ++ # project's public trigger updated/stolen. ++ self.assertNotEqual(other.id, result.id) ++ self.assertNotEqual(other.project_id, result.project_id) ++ ++ # The other project's trigger must be intact. ++ auth_context.set_ctx(USER_CTX) ++ ++ fetched = db_api.get_cron_trigger_by_id(other.id) ++ ++ self.assertEqual(42, fetched.remaining_executions) ++ + def test_get_cron_triggers(self): + created0 = db_api.create_cron_trigger(CRON_TRIGGERS[0]) + created1 = db_api.create_cron_trigger(CRON_TRIGGERS[1]) +diff --git a/releasenotes/notes/fix-create-or-update-cron-trigger-cross-project-c77c78d9d38c896b.yaml b/releasenotes/notes/fix-create-or-update-cron-trigger-cross-project-c77c78d9d38c896b.yaml +new file mode 100644 +index 00000000..4aaf6373 +--- /dev/null ++++ b/releasenotes/notes/fix-create-or-update-cron-trigger-cross-project-c77c78d9d38c896b.yaml +@@ -0,0 +1,10 @@ ++--- ++fixes: ++ - | ++ ``create_or_update_cron_trigger`` now resolves the existing trigger within ++ the caller's own project, so that when several projects have a cron ++ trigger with the same name the caller's own one is updated instead of ++ another project's (possibly public) trigger. The periodic scheduler's ++ ``update_cron_trigger`` bookkeeping is intentionally left unchanged, as it ++ legitimately updates triggers across projects; the cron trigger REST API ++ exposes no update operation. +-- +2.43.0 + diff -Nru mistral-20.0.0/debian/patches/CVE-2026-97147_Resolve_own-project_object_Fix-test-DB-cleanup-for-code-sources-and-dynamic-actions.patch mistral-20.0.0/debian/patches/CVE-2026-97147_Resolve_own-project_object_Fix-test-DB-cleanup-for-code-sources-and-dynamic-actions.patch --- mistral-20.0.0/debian/patches/CVE-2026-97147_Resolve_own-project_object_Fix-test-DB-cleanup-for-code-sources-and-dynamic-actions.patch 1970-01-01 00:00:00.000000000 +0000 +++ mistral-20.0.0/debian/patches/CVE-2026-97147_Resolve_own-project_object_Fix-test-DB-cleanup-for-code-sources-and-dynamic-actions.patch 2026-10-15 07:30:01.000000000 +0000 @@ -0,0 +1,29 @@ +Subject: Clean up code sources and dynamic actions in test DB cleanup + DbTestCase._clean_db() never deleted code_sources and + dynamic_action_definitions rows, so those two tables were not reset + between tests. With the serial test run used by pkgos-dh_auto_test, + rows left behind by the CVE-2026-97147 DB tests (a public code source + "cs1" and dynamic actions referencing class names that do not exist + in its content) leaked into later tests in the same process: listing + actions built descriptors for them and failed with 500 "module 'cs1' + has no attribute 'UpdatedClass'". + . + Delete both tables in _clean_db() for each of the two contexts it + already iterates, dynamic action definitions before code sources to + respect the foreign key order. +Assisted-By: Infomaniak Euria-Code +Last-Update: 2026-10-01 + +Index: mistral/mistral/tests/unit/base.py +=================================================================== +--- mistral.orig/mistral/tests/unit/base.py ++++ mistral/mistral/tests/unit/base.py +@@ -304,6 +304,8 @@ class DbTestCase(BaseTest): + db_api.delete_resource_members() + db_api.delete_delayed_calls() + db_api.delete_scheduled_jobs() ++ db_api.delete_dynamic_action_definitions() ++ db_api.delete_code_sources() + + sqlite_lock.cleanup() + diff -Nru mistral-20.0.0/debian/patches/CVE-2026-97147_Resolve_own-project_object_when_updating_code_sources_dynamic_actions.patch mistral-20.0.0/debian/patches/CVE-2026-97147_Resolve_own-project_object_when_updating_code_sources_dynamic_actions.patch --- mistral-20.0.0/debian/patches/CVE-2026-97147_Resolve_own-project_object_when_updating_code_sources_dynamic_actions.patch 1970-01-01 00:00:00.000000000 +0000 +++ mistral-20.0.0/debian/patches/CVE-2026-97147_Resolve_own-project_object_when_updating_code_sources_dynamic_actions.patch 2026-10-15 07:30:01.000000000 +0000 @@ -0,0 +1,205 @@ +Description: Resolve own-project object when updating code sources / dynamic actions + update_code_source() and update_dynamic_action_definition() resolved the + target with a lookup that can return an object of another project (admins + get an insecure cross-project lookup, and public objects are visible to any + caller), then rewrote project_id to the caller on write. When several + projects have an object with the same name, this could clobber or move the + wrong one. + . + Resolve the object within the caller's own project first and apply + check_db_obj_access(), consistently with workflow/action/workbook/environment + updates. These resources are admin-only by default policy, so this is + hardening / data integrity rather than a cross-tenant escalation. + . +Author: Arnaud Morin +Date: Thu, 9 Jul 2026 22:55:55 +0200 +Bug: https://bugs.launchpad.net/mistral/+bug/2160267 +Assisted-By: Claude Fable 5 +Change-Id: Ib9203c5707a03dad76d9daa7057f33b559041775 +Signed-off-by: Arnaud Morin +Origin: upstream, pre-OSSA mailing list +Last-Update: 2026-10-01 + +diff --git a/mistral/db/v2/sqlalchemy/api.py b/mistral/db/v2/sqlalchemy/api.py +index 55233c61..298a4dac 100644 +--- a/mistral/db/v2/sqlalchemy/api.py ++++ b/mistral/db/v2/sqlalchemy/api.py +@@ -775,7 +775,20 @@ def load_code_source(identifier, fields=(), session=None, namespace=''): + + @b.session_aware() + def update_code_source(identifier, values, namespace='', session=None): +- code_src = get_code_source(identifier, namespace=namespace) ++ # If several projects have a code source with the same name, update the ++ # one of the current project. See create_or_update_workflow_definition(). ++ code_src = _get_db_object_by_name_namespace_and_project( ++ models.CodeSource, ++ identifier, ++ namespace, ++ security.get_project_id() ++ ) ++ ++ if not code_src: ++ code_src = get_code_source(identifier, namespace=namespace) ++ ++ if context.has_ctx(): ++ m_dbutils.check_db_obj_access(code_src) + + values['version'] = code_src.version + 1 + +@@ -823,7 +836,24 @@ def create_dynamic_action_definition(values, session=None): + @b.session_aware() + def update_dynamic_action_definition(identifier, values, namespace='', + session=None): +- action_def = get_dynamic_action_definition(identifier, namespace=namespace) ++ # If several projects have a dynamic action with the same name, update ++ # the one of the current project. See ++ # create_or_update_workflow_definition(). ++ action_def = _get_db_object_by_name_namespace_and_project( ++ models.DynamicActionDefinition, ++ identifier, ++ namespace, ++ security.get_project_id() ++ ) ++ ++ if not action_def: ++ action_def = get_dynamic_action_definition( ++ identifier, ++ namespace=namespace ++ ) ++ ++ if context.has_ctx(): ++ m_dbutils.check_db_obj_access(action_def) + + action_def.update(values.copy()) + +diff --git a/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py b/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py +index c934c616..e06d3b8b 100644 +--- a/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py ++++ b/mistral/tests/unit/db/v2/test_sqlalchemy_db_api.py +@@ -3024,6 +3024,107 @@ class ScheduledJobTest(SQLAlchemyTest): + self.assertEqual(0, res) + + ++CODE_SOURCES = [ ++ { ++ 'name': 'cs1', ++ 'content': 'def f():\n return 1\n', ++ 'version': 1, ++ 'namespace': '', ++ 'scope': 'public', ++ 'tags': [] ++ } ++] ++ ++ ++class CodeSourceTest(SQLAlchemyTest): ++ def setUp(self): ++ super(CodeSourceTest, self).setUp() ++ ++ db_api.delete_code_sources() ++ ++ def test_update_code_source_prefers_own_project(self): ++ # Same-named public code source in another project. ++ auth_context.set_ctx(USER_CTX) ++ ++ other = db_api.create_code_source(copy.deepcopy(CODE_SOURCES[0])) ++ ++ # This project has its own code source with the same name. ++ auth_context.set_ctx(DEFAULT_CTX) ++ ++ mine = db_api.create_code_source(copy.deepcopy(CODE_SOURCES[0])) ++ ++ self.assertNotEqual(other.id, mine.id) ++ ++ updated = db_api.update_code_source( ++ 'cs1', ++ {'content': 'def f():\n return 2\n'} ++ ) ++ ++ self.assertEqual(mine.id, updated.id) ++ ++ # The other project's code source must be intact. ++ auth_context.set_ctx(USER_CTX) ++ ++ fetched = db_api.get_code_source(other.id) ++ ++ self.assertEqual('def f():\n return 1\n', fetched.content) ++ ++ ++DYNAMIC_ACTIONS = [ ++ { ++ 'name': 'dyn1', ++ 'class_name': 'MyClass', ++ 'namespace': '', ++ 'scope': 'public' ++ } ++] ++ ++ ++class DynamicActionDefinitionTest(SQLAlchemyTest): ++ def setUp(self): ++ super(DynamicActionDefinitionTest, self).setUp() ++ ++ db_api.delete_dynamic_action_definitions() ++ db_api.delete_code_sources() ++ ++ def _create_dyn_action(self): ++ code_src = db_api.create_code_source(copy.deepcopy(CODE_SOURCES[0])) ++ ++ values = copy.deepcopy(DYNAMIC_ACTIONS[0]) ++ values['code_source_id'] = code_src.id ++ values['code_source_name'] = code_src.name ++ ++ return db_api.create_dynamic_action_definition(values) ++ ++ def test_update_dynamic_action_prefers_own_project(self): ++ # Same-named public dynamic action in another project. ++ auth_context.set_ctx(USER_CTX) ++ ++ other = self._create_dyn_action() ++ ++ # This project has its own dynamic action with the same name. ++ auth_context.set_ctx(DEFAULT_CTX) ++ ++ mine = self._create_dyn_action() ++ ++ self.assertNotEqual(other.id, mine.id) ++ ++ updated = db_api.update_dynamic_action_definition( ++ 'dyn1', ++ {'class_name': 'UpdatedClass'} ++ ) ++ ++ self.assertEqual(mine.id, updated.id) ++ self.assertEqual('UpdatedClass', updated.class_name) ++ ++ # The other project's dynamic action must be intact. ++ auth_context.set_ctx(USER_CTX) ++ ++ fetched = db_api.get_dynamic_action_definition(other.id) ++ ++ self.assertEqual('MyClass', fetched.class_name) ++ ++ + ENVIRONMENTS = [ + { + 'name': 'env1', +diff --git a/releasenotes/notes/fix-update-code-source-dynamic-action-cross-project-6ffbc1a7a4719b02.yaml b/releasenotes/notes/fix-update-code-source-dynamic-action-cross-project-6ffbc1a7a4719b02.yaml +new file mode 100644 +index 00000000..0aee2ac6 +--- /dev/null ++++ b/releasenotes/notes/fix-update-code-source-dynamic-action-cross-project-6ffbc1a7a4719b02.yaml +@@ -0,0 +1,10 @@ ++--- ++fixes: ++ - | ++ Updating a code source or a dynamic action definition no longer risks ++ modifying an object of another project that happens to have the same ++ name. The existing object is now resolved within the caller's own project ++ (by name, namespace and project id) before writing, and an ownership ++ check is applied, consistently with the other definition types. These ++ resources are admin-only by default policy, so this is a hardening / ++ data-integrity fix rather than a cross-tenant escalation. +-- +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 2026-05-25 15:18:35.000000000 +0000 +++ mistral-20.0.0/debian/patches/series 2026-10-15 07:30:01.000000000 +0000 @@ -1,3 +1,11 @@ install-missing-files.patch cve-2026-41283-stable-2025.1.patch OSSN-0098_Strip_sensitive_info_from_workflow_execution_context.patch +CVE-2026-97147_Fix_create-or-update_of_definitions_hijacking_other_projects_rows.patch +CVE-2026-97147_Enforce_access_control_when_updating_action_definitions.patch +CVE-2026-97147_Enforce_access_control_when_updating_workbooks_and_environments.patch +CVE-2026-97147_Resolve_own-project_object_when_updating_code_sources_dynamic_actions.patch +CVE-2026-97147_Resolve_own-project_object_Fix-test-DB-cleanup-for-code-sources-and-dynamic-actions.patch +CVE-2026-97147_Resolve_own-project_cron_trigger_in_create_or_update_cron_trigger.patch +CVE-2026-93861_Only_allow_the_owner_of_a_resource_to_share_it.patch +CVE-2026-93858_Fix_RCE_via_std.ssh_proxied_proxy_command.patch