Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: [email protected]
Control: affects -1 + src:mistral
User: [email protected]
Usertags: pu

Hi,

I'd like to upgrade Mistral to 20.0.0-2+deb13u2 in trixie.

[ Reason ]
Curren version is affected by these CVEs:
CVE-2026-93858, CVE-2026-93860, CVE-2026-93861, CVE-2026-97147

[ Impact ]
As per upstream announce here:
https://security.openstack.org/ossa/OSSA-2026-044.html

this is "four authorization and privilege vulnerabilities in Mistral"
that I would like to fix.

[ Tests ]
Unit tests are run at package build time AND in autopkgtest, plus the
upstream CI has functional tests. My own (package based) CI installs and
run Mistral too.

[ Risks ]
Not much considering the amount of testing.

[ Checklist ]
  [x] *all* changes are documented in the d/changelog
  [x] I reviewed all changes and I approve them
  [x] attach debdiff against the package in (old)stable
  [x] the issue is verified as fixed in unstable

[ Changes ]
4 upstream patches.

Note that I have already uploaded the package. Please approve my upload.

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-02 12:00:25.000000000 +0200
@@ -1,3 +1,47 @@
+mistral (20.0.0-2+deb13u2) trixie-security; 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 <[email protected]>  Fri, 02 Oct 2026 12:00:25 +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-02 12:00:25.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-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-02 12:00:25.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-02 12:00:25.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-02 12:00:25.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-02 12:00:25.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-02 12:00:25.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-02 12:00:25.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-02 12:00:25.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-02 12:00:25.000000000 
+0200
@@ -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

Reply via email to