On 10/15/26 10:06 PM, Moritz Mühlenhoff wrote:
Hi Thomas,
[ Reason ]
Curren version is affected by these CVEs:
CVE-2026-93858, CVE-2026-93860, CVE-2026-93861, CVE-2026-97147
CVE-2026-93860 is mentioned here, but it's not in the diff?
Cheers,
Moritz
Indeed, forgot a patch. Here's the new debdiff.
Cheers,
Thomas Goirand (zigo)
diff -Nru mistral-20.0.0/debian/changelog mistral-20.0.0/debian/changelog
--- mistral-20.0.0/debian/changelog 2026-05-25 17:18:35.000000000 +0200
+++ mistral-20.0.0/debian/changelog 2026-10-15 09:30:01.000000000 +0200
@@ -1,3 +1,54 @@
+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
+ * CVE-2026-93860: vulnerability in Mistral's maintenance API method. By
+ calling the maintenance API method, an unprivileged authenticated user
+ may pause processing for creation of new objects for all tenant projects
+ resulting in a temporary denial of service. All Mistral deployments are
+ affected. Applied upstream patch: "Restrict the maintenance endpoint to
+ admins":
+ - CVE-2026-93860_Restrict_the_maintenance_endpoint_to_admins.patch
+
+ -- Thomas Goirand <[email protected]> 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 01:00:00.000000000 +0100
+++
mistral-20.0.0/debian/patches/CVE-2026-93858_Fix_RCE_via_std.ssh_proxied_proxy_command.patch
2026-10-15 09:30:01.000000000 +0200
@@ -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 <[email protected]>
+Date: Wed, 26 Aug 2026 10:36:06 +0200
+Bug: https://bugs.launchpad.net/mistral/+bug/2162100
+Signed-off-by: Arnaud Morin <[email protected]>
+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-93860_Restrict_the_maintenance_endpoint_to_admins.patch
mistral-20.0.0/debian/patches/CVE-2026-93860_Restrict_the_maintenance_endpoint_to_admins.patch
---
mistral-20.0.0/debian/patches/CVE-2026-93860_Restrict_the_maintenance_endpoint_to_admins.patch
1970-01-01 01:00:00.000000000 +0100
+++
mistral-20.0.0/debian/patches/CVE-2026-93860_Restrict_the_maintenance_endpoint_to_admins.patch
2026-10-15 09:30:01.000000000 +0200
@@ -0,0 +1,239 @@
+Description: Restrict the maintenance endpoint to admins
+ The root-level /maintenance resource read and changed Mistral's cluster
+ maintenance state with no policy enforcement: the controller cleared
+ the request context and called the service/DB layer directly. Switching
+ the status to PAUSED is a cluster-wide operation that pauses running
+ executions across every project, and reading the status exposes
+ cluster-wide operational state, yet any authenticated token holder
+ could reach both methods.
+ .
+ Add 'maintenance:get' and 'maintenance:update' policies (both default
+ rule:admin_only) and enforce them in MaintenanceController.get() and
+ put() before the request context is cleared, so only administrators can
+ read or change the maintenance status. Operators can relax the rules in
+ policy.yaml if needed.
+Author: Arnaud Morin <[email protected]>
+Date: Mon, 24 Aug 2026 09:59:12 +0200
+Origin: upstream, https://review.opendev.org/c/openstack/mistral/+/1009509
+Bug: https://bugs.launchpad.net/mistral/+bug/2162789
+Bug-Debian: https://bugs.debian.org/1150346
+Last-Update: 2026-11-09
+
+Index: mistral/mistral/api/controllers/maintenance.py
+===================================================================
+--- mistral.orig/mistral/api/controllers/maintenance.py
++++ mistral/mistral/api/controllers/maintenance.py
+@@ -14,6 +14,7 @@
+ from pecan import rest
+ import wsmeext.pecan as wsme_pecan
+
++from mistral.api import access_control as acl
+ from mistral.api.controllers import resource
+ from mistral import context
+ from mistral.db.v2 import api as db_api
+@@ -35,8 +36,15 @@
+
+ class MaintenanceController(rest.RestController):
+
++ @rest_utils.wrap_pecan_controller_exception
+ @pecan.expose('json')
+ def get(self):
++ # Enforce the policy while the request context is still available:
++ # the maintenance status exposes cluster-wide operational state and
++ # is restricted to administrators. This must run before the context
++ # is cleared below.
++ acl.enforce('maintenance:get', context.ctx())
++
+ context.set_ctx(None)
+
+ maintenance_status = db_api.get_maintenance_status()
+@@ -52,6 +60,13 @@
+ status_code=200
+ )
+ def put(self, new_maintenance_status):
++ # Enforce the policy while the request context is still available:
++ # changing the maintenance status is a cluster-wide operation that
++ # pauses running executions across all projects, so it is
++ # restricted to administrators. This must run before the context
++ # is cleared below.
++ acl.enforce('maintenance:update', context.ctx())
++
+ context.set_ctx(None)
+
+ new_maintenance_status.status = maintenance.change_maintenance_mode(
+
+Index: mistral/mistral/policies/__init__.py
+===================================================================
+--- mistral.orig/mistral/policies/__init__.py
++++ mistral/mistral/policies/__init__.py
+@@ -23,6 +23,7 @@
+ from mistral.policies import environment
+ from mistral.policies import event_trigger
+ from mistral.policies import execution
++from mistral.policies import maintenance
+ from mistral.policies import member
+ from mistral.policies import service
+ from mistral.policies import task
+@@ -41,6 +42,7 @@
+ environment.list_rules(),
+ event_trigger.list_rules(),
+ execution.list_rules(),
++ maintenance.list_rules(),
+ member.list_rules(),
+ service.list_rules(),
+ task.list_rules(),
+
+Index: mistral/mistral/policies/maintenance.py
+===================================================================
+--- /dev/null
++++ mistral/mistral/policies/maintenance.py
+@@ -0,0 +1,51 @@
++# 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 oslo_policy import policy
++
++from mistral.policies import base
++
++MAINTENANCE = 'maintenance:%s'
++
++rules = [
++ policy.DocumentedRuleDefault(
++ name=MAINTENANCE % 'get',
++ check_str=base.RULE_ADMIN_ONLY,
++ description='Return the cluster maintenance status. This exposes '
++ 'cluster-wide operational state, so it is restricted '
++ 'to administrators.',
++ operations=[
++ {
++ 'path': '/maintenance',
++ 'method': 'GET'
++ }
++ ]
++ ),
++ policy.DocumentedRuleDefault(
++ name=MAINTENANCE % 'update',
++ check_str=base.RULE_ADMIN_ONLY,
++ description='Change the cluster maintenance status. Pausing '
++ 'maintenance pauses running executions across all '
++ 'projects, so this is restricted to administrators.',
++ operations=[
++ {
++ 'path': '/maintenance',
++ 'method': 'PUT'
++ }
++ ]
++ )
++]
++
++
++def list_rules():
++ return rules
+
+Index: mistral/mistral/tests/unit/api/test_maintenance.py
+===================================================================
+--- /dev/null
++++ mistral/mistral/tests/unit/api/test_maintenance.py
+@@ -0,0 +1,74 @@
++# Copyright 2026 - OVHcloud.
++#
++# 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.tests.unit.api import base
++from mistral.tests.unit import base as unit_base
++
++
++class TestMaintenanceController(base.APITest):
++ """Checks that the maintenance endpoint is admin-only.
++
++ Pausing maintenance is a cluster-wide operation that pauses running
++ executions across all projects, and reading the status exposes
++ cluster-wide operational state, so both GET and PUT /maintenance
++ must be restricted to administrators (policies 'maintenance:get'
++ and 'maintenance:update').
++ """
++
++ @mock.patch('mistral.db.v2.api.get_maintenance_status')
++ @mock.patch('mistral.context.MistralContext.from_environ')
++ def test_get_status_admin_allowed(self, mock_context, mock_get):
++ mock_context.return_value = unit_base.get_context(admin=True)
++ mock_get.return_value = 'RUNNING'
++
++ resp = self.app.get('/maintenance')
++
++ self.assertEqual(200, resp.status_int)
++ self.assertEqual('RUNNING', resp.json['status'])
++
++ @mock.patch('mistral.db.v2.api.get_maintenance_status')
++ def test_get_status_non_admin_forbidden(self, mock_get):
++ # The default request context is a non-admin user.
++ resp = self.app.get('/maintenance', expect_errors=True)
++
++ self.assertEqual(403, resp.status_int)
++ # The maintenance state must not have been read.
++ self.assertFalse(mock_get.called)
++
++ @mock.patch('mistral.services.maintenance.change_maintenance_mode')
++ @mock.patch('mistral.context.MistralContext.from_environ')
++ def test_put_status_admin_allowed(self, mock_context, mock_change):
++ mock_context.return_value = unit_base.get_context(admin=True)
++ mock_change.return_value = 'PAUSED'
++
++ resp = self.app.put_json('/maintenance', {'status': 'PAUSED'})
++
++ self.assertEqual(200, resp.status_int)
++ self.assertEqual('PAUSED', resp.json['status'])
++ mock_change.assert_called_once_with('PAUSED')
++
++ @mock.patch('mistral.services.maintenance.change_maintenance_mode')
++ def test_put_status_non_admin_forbidden(self, mock_change):
++ # The default request context is a non-admin user.
++ resp = self.app.put_json(
++ '/maintenance',
++ {'status': 'PAUSED'},
++ expect_errors=True
++ )
++
++ self.assertEqual(403, resp.status_int)
++ # The maintenance state must not have been touched.
++ self.assertFalse(mock_change.called)
+
+Index:
mistral/releasenotes/notes/maintenance-admin-only-policy-6b1f0a4d92c7e358.yaml
+===================================================================
+--- /dev/null
++++
mistral/releasenotes/notes/maintenance-admin-only-policy-6b1f0a4d92c7e358.yaml
+@@ -0,0 +1,12 @@
++---
++security:
++ - |
++ The cluster maintenance endpoint is now guarded by policy. Reading the
++ status (``GET /maintenance``) requires the new ``maintenance:get``
++ rule and changing it (``PUT /maintenance``) requires the new
++ ``maintenance:update`` rule; both default to admin only
++ (``rule:admin_only``). Previously the endpoint enforced no policy, so
++ any authenticated token holder could read the status or switch the
++ cluster to ``PAUSED`` - a cluster-wide operation that pauses running
++ executions across all projects. Operators who need to grant these to
++ non-admins can override the rules in ``policy.yaml``.
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 01:00:00.000000000 +0100
+++
mistral-20.0.0/debian/patches/CVE-2026-93861_Only_allow_the_owner_of_a_resource_to_share_it.patch
2026-10-15 09:30:01.000000000 +0200
@@ -0,0 +1,139 @@
+Description: Only allow the owner of a resource to share it
+ Sharing a workflow (POST /v2/workflows/<id>/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 <[email protected]>
+Date: Wed, 26 Aug 2026 21:29:26 +0200
+Bug: https://bugs.launchpad.net/mistral/+bug/2161277
+Signed-off-by: Arnaud Morin <[email protected]>
+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/<id>/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 01:00:00.000000000 +0100
+++
mistral-20.0.0/debian/patches/CVE-2026-97147_Enforce_access_control_when_updating_action_definitions.patch
2026-10-15 09:30:01.000000000 +0200
@@ -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 <[email protected]>
+Date: Thu, 9 Jul 2026 21:34:58 +0200
+Bug: https://bugs.launchpad.net/mistral/+bug/2160267
+Assisted-By: Claude Fable 5 <[email protected]>
+Change-Id: I98129d2079f1e0a565af89f76e7a8152a1beba61
+Signed-off-by: Arnaud Morin <[email protected]>
+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 01:00:00.000000000 +0100
+++
mistral-20.0.0/debian/patches/CVE-2026-97147_Enforce_access_control_when_updating_workbooks_and_environments.patch
2026-10-15 09:30:01.000000000 +0200
@@ -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 <[email protected]>
+Date: Thu, 9 Jul 2026 22:52:43 +0200
+Bug: https://bugs.launchpad.net/mistral/+bug/2160267
+Assisted-By: Claude Fable 5 <[email protected]>
+Change-Id: I1dc5c62d4795f12813d5957f92b897bf8d4a0bf2
+Signed-off-by: Arnaud Morin <[email protected]>
+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 01:00:00.000000000 +0100
+++
mistral-20.0.0/debian/patches/CVE-2026-97147_Fix_create-or-update_of_definitions_hijacking_other_projects_rows.patch
2026-10-15 09:30:01.000000000 +0200
@@ -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 <[email protected]>
+Date: Thu, 9 Jul 2026 21:16:53 +0200
+Bug: https://bugs.launchpad.net/mistral/+bug/2160267
+Assisted-By: Claude Fable 5 <[email protected]>
+Change-Id: I02ff0eb095ea653ae0e201058547befe8c4d6ea1
+Signed-off-by: Arnaud Morin <[email protected]>
+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 01:00:00.000000000 +0100
+++
mistral-20.0.0/debian/patches/CVE-2026-97147_Resolve_own-project_cron_trigger_in_create_or_update_cron_trigger.patch
2026-10-15 09:30:01.000000000 +0200
@@ -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 <[email protected]>
+Date: Thu, 9 Jul 2026 22:59:53 +0200
+Bug: https://bugs.launchpad.net/mistral/+bug/2160267
+Assisted-By: Claude Fable 5 <[email protected]>
+Change-Id: I19ea9c8e0a22c43039609d76e377d8b51101c1c1
+Signed-off-by: Arnaud Morin <[email protected]>
+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 01:00:00.000000000 +0100
+++
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 09:30:01.000000000 +0200
@@ -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 01:00:00.000000000 +0100
+++
mistral-20.0.0/debian/patches/CVE-2026-97147_Resolve_own-project_object_when_updating_code_sources_dynamic_actions.patch
2026-10-15 09:30:01.000000000 +0200
@@ -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 <[email protected]>
+Date: Thu, 9 Jul 2026 22:55:55 +0200
+Bug: https://bugs.launchpad.net/mistral/+bug/2160267
+Assisted-By: Claude Fable 5 <[email protected]>
+Change-Id: Ib9203c5707a03dad76d9daa7057f33b559041775
+Signed-off-by: Arnaud Morin <[email protected]>
+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 17:18:35.000000000
+0200
+++ mistral-20.0.0/debian/patches/series 2026-10-15 09:30:01.000000000
+0200
@@ -1,3 +1,12 @@
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
+CVE-2026-93860_Restrict_the_maintenance_endpoint_to_admins.patch