This is an automated email from the ASF dual-hosted git repository.

EnxDev pushed a commit to branch enxdev/feat/subjects-group-access-gaps
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 4382c754aadcd70fbdf2928e914a6b4cc9d89414
Author: Enzo Martellucci <[email protected]>
AuthorDate: Mon Jul 27 11:58:34 2026 +0200

    feat(subjects): scope principal listings and default new assets to creator 
groups
    
    Ports preset-io/superset-private#995 onto this branch.
    
    Scopes SubjectRestApi principal listings (base_filters + include_ids
    filtering), adds the opt-in ASSIGN_CREATOR_GROUPS_AS_VIEWERS setting wired
    through every asset-creation path, emits security.<Action> StatsD counters
    for group/role audit events, and exposes the caller's groups on /api/v1/me/.
    
    Feature code matches the source PR exactly. The only local additions are
    generic type parameters on the new tests' annotations (dict[str, Any],
    list[Any]) plus two type: ignore comments, required because this repo's mypy
    config enforces disallow_any_generics on test files.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---
 .github/workflows/superset-frontend.yml            |   6 +
 docs/admin_docs/security/security.mdx              |  21 ++
 scripts/change_detector.py                         |  45 ++-
 superset/commands/chart/importers/v1/utils.py      |   8 +-
 superset/commands/dashboard/importers/v1/utils.py  |   8 +-
 superset/commands/dataset/create.py                |   4 +-
 superset/commands/utils.py                         |   8 +-
 superset/config.py                                 |   6 +
 superset/daos/dashboard.py                         |  24 +-
 .../dashboard/tool/generate_dashboard.py           |   8 +-
 superset/models/dashboard.py                       |  10 +-
 superset/models/helpers.py                         |   9 +-
 superset/security/manager.py                       |  10 +-
 superset/subjects/api.py                           |   3 +-
 superset/subjects/filters.py                       |  16 +
 superset/subjects/utils.py                         |  89 ++++-
 superset/views/base_api.py                         |  11 +-
 superset/views/core.py                             |  20 +-
 superset/views/dashboard/views.py                  |   6 +-
 superset/views/users/schemas.py                    |   8 +
 tests/integration_tests/subjects/api_tests.py      |  22 ++
 tests/integration_tests/users/api_tests.py         |   9 +
 .../commands/dataset/test_create_subjects.py       |  71 ++++
 .../dashboard/tool/test_dashboard_generation.py    |  32 ++
 tests/unit_tests/scripts/change_detector_test.py   |  63 ++++
 .../security/test_audit_event_metrics.py           |  55 +++
 tests/unit_tests/subjects/test_api_filters.py      |  82 ++++
 .../subjects/test_creator_group_call_sites.py      | 415 +++++++++++++++++++++
 .../subjects/test_creator_group_defaults.py        | 270 ++++++++++++++
 tests/unit_tests/views/test_related_include_ids.py | 124 ++++++
 tests/unit_tests/views/test_user_schemas.py        |  52 +++
 31 files changed, 1486 insertions(+), 29 deletions(-)

diff --git a/.github/workflows/superset-frontend.yml 
b/.github/workflows/superset-frontend.yml
index e4e61344a7c..fa748cd7d43 100644
--- a/.github/workflows/superset-frontend.yml
+++ b/.github/workflows/superset-frontend.yml
@@ -23,6 +23,12 @@ jobs:
   frontend-build:
     runs-on: ubuntu-26.04
     timeout-minutes: 30
+    # The change detector reads the PR's file list, which needs
+    # `pull-requests: read`. Public repos serve that endpoint without it;
+    # private forks return 403.
+    permissions:
+      contents: read
+      pull-requests: read
     outputs:
       should-run: ${{ steps.check.outputs.frontend }}
     steps:
diff --git a/docs/admin_docs/security/security.mdx 
b/docs/admin_docs/security/security.mdx
index e6c11a7ae85..97c24a9ecb2 100644
--- a/docs/admin_docs/security/security.mdx
+++ b/docs/admin_docs/security/security.mdx
@@ -247,6 +247,27 @@ This feature is particularly useful for:
 - Granting access to dashboards without exposing the underlying datasets for 
other uses
 - Creating dashboard-specific access patterns that don't align with dataset 
permissions
 
+#### Assigning the creator's groups automatically
+
+```python
+ASSIGN_CREATOR_GROUPS_AS_VIEWERS = True
+```
+
+With this enabled, a newly created dashboard or chart is shared read-only with 
every group its
+creator belongs to, unless the create payload names viewers explicitly. It 
applies to every path
+that creates an asset: the REST API, `/dashboard/new/`, save-as from Explore, 
dashboard copy,
+the import commands, and the MCP tools. Datasets are unaffected, as they have 
editors but no
+viewers.
+
+Note that this **narrows** access rather than widening it. Because an asset 
with no viewers falls
+back to dataset-based access, giving new assets a viewer makes that fallback 
unreachable for them:
+a colleague who could previously open a new dashboard by virtue of dataset 
access can no longer do
+so unless they share one of the creator's groups. Existing assets are 
untouched — only assets
+created after the setting is enabled are affected.
+
+The setting assigns groups as *viewers*, not editors, so group members get 
read-only access and
+cannot modify or delete the asset.
+
 ### SQL Execution Security Considerations
 
 Apache Superset includes features designed to provide safeguards when 
interacting with connected databases, such as the `DISALLOWED_SQL_FUNCTIONS` 
configuration setting. This aims to prevent the execution of potentially 
harmful database functions or system variables directly from Superset 
interfaces like SQL Lab.
diff --git a/scripts/change_detector.py b/scripts/change_detector.py
index 55548b270b0..f7a4bf3425a 100755
--- a/scripts/change_detector.py
+++ b/scripts/change_detector.py
@@ -31,9 +31,12 @@ from urllib.request import Request, urlopen
 MAX_RETRIES: int = 4
 RETRY_BACKOFF_SECONDS: int = 2
 REQUEST_TIMEOUT_SECONDS: int = 30
-# GitHub returns 429 (and 403 for secondary rate limits) when throttling, which
-# is transient and worth retrying alongside 5xx server errors.
-RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({403, 429})
+# GitHub returns 429 when throttling, which is transient and worth retrying
+# alongside 5xx server errors. It also returns 403 for two very different
+# reasons — a secondary rate limit, and a token missing the required scope —
+# so 403 is only retried when the response headers show throttling. Retrying a
+# scope failure just delays the error and buries its cause.
+RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429})
 
 # Define patterns for each group of files you're interested in
 PATTERNS = {
@@ -68,6 +71,35 @@ PATTERNS = {
 GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
 
 
+def _is_rate_limited(err: HTTPError) -> bool:
+    """Whether a 403 is GitHub throttling rather than a missing token scope."""
+    headers = getattr(err, "headers", None)
+    if headers is None:
+        return False
+    try:
+        return (
+            headers.get("x-ratelimit-remaining") == "0"
+            or headers.get("retry-after") is not None
+        )
+    except AttributeError:
+        return False
+
+
+def _api_error_detail(err: object) -> str:
+    """The API's own explanation, e.g. ``Resource not accessible by 
integration``.
+
+    Without this a 403 is indistinguishable from a rate limit in the CI log.
+    """
+    try:
+        body = err.read().decode("utf-8", "replace")  # type: 
ignore[attr-defined]
+    except Exception:  # noqa: BLE001  # pylint: disable=broad-except
+        return ""
+    try:
+        return json.loads(body).get("message") or body
+    except (ValueError, AttributeError):
+        return body
+
+
 def fetch_files_github_api(url: str):  # type: ignore
     """Fetches data using GitHub API, retrying on transient failures."""
     req = Request(url)  # noqa: S310
@@ -87,9 +119,14 @@ def fetch_files_github_api(url: str):  # type: ignore
             # re-raise once the retry budget is exhausted.
             status = getattr(err, "code", None)
             is_transient = (
-                status is None or status >= 500 or status in 
RETRYABLE_STATUS_CODES
+                status is None
+                or status >= 500
+                or status in RETRYABLE_STATUS_CODES
+                or (status == 403 and _is_rate_limited(err))  # type: 
ignore[arg-type]
             )
             if not is_transient or attempt == MAX_RETRIES:
+                if detail := _api_error_detail(err):
+                    print(f"GitHub API error {status}: {detail}")
                 raise
             wait = RETRY_BACKOFF_SECONDS * 2 ** (attempt - 1)
             print(
diff --git a/superset/commands/chart/importers/v1/utils.py 
b/superset/commands/chart/importers/v1/utils.py
index a4e0bba2e1b..9c0e53a66b1 100644
--- a/superset/commands/chart/importers/v1/utils.py
+++ b/superset/commands/chart/importers/v1/utils.py
@@ -202,11 +202,17 @@ def import_chart(
         db.session.flush()
 
     if user:
-        from superset.subjects.utils import get_user_subject
+        from superset.subjects.utils import (
+            get_default_viewers_for_new_asset,
+            get_user_subject,
+        )
 
         subj = get_user_subject(user.id)
         if subj and subj not in chart.editors:
             chart.editors.append(subj)
+        for viewer in get_default_viewers_for_new_asset(user.id):
+            if viewer not in chart.viewers:
+                chart.viewers.append(viewer)
 
     return chart
 
diff --git a/superset/commands/dashboard/importers/v1/utils.py 
b/superset/commands/dashboard/importers/v1/utils.py
index e556674d8bd..05fe90d6ea2 100644
--- a/superset/commands/dashboard/importers/v1/utils.py
+++ b/superset/commands/dashboard/importers/v1/utils.py
@@ -449,10 +449,16 @@ def import_dashboard(  # noqa: C901
         db.session.flush()
 
     if not existing and user:
-        from superset.subjects.utils import get_user_subject
+        from superset.subjects.utils import (
+            get_default_viewers_for_new_asset,
+            get_user_subject,
+        )
 
         subj = get_user_subject(user.id)
         if subj and subj not in dashboard.editors:
             dashboard.editors.append(subj)
+        for viewer in get_default_viewers_for_new_asset(user.id):
+            if viewer not in dashboard.viewers:
+                dashboard.viewers.append(viewer)
 
     return dashboard
diff --git a/superset/commands/dataset/create.py 
b/superset/commands/dataset/create.py
index 7289f168806..310fa9b24d8 100644
--- a/superset/commands/dataset/create.py
+++ b/superset/commands/dataset/create.py
@@ -120,7 +120,9 @@ class CreateDatasetCommand(CreateMixin, BaseCommand):
             except SupersetSecurityException as ex:
                 
exceptions.append(DatasetDataAccessIsNotAllowed(ex.error.message))
 
-        populate_subjects(self._properties, exceptions)
+        # Datasets have editors only — there is no ``sqlatable_viewers`` table,
+        # so a ``viewers`` key would be dropped by the DAO's ``setattr`` loop.
+        populate_subjects(self._properties, exceptions, include_viewers=False)
 
         if exceptions:
             raise DatasetInvalidError(exceptions=exceptions)
diff --git a/superset/commands/utils.py b/superset/commands/utils.py
index 4caae03761f..d5325a0c9af 100644
--- a/superset/commands/utils.py
+++ b/superset/commands/utils.py
@@ -35,6 +35,7 @@ from superset.daos.tag import TagDAO
 from superset.subjects.exceptions import SubjectsNotFoundValidationError
 from superset.subjects.models import Subject
 from superset.subjects.utils import (
+    get_default_viewers_for_new_asset,
     get_or_create_user_subject as get_user_subject,
     get_subject,
     get_user_subject_ids,
@@ -146,7 +147,10 @@ def populate_subjects(
     except ValidationError as ex:
         exceptions.append(ex)
 
-    if include_viewers and properties.get("viewers") is not None:
+    if not include_viewers:
+        return
+
+    if properties.get("viewers") is not None:
         try:
             properties["viewers"] = populate_subject_list(
                 properties["viewers"],
@@ -155,6 +159,8 @@ def populate_subjects(
             )
         except ValidationError as ex:
             exceptions.append(ex)
+    elif default_viewers := get_default_viewers_for_new_asset(get_user_id()):
+        properties["viewers"] = default_viewers
 
 
 def compute_subjects(
diff --git a/superset/config.py b/superset/config.py
index 4eb87cea2e4..a62aa26679b 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -2945,6 +2945,12 @@ EXTRA_RELATED_QUERY_FILTERS: ExtraRelatedQueryFilters = 
{}
 # Only effective when ENABLE_VIEWERS is on.
 VIEWER_PROMISCUOUS_MODE = False
 
+# When True, a newly created asset is shared read-only with every group its
+# creator belongs to, unless the create payload names viewers explicitly.
+# This narrows access: an asset with no viewers falls back to datasource
+# permissions, while one with viewers is limited to its editors and viewers.
+ASSIGN_CREATOR_GROUPS_AS_VIEWERS = False
+
 # Default Subject types shown in editor/viewer/subject pickers.
 # Entity-specific SUBJECTS_RELATED_TYPES_* settings can replace this default.
 # None = show all types (USER, ROLE, GROUP).
diff --git a/superset/daos/dashboard.py b/superset/daos/dashboard.py
index 4afa24edac8..c4bd384bfe0 100644
--- a/superset/daos/dashboard.py
+++ b/superset/daos/dashboard.py
@@ -428,11 +428,22 @@ class DashboardDAO(BaseDAO[Dashboard]):
             raise DashboardForbiddenError()
 
         dash = Dashboard()
+        # The copied dashboard and every chart cloned below share one creator,
+        # so both lookups are resolved here rather than inside the loop, where
+        # they would cost two extra queries for each chart in the dashboard.
+        creator_editors: list[Any] = []
+        creator_viewers: list[Any] = []
         if g.user:
-            from superset.subjects.utils import get_user_subject
+            from superset.subjects.utils import (
+                get_default_viewers_for_new_asset,
+                get_user_subject,
+            )
 
             user_subject = get_user_subject(g.user.id)
-            dash.editors = [user_subject] if user_subject else []
+            creator_editors = [user_subject] if user_subject else []
+            creator_viewers = get_default_viewers_for_new_asset(g.user.id)
+        dash.editors = creator_editors
+        dash.viewers = creator_viewers
         dash.dashboard_title = data["dashboard_title"]
         dash.css = data.get("css")
 
@@ -442,11 +453,10 @@ class DashboardDAO(BaseDAO[Dashboard]):
             # Duplicating slices as well, mapping old ids to new ones
             for slc in original_dash.slices:
                 new_slice = slc.clone()
-                if g.user:
-                    from superset.subjects.utils import get_user_subject
-
-                    user_subject = get_user_subject(g.user.id)
-                    new_slice.editors = [user_subject] if user_subject else []
+                # ``Slice.clone()`` carries over no subjects, so both
+                # collections start empty on the new chart.
+                new_slice.editors = list(creator_editors)
+                new_slice.viewers = list(creator_viewers)
                 db.session.add(new_slice)
                 db.session.flush()
                 new_slice.dashboards.append(dash)
diff --git a/superset/mcp_service/dashboard/tool/generate_dashboard.py 
b/superset/mcp_service/dashboard/tool/generate_dashboard.py
index a244edbd4c8..71319199279 100644
--- a/superset/mcp_service/dashboard/tool/generate_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/generate_dashboard.py
@@ -347,11 +347,17 @@ def generate_dashboard(  # noqa: C901
                     .first()
                 )
                 if current_user:
-                    from superset.subjects.utils import get_user_subject
+                    from superset.subjects.utils import (
+                        get_default_viewers_for_new_asset,
+                        get_user_subject,
+                    )
 
                     subj = get_user_subject(current_user.id)
                     if subj:
                         dashboard.editors = [subj]
+                    dashboard.viewers = get_default_viewers_for_new_asset(
+                        current_user.id
+                    )
 
                 fresh_charts = (
                     db.session.query(Slice)
diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py
index 7ef6d16db07..dbddea06aea 100644
--- a/superset/models/dashboard.py
+++ b/superset/models/dashboard.py
@@ -71,7 +71,10 @@ def copy_dashboard(_mapper: Mapper, _connection: Connection, 
target: Dashboard)
     if dashboard_id is None:
         return
 
-    from superset.subjects.utils import get_user_subject
+    from superset.subjects.utils import (
+        get_default_viewers_for_groups,
+        get_user_subject,
+    )
 
     session = sqla.inspect(target).session  # pylint: disable=disallowed-name
     new_user = session.query(User).filter_by(id=target.id).first()
@@ -91,6 +94,11 @@ def copy_dashboard(_mapper: Mapper, _connection: Connection, 
target: Dashboard)
         json_metadata=template.json_metadata,
         slices=template.slices,
         editors=editors,
+        # Resolved from the in-memory collection: this runs in ``after_insert``
+        # for the user, before their ``ab_user_group`` rows are written.
+        viewers=get_default_viewers_for_groups(
+            list(getattr(new_user, "groups", []) or [])
+        ),
     )
     session.add(dashboard)
 
diff --git a/superset/models/helpers.py b/superset/models/helpers.py
index 1376a6bfbb6..ccf92bce45a 100644
--- a/superset/models/helpers.py
+++ b/superset/models/helpers.py
@@ -646,7 +646,10 @@ class ImportExportMixin(UUIDMixin):
 
     def reset_ownership(self) -> None:
         """object will belong to the current user"""
-        from superset.subjects.utils import get_user_subject
+        from superset.subjects.utils import (
+            get_default_viewers_for_new_asset,
+            get_user_subject,
+        )
 
         # Reset the audit pointers. When a Flask request context is
         # available we explicitly stamp the current user, otherwise we
@@ -664,6 +667,10 @@ class ImportExportMixin(UUIDMixin):
             user_subject = get_user_subject(g.user.id)
             if user_subject:
                 self.editors = [user_subject]
+            # Only dashboards and charts have ``viewers``; this mixin also
+            # serves datasets, which have editors only.
+            if hasattr(self, "viewers"):
+                self.viewers = get_default_viewers_for_new_asset(g.user.id)
         else:
             self.created_by = None
             self.changed_by = None
diff --git a/superset/security/manager.py b/superset/security/manager.py
index 1040e00beff..418b38784d9 100644
--- a/superset/security/manager.py
+++ b/superset/security/manager.py
@@ -193,10 +193,16 @@ def _log_audit_event(action: str, payload: dict[str, 
Any]) -> None:
     configured implementation (DBEventLogger, S3EventLogger, etc.)
     receives these security audit events.
     """
-    from superset.extensions import (
-        event_logger,  # pylint: disable=import-outside-toplevel
+    from superset.extensions import (  # pylint: 
disable=import-outside-toplevel
+        event_logger,
+        stats_logger_manager,
     )
 
+    try:
+        stats_logger_manager.instance.incr(f"security.{action}")
+    except Exception:  # pylint: disable=broad-except
+        logger.warning("Failed to emit audit metric: %s", action, 
exc_info=True)
+
     user_id = get_user_id()
     try:
         event_logger.log(
diff --git a/superset/subjects/api.py b/superset/subjects/api.py
index 2984dddc3df..dfd9da9de0f 100644
--- a/superset/subjects/api.py
+++ b/superset/subjects/api.py
@@ -19,7 +19,7 @@ import logging
 from flask_appbuilder.models.sqla.interface import SQLAInterface
 
 from superset.constants import RouteMethod
-from superset.subjects.filters import SubjectAllTextFilter
+from superset.subjects.filters import SubjectAllTextFilter, SubjectListFilter
 from superset.subjects.models import Subject
 from superset.subjects.schemas import openapi_spec_methods_override
 from superset.views.base_api import BaseSupersetModelRestApi
@@ -52,6 +52,7 @@ class SubjectRestApi(BaseSupersetModelRestApi):
 
     resource_name = "security/subject"
     allow_browser_login = True
+    base_filters = [["id", SubjectListFilter, lambda: []]]
     openapi_spec_tag = "Security Subjects"
     openapi_spec_methods = openapi_spec_methods_override
 
diff --git a/superset/subjects/filters.py b/superset/subjects/filters.py
index c99fd7df972..8305a07e470 100644
--- a/superset/subjects/filters.py
+++ b/superset/subjects/filters.py
@@ -101,6 +101,22 @@ def _apply_subject_list_filters(query: Query) -> Query:
     return _apply_extra_related_query_filters(_apply_excluded_users(query))
 
 
+class SubjectListFilter(BaseFilter):  # pylint: disable=too-few-public-methods
+    """Scope a Subject query to the principals a deployment chooses to expose.
+
+    The related-item endpoints backing the editor/viewer pickers already apply
+    ``EXCLUDE_USERS_FROM_LISTS`` and ``EXTRA_RELATED_QUERY_FILTERS``.
+    ``SubjectRestApi`` serves the same principal directory — including
+    ``user.email`` on its show endpoint — so it applies them too.
+    """
+
+    name = _("Subject list")
+    arg_name = "subject_list"
+
+    def apply(self, query: Query, value: Any) -> Query:
+        return _apply_subject_list_filters(query)
+
+
 def subject_type_filter(entity_config_key: str | None = None) -> 
type[BaseFilter]:
     """Return a BaseFilter subclass scoped to a specific entity config.
 
diff --git a/superset/subjects/utils.py b/superset/subjects/utils.py
index f19bafafc50..5ff834ebf84 100644
--- a/superset/subjects/utils.py
+++ b/superset/subjects/utils.py
@@ -19,9 +19,10 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 
 if TYPE_CHECKING:
-    from flask_appbuilder.security.sqla.models import Role, User
-    from sqlalchemy.sql import CompoundSelect
+    from flask_appbuilder.security.sqla.models import Group, Role, User
+    from sqlalchemy.sql import CompoundSelect, Select
 
+from flask import current_app, has_app_context
 from sqlalchemy import select, union_all
 
 from superset import db
@@ -79,6 +80,70 @@ def get_user_subject_ids_subquery(user_id: int) -> 
CompoundSelect:
     return union_all(user_subj, role_subj, group_subj, group_role_subj)
 
 
+def get_user_group_subject_ids_subquery(user_id: int) -> Select:
+    """Return a Select of GROUP-type Subject IDs for a user's groups.
+
+    Unlike :func:`get_user_subject_ids_subquery` this covers group membership
+    only — it excludes the user's own subject and any roles.
+    """
+    from flask_appbuilder.security.sqla.models import assoc_user_group
+
+    return (
+        select(Subject.id)
+        .join(assoc_user_group, Subject.group_id == 
assoc_user_group.c.group_id)
+        .where(
+            assoc_user_group.c.user_id == user_id,
+            Subject.type == SubjectType.GROUP,
+        )
+    )
+
+
+def get_user_group_subjects(user_id: int) -> list[Subject]:
+    """Return the GROUP-type Subjects for every group a user belongs to."""
+    return (
+        db.session.query(Subject)
+        .filter(Subject.id.in_(get_user_group_subject_ids_subquery(user_id)))
+        .all()
+    )
+
+
+def _assigns_creator_groups_as_viewers() -> bool:
+    return bool(
+        has_app_context() and 
current_app.config.get("ASSIGN_CREATOR_GROUPS_AS_VIEWERS")
+    )
+
+
+def get_default_viewers_for_new_asset(user_id: int | None) -> list[Subject]:
+    """Return the viewers a newly created asset should default to.
+
+    The single place deciding how a creator's group membership propagates to a
+    new asset, so the paths that build assets outside the create commands
+    (save-as, importers, MCP tools) stay consistent with them. Empty unless
+    ``ASSIGN_CREATOR_GROUPS_AS_VIEWERS`` is enabled.
+
+    Only assets with a ``viewers`` relationship apply — datasets have editors
+    only. Callers inside a flush event use
+    :func:`get_default_viewers_for_groups` instead.
+    """
+    if user_id is None or not _assigns_creator_groups_as_viewers():
+        return []
+    return get_user_group_subjects(user_id)
+
+
+def get_default_viewers_for_groups(groups: list[Group]) -> list[Subject]:
+    """Like :func:`get_default_viewers_for_new_asset`, for in-memory groups.
+
+    Callers inside a SQLAlchemy flush event cannot resolve membership by
+    querying ``ab_user_group``: for a user being inserted, the association rows
+    are written *after* the user's own INSERT, so the query returns nothing.
+    Those callers already hold the group objects on the instance and pass them
+    here instead.
+    """
+    if not groups or not _assigns_creator_groups_as_viewers():
+        return []
+    return subjects_from_groups(groups)
+
+
 def get_user_subject_ids(user_id: int) -> list[int]:
     """Return all Subject IDs that a user represents.
 
@@ -176,6 +241,26 @@ def get_or_create_role_subject(role_id: int) -> Subject | 
None:
     return get_role_subject(role_id)
 
 
+def subjects_from_groups(groups: list[Group | int]) -> list[Subject]:
+    """Convert a list of Group objects or group IDs to GROUP-type Subjects.
+
+    Mirrors :func:`subjects_from_roles`, but resolves the whole list in one
+    query rather than one per group. Silently skips groups without a matching
+    Subject row.
+    """
+    group_ids = [group if isinstance(group, int) else group.id for group in 
groups]
+    if not group_ids:
+        return []
+    return (
+        db.session.query(Subject)
+        .filter(
+            Subject.group_id.in_(group_ids),
+            Subject.type == SubjectType.GROUP,
+        )
+        .all()
+    )
+
+
 def subjects_from_roles(roles: list[Role | int]) -> list[Subject]:
     """Convert a list of Role objects or role IDs to role-type Subjects.
 
diff --git a/superset/views/base_api.py b/superset/views/base_api.py
index 852602c700f..d556654c58b 100644
--- a/superset/views/base_api.py
+++ b/superset/views/base_api.py
@@ -501,8 +501,15 @@ class BaseSupersetModelRestApi(BaseSupersetApiMixin, 
ModelRestApi):
             values = [row["value"] for row in result]
             ids = [id_ for id_ in ids if id_ not in values]
             pk_col = datamodel.get_pk()
-            # Fetch requested values from ids
-            extra_rows = 
db.session.query(datamodel.obj).filter(pk_col.in_(ids)).all()
+            # Fetch requested values from ids, applying the same scoping as the
+            # unforced query so ``include_ids`` cannot resolve rows the
+            # related-field filters deliberately hide.
+            query = db.session.query(datamodel.obj).filter(pk_col.in_(ids))
+            if base_filters := 
self.base_related_field_filters.get(column_name):
+                query = datamodel.apply_filters(
+                    query, 
datamodel.get_filters().add_filter_list(base_filters)
+                )
+            extra_rows = query.all()
             result += self._get_result_from_rows(datamodel, extra_rows, 
column_name)
 
     @event_logger.log_this_with_context(
diff --git a/superset/views/core.py b/superset/views/core.py
index 37d13f0a1c2..980eec9ce33 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -626,14 +626,22 @@ class Superset(BaseSupersetView):
         if action == "saveas":
             if "slice_id" in form_data:
                 form_data.pop("slice_id")  # don't save old slice_id
-            from superset.subjects.utils import get_user_subject
+            from superset.subjects.utils import (
+                get_default_viewers_for_new_asset,
+                get_user_subject,
+            )
 
             editors = []
             if g.user:
                 subj = get_user_subject(g.user.id)
                 if subj:
                     editors.append(subj)
-            slc = Slice(editors=editors)
+            slc = Slice(
+                editors=editors,
+                viewers=get_default_viewers_for_new_asset(
+                    g.user.id if g.user else None
+                ),
+            )
 
         utils.remove_extra_adhoc_filters(form_data)
 
@@ -685,7 +693,10 @@ class Superset(BaseSupersetView):
                     status=403,
                 )
 
-            from superset.subjects.utils import get_user_subject
+            from superset.subjects.utils import (
+                get_default_viewers_for_new_asset,
+                get_user_subject,
+            )
 
             editors = []
             if g.user:
@@ -695,6 +706,9 @@ class Superset(BaseSupersetView):
             dash = Dashboard(
                 dashboard_title=request.args.get("new_dashboard_name"),
                 editors=editors,
+                viewers=get_default_viewers_for_new_asset(
+                    g.user.id if g.user else None
+                ),
             )
 
         if dash and slc not in dash.slices:
diff --git a/superset/views/dashboard/views.py 
b/superset/views/dashboard/views.py
index b5e788c5dde..6bbe30da1ac 100644
--- a/superset/views/dashboard/views.py
+++ b/superset/views/dashboard/views.py
@@ -78,7 +78,10 @@ class Dashboard(BaseSupersetView):
     @expose("/new/")
     def new(self) -> FlaskResponse:
         """Creates a new, blank dashboard and redirects to it in edit mode"""
-        from superset.subjects.utils import get_user_subject
+        from superset.subjects.utils import (
+            get_default_viewers_for_new_asset,
+            get_user_subject,
+        )
 
         editors = []
         if g.user:
@@ -88,6 +91,7 @@ class Dashboard(BaseSupersetView):
         new_dashboard = DashboardModel(
             dashboard_title="[ untitled dashboard ]",
             editors=editors,
+            viewers=get_default_viewers_for_new_asset(g.user.id if g.user else 
None),
         )
         db.session.add(new_dashboard)
         db.session.commit()  # pylint: disable=consider-using-transaction
diff --git a/superset/views/users/schemas.py b/superset/views/users/schemas.py
index f74d1cbca50..6493ba24177 100644
--- a/superset/views/users/schemas.py
+++ b/superset/views/users/schemas.py
@@ -27,6 +27,13 @@ last_name_description = "The current user's last name"
 password_description = "The current user's password for authentication"  # 
noqa: S105
 
 
+class UserGroupSchema(Schema):
+    """A group the current user belongs to."""
+
+    id = Integer()
+    name = String()
+
+
 class UserResponseSchema(Schema):
     id = Integer()
     username = String()
@@ -36,6 +43,7 @@ class UserResponseSchema(Schema):
     is_active = Boolean()
     is_anonymous = Boolean()
     login_count = Integer()
+    groups = fields.List(fields.Nested(UserGroupSchema))
 
 
 class CurrentUserPutSchema(Schema):
diff --git a/tests/integration_tests/subjects/api_tests.py 
b/tests/integration_tests/subjects/api_tests.py
index b2e2a698ba2..7bc021707ae 100644
--- a/tests/integration_tests/subjects/api_tests.py
+++ b/tests/integration_tests/subjects/api_tests.py
@@ -24,6 +24,7 @@ from superset import db
 from superset.subjects.models import Subject
 from superset.subjects.types import SubjectType
 from superset.utils import json
+from tests.conftest import with_config
 from tests.integration_tests.base_tests import SupersetTestCase
 from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
 
@@ -140,3 +141,24 @@ class TestSubjectApi(SupersetTestCase):
         assert self.client.post("api/v1/security/subject/", 
json={}).status_code == 405
         assert self.client.put("api/v1/security/subject/1", 
json={}).status_code == 405
         assert self.client.delete("api/v1/security/subject/1").status_code == 
405
+
+    @pytest.mark.usefixtures("create_subjects")
+    @with_config({"EXCLUDE_USERS_FROM_LISTS": ["admin"]})
+    def test_get_list_subject_excludes_configured_users(self) -> None:
+        """Subject API: excluded users are absent from the list"""
+        self.login(ADMIN_USERNAME)
+
+        rv = self.get_assert_metric("api/v1/security/subject/", "get_list")
+
+        assert rv.status_code == 200
+        data = json.loads(rv.data.decode("utf-8"))
+        excluded = (
+            db.session.query(Subject)
+            .filter(Subject.type == SubjectType.USER)
+            .join(Subject.user)
+            .filter_by(username="admin")
+            .one_or_none()
+        )
+        assert excluded is not None, "expected an admin USER subject to exist"
+        assert excluded.id not in {row["id"] for row in data["result"]}
+        assert data["count"] == db.session.query(Subject).count() - 1
diff --git a/tests/integration_tests/users/api_tests.py 
b/tests/integration_tests/users/api_tests.py
index 85985f9f35b..184a76a6660 100644
--- a/tests/integration_tests/users/api_tests.py
+++ b/tests/integration_tests/users/api_tests.py
@@ -42,6 +42,15 @@ class TestCurrentUserApi(SupersetTestCase):
         assert True is response["result"]["is_active"]
         assert False is response["result"]["is_anonymous"]
 
+    def test_get_me_includes_groups(self):
+        self.login(ADMIN_USERNAME)
+
+        rv = self.client.get(meUri)
+
+        assert 200 == rv.status_code
+        response = json.loads(rv.data.decode("utf-8"))
+        assert isinstance(response["result"]["groups"], list)
+
     def test_get_me_with_roles(self):
         self.login(ADMIN_USERNAME)
 
diff --git a/tests/unit_tests/commands/dataset/test_create_subjects.py 
b/tests/unit_tests/commands/dataset/test_create_subjects.py
new file mode 100644
index 00000000000..30c602fd5ca
--- /dev/null
+++ b/tests/unit_tests/commands/dataset/test_create_subjects.py
@@ -0,0 +1,71 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+"""Datasets must never be given viewers.
+
+``SqlaTable`` has ``sqlatable_editors`` but no viewers table, so a ``viewers``
+key in the create properties would be handed to ``setattr`` by the DAO and
+silently dropped instead of granting anything.
+"""
+
+from unittest.mock import Mock, patch
+
+from superset.commands.dataset.create import CreateDatasetCommand
+from superset.models.core import Database
+from superset.subjects.models import Subject
+from superset.subjects.types import SubjectType
+
+
+def _group_subject(id_: int) -> Subject:
+    subject = Subject()
+    subject.id = id_
+    subject.type = SubjectType.GROUP
+    return subject
+
+
+def test_dataset_create_never_populates_viewers(app_context) -> None:
+    database = Mock(spec=Database)
+    database.id = 1
+    database.get_default_catalog.return_value = None
+
+    command = CreateDatasetCommand(
+        {"database": 1, "table_name": "some_table", "sql": "SELECT 1"}
+    )
+
+    with (
+        patch(
+            "superset.commands.dataset.create.DatasetDAO.get_database_by_id",
+            return_value=database,
+        ),
+        patch(
+            "superset.commands.dataset.create.DatasetDAO.validate_uniqueness",
+            return_value=True,
+        ),
+        
patch("superset.commands.dataset.create.security_manager.raise_for_access"),
+        patch("superset.commands.utils.populate_subject_list", 
return_value=[]),
+        patch("superset.commands.utils.get_user_id", return_value=5),
+        patch(
+            "superset.subjects.utils.get_user_group_subjects",
+            return_value=[_group_subject(11)],
+        ),
+        patch.dict(
+            "superset.commands.utils.current_app.config",
+            {"ASSIGN_CREATOR_GROUPS_AS_VIEWERS": True},
+        ),
+    ):
+        command.validate()
+
+    assert "viewers" not in command._properties  # noqa: SLF001
diff --git 
a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py 
b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
index b401665657a..9271d7d070f 100644
--- a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
+++ b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py
@@ -2140,3 +2140,35 @@ class TestDashboardSerializationEagerLoading:
             assert "editors" not in dash
             assert dash["tags"] == []
             assert dash["charts"] == []
+
+
+class TestGenerateDashboardCreatorGroups:
+    """The tool creates dashboards outside CreateDashboardCommand, so it has to
+    apply the creator's default viewers itself."""
+
+    @patch("superset.models.dashboard.Dashboard")
+    @patch("superset.daos.dashboard.DashboardDAO.find_by_id")
+    @patch("superset.db.session")
+    @pytest.mark.asyncio
+    async def test_generated_dashboard_gets_the_creators_default_viewers(
+        self, mock_db_session, mock_find_by_id, mock_dashboard_cls, mcp_server
+    ):
+        charts = [_mock_chart(id=1, slice_name="Sales Chart")]
+        mock_dashboard = _mock_dashboard(id=10, title="Analytics Dashboard")
+        mock_dashboard.viewers = []
+        _setup_generate_dashboard_mocks(
+            mock_db_session, mock_find_by_id, mock_dashboard_cls, charts, 
mock_dashboard
+        )
+        viewer = Mock()
+
+        with patch(
+            "superset.subjects.utils.get_default_viewers_for_new_asset",
+            return_value=[viewer],
+        ):
+            async with Client(mcp_server) as client:
+                await client.call_tool(
+                    "generate_dashboard",
+                    {"request": {"chart_ids": [1], "dashboard_title": 
"Analytics"}},
+                )
+
+        assert mock_dashboard.viewers == [viewer]
diff --git a/tests/unit_tests/scripts/change_detector_test.py 
b/tests/unit_tests/scripts/change_detector_test.py
index 69cf4dccce2..619549153ec 100644
--- a/tests/unit_tests/scripts/change_detector_test.py
+++ b/tests/unit_tests/scripts/change_detector_test.py
@@ -14,6 +14,7 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+import io
 from unittest import mock
 from urllib.error import HTTPError, URLError
 
@@ -94,3 +95,65 @@ def test_fetch_gives_up_after_max_retries() -> None:
             change_detector.fetch_files_github_api("http://api";)
 
     assert urlopen_mock.call_count == change_detector.MAX_RETRIES
+
+
+def _http_error(
+    status: int, headers: dict[str, str] | None = None, body: bytes = b""
+) -> HTTPError:
+    """Builds an HTTPError carrying headers and a readable body, as urllib 
does."""
+    return HTTPError(
+        "http://api";,
+        status,
+        "Forbidden",
+        headers or {},  # type: ignore[arg-type]
+        io.BytesIO(body),
+    )
+
+
+def test_fetch_does_not_retry_a_permission_denied_403() -> None:
+    """A missing token scope is deterministic; retrying only delays the 
failure."""
+    error = _http_error(
+        403,
+        headers={"x-ratelimit-remaining": "4998"},
+        body=b'{"message": "Resource not accessible by integration"}',
+    )
+    with (
+        mock.patch.object(
+            change_detector, "urlopen", side_effect=error
+        ) as urlopen_mock,
+        mock.patch.object(change_detector.time, "sleep") as sleep_mock,
+    ):
+        with pytest.raises(HTTPError):
+            change_detector.fetch_files_github_api("http://api";)
+
+    assert urlopen_mock.call_count == 1
+    sleep_mock.assert_not_called()
+
+
+def test_fetch_retries_a_rate_limited_403() -> None:
+    """GitHub signals rate limiting with 403 and an exhausted remaining 
count."""
+    side_effects: list[object] = [
+        _http_error(403, headers={"x-ratelimit-remaining": "0"}),
+        _make_response(b'[{"filename": "superset/foo.py"}]'),
+    ]
+    with (
+        mock.patch.object(change_detector, "urlopen", 
side_effect=side_effects),
+        mock.patch.object(change_detector.time, "sleep"),
+    ):
+        result = change_detector.fetch_files_github_api("http://api";)
+
+    assert result == [{"filename": "superset/foo.py"}]
+
+
+def test_fetch_reports_the_api_error_message(capsys) -> None:
+    """The response body names the cause; without it a 403 is unactionable."""
+    error = _http_error(
+        403,
+        headers={"x-ratelimit-remaining": "4998"},
+        body=b'{"message": "Resource not accessible by integration"}',
+    )
+    with mock.patch.object(change_detector, "urlopen", side_effect=error):
+        with pytest.raises(HTTPError):
+            change_detector.fetch_files_github_api("http://api";)
+
+    assert "Resource not accessible by integration" in capsys.readouterr().out
diff --git a/tests/unit_tests/security/test_audit_event_metrics.py 
b/tests/unit_tests/security/test_audit_event_metrics.py
new file mode 100644
index 00000000000..858d57f99f8
--- /dev/null
+++ b/tests/unit_tests/security/test_audit_event_metrics.py
@@ -0,0 +1,55 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+"""Tests that role/group audit events are also observable as StatsD metrics.
+
+The group and role write endpoints record audit events through
+``_log_audit_event``. Operators need the same activity as counters, so that
+group management is visible on dashboards without parsing the event log.
+"""
+
+from unittest.mock import MagicMock
+
+from pytest_mock import MockerFixture
+
+from superset.security.manager import _log_audit_event
+
+
+def test_audit_event_increments_a_statsd_counter(
+    mocker: MockerFixture,
+    app_context,
+) -> None:
+    stats = MagicMock()
+    mocker.patch("superset.extensions.stats_logger_manager", stats)
+    mocker.patch("superset.extensions.event_logger")
+
+    _log_audit_event("GroupCreated", {"group_id": 1, "group_name": "tenant-a"})
+
+    stats.instance.incr.assert_called_once_with("security.GroupCreated")
+
+
+def test_audit_event_is_still_logged_when_statsd_fails(
+    mocker: MockerFixture,
+    app_context,
+) -> None:
+    stats = MagicMock()
+    stats.instance.incr.side_effect = RuntimeError("statsd down")
+    mocker.patch("superset.extensions.stats_logger_manager", stats)
+    event_logger = mocker.patch("superset.extensions.event_logger")
+
+    _log_audit_event("GroupDeleted", {"group_id": 1, "group_name": "tenant-a"})
+
+    event_logger.log.assert_called_once()
diff --git a/tests/unit_tests/subjects/test_api_filters.py 
b/tests/unit_tests/subjects/test_api_filters.py
new file mode 100644
index 00000000000..a10c8a42569
--- /dev/null
+++ b/tests/unit_tests/subjects/test_api_filters.py
@@ -0,0 +1,82 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+"""Tests that ``SubjectRestApi`` scopes its own list/show queries.
+
+``SubjectRestApi`` exposes the principal directory (including ``user.email``),
+so the ``EXCLUDE_USERS_FROM_LISTS`` and ``EXTRA_RELATED_QUERY_FILTERS``
+deployment settings must apply to it, exactly as they do to the related-item
+endpoints that feed the editor/viewer pickers.
+"""
+
+from unittest.mock import MagicMock
+
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+from pytest_mock import MockerFixture
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+from superset.subjects.filters import SubjectListFilter
+from superset.subjects.models import Subject
+
+
+def _compiled(query) -> str:
+    engine = create_engine("sqlite://")
+    return str(
+        query.statement.compile(engine, compile_kwargs={"literal_binds": 
True}),
+    )
+
+
+def _subject_query():
+    session = sessionmaker(bind=create_engine("sqlite://"))()
+    return session.query(Subject)
+
+
+def test_subject_list_filter_excludes_configured_users(
+    mocker: MockerFixture,
+) -> None:
+    mock_current_app = MagicMock()
+    mock_current_app.config = {
+        "EXCLUDE_USERS_FROM_LISTS": ["service_account"],
+        "EXTRA_RELATED_QUERY_FILTERS": {},
+    }
+    mocker.patch("superset.subjects.filters.current_app", mock_current_app)
+
+    filter_ = SubjectListFilter("id", SQLAInterface(Subject))
+    filtered = filter_.apply(_subject_query(), None)
+
+    assert "service_account" in _compiled(filtered)
+
+
+def test_subject_list_filter_is_a_noop_without_configuration(
+    mocker: MockerFixture,
+) -> None:
+    mock_current_app = MagicMock()
+    mock_current_app.config = {}
+    mocker.patch("superset.subjects.filters.current_app", mock_current_app)
+
+    query = _subject_query()
+    filter_ = SubjectListFilter("id", SQLAInterface(Subject))
+
+    assert filter_.apply(query, None) is query
+
+
+def test_subject_rest_api_registers_the_scoping_base_filter() -> None:
+    from superset.subjects.api import SubjectRestApi
+
+    registered = [spec[1] for spec in SubjectRestApi.base_filters]
+
+    assert SubjectListFilter in registered
diff --git a/tests/unit_tests/subjects/test_creator_group_call_sites.py 
b/tests/unit_tests/subjects/test_creator_group_call_sites.py
new file mode 100644
index 00000000000..03ab39f6f95
--- /dev/null
+++ b/tests/unit_tests/subjects/test_creator_group_call_sites.py
@@ -0,0 +1,415 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+"""Tests that asset-creation paths attach the creator's default viewers.
+
+``get_default_viewers_for_new_asset`` is covered on its own elsewhere; these
+tests pin the wiring at the call sites that build an asset outside the create
+commands, so removing one fails the suite. Covered here: both v1 importers,
+the template copy, ``/dashboard/new/``, the dashboard-copy DAO (including its
+cloned charts), the legacy explore save paths, and ``reset_ownership`` (used
+by the v0 importers). The MCP ``generate_dashboard`` tool is covered by
+``tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py``.
+"""
+
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from superset.subjects.models import Subject
+from superset.subjects.types import SubjectType
+
+
+def _group_subject(id_: int = 11) -> Subject:
+    subject = Subject()
+    subject.id = id_
+    subject.type = SubjectType.GROUP
+    return subject
+
+
+def _user(user_id: int = 5) -> SimpleNamespace:
+    return SimpleNamespace(id=user_id)
+
+
+def test_chart_importer_attaches_default_viewers(app_context) -> None:
+    from superset.commands.chart.importers.v1 import utils as chart_utils
+
+    viewer = _group_subject()
+    chart = SimpleNamespace(id=1, editors=[], viewers=[])
+
+    with (
+        patch.object(chart_utils, "find_existing_for_import", 
return_value=None),
+        patch.object(chart_utils, "get_user", return_value=_user()),
+        patch.object(chart_utils, "filter_chart_annotations"),
+        patch.object(chart_utils, "migrate_chart", side_effect=lambda config: 
config),
+        patch.object(chart_utils.Slice, "import_from_dict", 
return_value=chart),
+        patch("superset.subjects.utils.get_user_subject", return_value=None),
+        patch(
+            "superset.subjects.utils.get_default_viewers_for_new_asset",
+            return_value=[viewer],
+        ),
+    ):
+        result = chart_utils.import_chart(
+            {"uuid": "x", "params": {}}, ignore_permissions=True
+        )
+
+    assert result.viewers == [viewer]
+
+
+def test_chart_importer_adds_missing_viewers_without_duplicating_present_ones(
+    app_context,
+) -> None:
+    from superset.commands.chart.importers.v1 import utils as chart_utils
+
+    already_there = _group_subject(11)
+    newly_added = _group_subject(12)
+    chart = SimpleNamespace(id=1, editors=[], viewers=[already_there])
+
+    with (
+        patch.object(chart_utils, "find_existing_for_import", 
return_value=None),
+        patch.object(chart_utils, "get_user", return_value=_user()),
+        patch.object(chart_utils, "filter_chart_annotations"),
+        patch.object(chart_utils, "migrate_chart", side_effect=lambda config: 
config),
+        patch.object(chart_utils.Slice, "import_from_dict", 
return_value=chart),
+        patch("superset.subjects.utils.get_user_subject", return_value=None),
+        patch(
+            "superset.subjects.utils.get_default_viewers_for_new_asset",
+            return_value=[already_there, newly_added],
+        ),
+    ):
+        result = chart_utils.import_chart(
+            {"uuid": "x", "params": {}}, ignore_permissions=True
+        )
+
+    assert result.viewers == [already_there, newly_added]
+
+
+def test_copy_dashboard_attaches_viewers_from_the_users_in_memory_groups(
+    app_context,
+) -> None:
+    """The template copy runs in ``after_insert``, before ``ab_user_group`` 
exists."""
+    from superset.models import dashboard as dashboard_module
+
+    viewer = _group_subject()
+    group = MagicMock()
+    new_user = MagicMock()
+    new_user.id = 5
+    new_user.groups = [group]
+    session = MagicMock()
+    # Both lookups (the user, then the template dashboard) go through the same
+    # mocked chain; a MagicMock stands in for either.
+    session.query.return_value.filter_by.return_value.first.return_value = 
new_user
+
+    created: dict[str, Any] = {}
+
+    def _capture(**kwargs):
+        created.update(kwargs)
+        return MagicMock()
+
+    with (
+        patch.dict(dashboard_module.app.config, {"DASHBOARD_TEMPLATE_ID": 1}),
+        patch.object(
+            dashboard_module.sqla, "inspect", 
return_value=MagicMock(session=session)
+        ),
+        patch.object(dashboard_module, "Dashboard", side_effect=_capture),
+        patch("superset.subjects.utils.get_user_subject", return_value=None),
+        patch(
+            "superset.subjects.utils.get_default_viewers_for_groups",
+            return_value=[viewer],
+        ) as mock_for_groups,
+    ):
+        dashboard_module.copy_dashboard(
+            MagicMock(),
+            MagicMock(),
+            SimpleNamespace(id=5),  # type: ignore[arg-type]
+        )
+
+    assert created["viewers"] == [viewer]
+    # Resolved from the in-memory collection, never by querying membership.
+    mock_for_groups.assert_called_once_with([group])
+
+
+def test_dashboard_importer_attaches_default_viewers(app_context) -> None:
+    from superset.commands.dashboard.importers.v1 import utils as 
dashboard_utils
+
+    viewer = _group_subject()
+    dashboard = SimpleNamespace(id=1, editors=[], viewers=[])
+
+    with (
+        patch.object(dashboard_utils, "find_existing_for_import", 
return_value=None),
+        patch.object(dashboard_utils, "get_user", return_value=_user()),
+        patch.object(
+            dashboard_utils.Dashboard, "import_from_dict", 
return_value=dashboard
+        ),
+        patch("superset.subjects.utils.get_user_subject", return_value=None),
+        patch(
+            "superset.subjects.utils.get_default_viewers_for_new_asset",
+            return_value=[viewer],
+        ),
+    ):
+        result = dashboard_utils.import_dashboard(
+            {"uuid": "x", "metadata": {}}, ignore_permissions=True
+        )
+
+    assert result.viewers == [viewer]
+
+
+def test_new_dashboard_view_attaches_default_viewers(app_context) -> None:
+    from superset.views.dashboard import views as dashboard_views
+
+    viewer = _group_subject()
+    created: dict[str, Any] = {}
+
+    def _capture(**kwargs):
+        created.update(kwargs)
+        return MagicMock(id=1)
+
+    # Strip ``@has_access``/``@expose`` so the handler body runs directly.
+    handler = dashboard_views.Dashboard.new
+    while hasattr(handler, "__wrapped__"):
+        handler = handler.__wrapped__
+
+    with (
+        patch.object(dashboard_views, "DashboardModel", side_effect=_capture),
+        patch.object(dashboard_views, "db"),
+        patch.object(dashboard_views, "redirect"),
+        patch.object(dashboard_views, "url_for"),
+        patch.object(dashboard_views, "g", MagicMock(user=_user())),
+        patch("superset.subjects.utils.get_user_subject", return_value=None),
+        patch(
+            "superset.subjects.utils.get_default_viewers_for_new_asset",
+            return_value=[viewer],
+        ),
+    ):
+        handler(MagicMock())
+
+    assert created["viewers"] == [viewer]
+
+
+def test_dashboard_copy_dao_attaches_default_viewers(app_context) -> None:
+    from superset.daos import dashboard as dashboard_dao
+
+    viewer = _group_subject()
+    original = MagicMock()
+    original.slices = []
+    created: list[Any] = []
+
+    with (
+        patch.object(dashboard_dao.security_manager, "is_editor", 
return_value=True),
+        patch.object(dashboard_dao, "g", MagicMock(user=_user())),
+        patch.object(dashboard_dao, "db"),
+        patch.object(dashboard_dao.DashboardDAO, "set_dash_metadata"),
+        patch.object(
+            dashboard_dao,
+            "Dashboard",
+            side_effect=lambda: created.append(MagicMock())  # type: 
ignore[func-returns-value]
+            or created[-1],
+        ),
+        patch("superset.subjects.utils.get_user_subject", return_value=None),
+        patch(
+            "superset.subjects.utils.get_default_viewers_for_new_asset",
+            return_value=[viewer],
+        ),
+    ):
+        dashboard_dao.DashboardDAO.copy_dashboard(
+            original, {"dashboard_title": "copy", "json_metadata": "{}"}
+        )
+
+    assert created[-1].viewers == [viewer]
+
+
+def test_dashboard_copy_dao_attaches_default_viewers_to_cloned_charts(
+    app_context,
+) -> None:
+    from superset.daos import dashboard as dashboard_dao
+
+    viewer = _group_subject()
+    clone = MagicMock()
+    source_slice = MagicMock()
+    source_slice.clone.return_value = clone
+    original = MagicMock()
+    original.slices = [source_slice]
+
+    with (
+        patch.object(dashboard_dao.security_manager, "is_editor", 
return_value=True),
+        patch.object(dashboard_dao, "g", MagicMock(user=_user())),
+        patch.object(dashboard_dao, "db"),
+        patch.object(dashboard_dao.DashboardDAO, "set_dash_metadata"),
+        patch.object(dashboard_dao, "Dashboard", return_value=MagicMock()),
+        patch("superset.subjects.utils.get_user_subject", return_value=None),
+        patch(
+            "superset.subjects.utils.get_default_viewers_for_new_asset",
+            return_value=[viewer],
+        ),
+    ):
+        dashboard_dao.DashboardDAO.copy_dashboard(
+            original,
+            {
+                "dashboard_title": "copy",
+                "json_metadata": '{"positions": {}}',
+                "duplicate_slices": True,
+            },
+        )
+
+    assert clone.viewers == [viewer]
+
+
+def test_reset_ownership_attaches_default_viewers(app_context) -> None:
+    """Used by the v0 importers, which never went through the v1 wiring."""
+    from superset.models.helpers import ImportExportMixin
+
+    viewer = _group_subject()
+
+    class _Asset(ImportExportMixin):
+        def __init__(self) -> None:
+            self.editors: list[Any] = []
+            self.viewers: list[Any] = []
+
+    asset = _Asset()
+
+    with (
+        patch("superset.models.helpers.g", MagicMock(user=_user())),
+        patch("superset.subjects.utils.get_user_subject", return_value=None),
+        patch(
+            "superset.subjects.utils.get_default_viewers_for_new_asset",
+            return_value=[viewer],
+        ),
+    ):
+        asset.reset_ownership()
+
+    assert asset.viewers == [viewer]
+
+
+class _ConstructedError(Exception):
+    """Raised once the asset under test has been constructed."""
+
+
+def _capturing(store: dict[str, Any]):
+    """Record constructor kwargs, then abort the surrounding request handler.
+
+    ``save_or_overwrite_slice`` does a lot of work after building the asset;
+    stopping at construction keeps these tests focused on the wiring.
+    """
+
+    def _factory(**kwargs):
+        store.update(kwargs)
+        raise _ConstructedError
+
+    return _factory
+
+
+def test_legacy_explore_saveas_attaches_default_viewers(app_context) -> None:
+    """The ``@deprecated`` explore save route is still mounted and 
reachable."""
+    from superset.views import core as core_views
+
+    viewer = _group_subject()
+    created: dict[str, Any] = {}
+    request = MagicMock()
+    request.args.get.side_effect = lambda key, *a: {
+        "slice_name": "n",
+        "action": "saveas",
+    }.get(key)
+
+    with (
+        patch.object(core_views, "request", request),
+        patch.object(
+            core_views, "get_form_data", return_value=({"viz_type": "table"}, 
None)
+        ),
+        patch.object(core_views, "Slice", side_effect=_capturing(created)),
+        patch.object(core_views, "g", MagicMock(user=_user())),
+        patch("superset.subjects.utils.get_user_subject", return_value=None),
+        patch(
+            "superset.subjects.utils.get_default_viewers_for_new_asset",
+            return_value=[viewer],
+        ),
+        pytest.raises(_ConstructedError),
+    ):
+        core_views.Superset.save_or_overwrite_slice(
+            None, True, False, False, 1, "table", "t"
+        )
+
+    assert created["viewers"] == [viewer]
+
+
+def test_legacy_explore_new_dashboard_attaches_default_viewers(app_context) -> 
None:
+    """Saving a chart to a brand new dashboard from legacy explore."""
+    from superset.views import core as core_views
+
+    viewer = _group_subject()
+    created: dict[str, Any] = {}
+    request = MagicMock()
+    request.args.get.side_effect = lambda key, *a: {
+        "slice_name": "n",
+        "new_dashboard_name": "d",
+    }.get(key)
+
+    with (
+        patch.object(core_views, "request", request),
+        patch.object(
+            core_views, "get_form_data", return_value=({"viz_type": "table"}, 
None)
+        ),
+        patch.object(core_views, "Dashboard", side_effect=_capturing(created)),
+        patch.object(core_views, "g", MagicMock(user=_user())),
+        patch.object(core_views.security_manager, "can_access", 
return_value=True),
+        patch.object(core_views.utils, "remove_extra_adhoc_filters"),
+        patch("superset.subjects.utils.get_user_subject", return_value=None),
+        patch(
+            "superset.subjects.utils.get_default_viewers_for_new_asset",
+            return_value=[viewer],
+        ),
+        pytest.raises(_ConstructedError),
+    ):
+        core_views.Superset.save_or_overwrite_slice(
+            MagicMock(), False, False, False, 1, "table", "t"
+        )
+
+    assert created["viewers"] == [viewer]
+
+
+def test_dashboard_copy_dao_resolves_creator_subjects_once(app_context) -> 
None:
+    """Both lookups are loop-invariant: N cloned charts must not mean N 
queries."""
+    from superset.daos import dashboard as dashboard_dao
+
+    original = MagicMock()
+    original.slices = [MagicMock(), MagicMock(), MagicMock()]
+
+    with (
+        patch.object(dashboard_dao.security_manager, "is_editor", 
return_value=True),
+        patch.object(dashboard_dao, "g", MagicMock(user=_user())),
+        patch.object(dashboard_dao, "db"),
+        patch.object(dashboard_dao.DashboardDAO, "set_dash_metadata"),
+        patch.object(dashboard_dao, "Dashboard", return_value=MagicMock()),
+        patch(
+            "superset.subjects.utils.get_user_subject", return_value=None
+        ) as mock_user_subject,
+        patch(
+            "superset.subjects.utils.get_default_viewers_for_new_asset",
+            return_value=[_group_subject()],
+        ) as mock_viewers,
+    ):
+        dashboard_dao.DashboardDAO.copy_dashboard(
+            original,
+            {
+                "dashboard_title": "copy",
+                "json_metadata": '{"positions": {}}',
+                "duplicate_slices": True,
+            },
+        )
+
+    assert mock_user_subject.call_count == 1
+    assert mock_viewers.call_count == 1
diff --git a/tests/unit_tests/subjects/test_creator_group_defaults.py 
b/tests/unit_tests/subjects/test_creator_group_defaults.py
new file mode 100644
index 00000000000..ce0afb0dbd5
--- /dev/null
+++ b/tests/unit_tests/subjects/test_creator_group_defaults.py
@@ -0,0 +1,270 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+"""Tests for defaulting a new asset's viewers to its creator's groups.
+
+Assigning the creator's groups as *viewers* (rather than editors) shares the
+asset read-only, and is gated behind ``ASSIGN_CREATOR_GROUPS_AS_VIEWERS``
+because populating ``viewers`` changes how access is enforced: once an asset
+has any viewer, the datasource-permission fallback no longer applies to it.
+"""
+
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+from superset.commands.utils import populate_subjects
+from superset.subjects.models import Subject
+from superset.subjects.types import SubjectType
+from superset.subjects.utils import (
+    get_default_viewers_for_groups,
+    get_default_viewers_for_new_asset,
+    get_user_group_subject_ids_subquery,
+)
+
+
+def _group_subject(id_: int) -> Subject:
+    subject = Subject()
+    subject.id = id_
+    subject.type = SubjectType.GROUP
+    return subject
+
+
+def test_user_group_subject_ids_subquery_joins_group_membership(app_context) 
-> None:
+    sql = str(
+        get_user_group_subject_ids_subquery(7).compile(
+            compile_kwargs={"literal_binds": True}
+        )
+    )
+
+    assert "ab_user_group" in sql
+    assert "user_id = 7" in sql
+
+
+@patch("superset.commands.utils.get_user_id", return_value=5)
+@patch("superset.commands.utils.populate_subject_list")
+@patch("superset.subjects.utils.get_user_group_subjects")
+def test_new_asset_viewers_default_to_creator_groups_when_enabled(
+    mock_groups: MagicMock,
+    mock_populate: MagicMock,
+    mock_user_id: MagicMock,
+    app_context,
+) -> None:
+    groups = [_group_subject(11), _group_subject(12)]
+    mock_groups.return_value = groups
+    mock_populate.return_value = [_group_subject(1)]
+    properties: dict[str, Any] = {}
+
+    with patch.dict(
+        "superset.commands.utils.current_app.config",
+        {"ASSIGN_CREATOR_GROUPS_AS_VIEWERS": True},
+    ):
+        populate_subjects(properties, [])
+
+    assert properties["viewers"] == groups
+
+
+@patch("superset.commands.utils.get_user_id", return_value=5)
+@patch("superset.commands.utils.populate_subject_list")
+@patch("superset.subjects.utils.get_user_group_subjects")
+def test_new_asset_gets_no_default_viewers_when_disabled(
+    mock_groups: MagicMock,
+    mock_populate: MagicMock,
+    mock_user_id: MagicMock,
+    app_context,
+) -> None:
+    mock_groups.return_value = [_group_subject(11)]
+    mock_populate.return_value = [_group_subject(1)]
+    properties: dict[str, Any] = {}
+
+    with patch.dict(
+        "superset.commands.utils.current_app.config",
+        {"ASSIGN_CREATOR_GROUPS_AS_VIEWERS": False},
+    ):
+        populate_subjects(properties, [])
+
+    assert "viewers" not in properties
+
+
+@patch("superset.subjects.utils.get_user_group_subjects")
+def test_default_viewers_are_empty_for_an_anonymous_creator(
+    mock_groups: MagicMock,
+    app_context,
+) -> None:
+    with patch.dict(
+        "superset.subjects.utils.current_app.config",
+        {"ASSIGN_CREATOR_GROUPS_AS_VIEWERS": True},
+    ):
+        assert get_default_viewers_for_new_asset(None) == []
+
+    mock_groups.assert_not_called()
+
+
+@patch("superset.subjects.utils.get_user_group_subjects")
+def test_default_viewers_are_empty_when_the_setting_is_off(
+    mock_groups: MagicMock,
+    app_context,
+) -> None:
+    with patch.dict(
+        "superset.subjects.utils.current_app.config",
+        {"ASSIGN_CREATOR_GROUPS_AS_VIEWERS": False},
+    ):
+        assert get_default_viewers_for_new_asset(5) == []
+
+    mock_groups.assert_not_called()
+
+
+@patch("superset.subjects.utils.get_user_group_subjects")
+def test_default_viewers_are_the_creators_groups_when_enabled(
+    mock_groups: MagicMock,
+    app_context,
+) -> None:
+    groups = [_group_subject(11)]
+    mock_groups.return_value = groups
+
+    with patch.dict(
+        "superset.subjects.utils.current_app.config",
+        {"ASSIGN_CREATOR_GROUPS_AS_VIEWERS": True},
+    ):
+        assert get_default_viewers_for_new_asset(5) == groups
+
+
+@patch("superset.commands.utils.get_user_id", return_value=5)
+@patch("superset.commands.utils.populate_subject_list")
+@patch("superset.subjects.utils.get_user_group_subjects")
+def test_explicit_viewers_are_not_replaced_by_creator_groups(
+    mock_groups: MagicMock,
+    mock_populate: MagicMock,
+    mock_user_id: MagicMock,
+    app_context,
+) -> None:
+    chosen = [_group_subject(99)]
+    mock_groups.return_value = [_group_subject(11)]
+    mock_populate.side_effect = [[_group_subject(1)], chosen]
+    properties: dict[str, Any] = {"viewers": [99]}
+
+    with patch.dict(
+        "superset.commands.utils.current_app.config",
+        {"ASSIGN_CREATOR_GROUPS_AS_VIEWERS": True},
+    ):
+        populate_subjects(properties, [])
+
+    assert properties["viewers"] == chosen
+    mock_groups.assert_not_called()
+
+
+@patch("superset.subjects.utils.subjects_from_groups")
+def test_default_viewers_for_groups_resolves_in_memory_groups(
+    mock_from_groups: MagicMock,
+    app_context,
+) -> None:
+    """Flush-time callers hold groups in memory before ab_user_group is 
written."""
+    groups = [_group_subject(11)]
+    mock_from_groups.return_value = groups
+
+    with patch.dict(
+        "superset.subjects.utils.current_app.config",
+        {"ASSIGN_CREATOR_GROUPS_AS_VIEWERS": True},
+    ):
+        assert get_default_viewers_for_groups([MagicMock()]) == groups
+
+
+@patch("superset.subjects.utils.subjects_from_groups")
+def test_default_viewers_for_groups_is_empty_when_the_setting_is_off(
+    mock_from_groups: MagicMock,
+    app_context,
+) -> None:
+    with patch.dict(
+        "superset.subjects.utils.current_app.config",
+        {"ASSIGN_CREATOR_GROUPS_AS_VIEWERS": False},
+    ):
+        assert get_default_viewers_for_groups([MagicMock()]) == []
+
+    mock_from_groups.assert_not_called()
+
+
+@patch("superset.subjects.utils.get_user_group_subjects")
+def test_user_id_zero_is_treated_as_a_real_principal(
+    mock_groups: MagicMock,
+    app_context,
+) -> None:
+    """A falsy-but-valid id must not be conflated with 'no user'."""
+    groups = [_group_subject(11)]
+    mock_groups.return_value = groups
+
+    with patch.dict(
+        "superset.subjects.utils.current_app.config",
+        {"ASSIGN_CREATOR_GROUPS_AS_VIEWERS": True},
+    ):
+        assert get_default_viewers_for_new_asset(0) == groups
+
+
+def test_user_group_subject_ids_subquery_restricts_to_group_subjects(
+    app_context,
+) -> None:
+    """Defense in depth: don't rely on ``group_id`` alone to imply the type."""
+    sql = str(
+        get_user_group_subject_ids_subquery(7).compile(
+            compile_kwargs={"literal_binds": True}
+        )
+    )
+
+    assert "subjects.type = 3" in sql
+
+
+def test_subjects_from_groups_issues_a_single_query(app_context) -> None:
+    """One query for the whole list, not one per group."""
+    from superset.subjects import utils as subjects_utils
+
+    with patch.object(subjects_utils, "db") as mock_db:
+        
mock_db.session.query.return_value.filter.return_value.all.return_value = []
+        subjects_utils.subjects_from_groups([MagicMock(id=1), MagicMock(id=2)])
+
+    assert mock_db.session.query.call_count == 1
+
+
+@patch("superset.commands.utils.get_user_id", return_value=5)
+@patch("superset.commands.utils.populate_subject_list", return_value=[])
+@patch("superset.subjects.utils.get_user_group_subjects")
+def test_an_explicit_empty_viewers_list_suppresses_the_group_default(
+    mock_groups: MagicMock,
+    mock_populate: MagicMock,
+    mock_user_id: MagicMock,
+    app_context,
+) -> None:
+    """``viewers: []`` means "no viewers", not "fall back to my groups"."""
+    properties: dict[str, Any] = {"viewers": []}
+
+    with patch.dict(
+        "superset.commands.utils.current_app.config",
+        {"ASSIGN_CREATOR_GROUPS_AS_VIEWERS": True},
+    ):
+        populate_subjects(properties, [])
+
+    assert properties["viewers"] == []
+    mock_groups.assert_not_called()
+
+
+@patch("superset.subjects.utils.get_user_group_subjects", return_value=[])
+def test_a_creator_without_groups_leaves_the_dataset_fallback_intact(
+    mock_groups: MagicMock,
+    app_context,
+) -> None:
+    """No groups means no viewers, so the asset keeps dataset-based access."""
+    with patch.dict(
+        "superset.subjects.utils.current_app.config",
+        {"ASSIGN_CREATOR_GROUPS_AS_VIEWERS": True},
+    ):
+        assert get_default_viewers_for_new_asset(5) == []
diff --git a/tests/unit_tests/views/test_related_include_ids.py 
b/tests/unit_tests/views/test_related_include_ids.py
new file mode 100644
index 00000000000..dc9e42802a8
--- /dev/null
+++ b/tests/unit_tests/views/test_related_include_ids.py
@@ -0,0 +1,124 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+"""Tests that ``?include_ids=`` cannot bypass related-field scoping.
+
+The ``/related/<column>`` endpoints scope their results with
+``base_related_field_filters``. The ``include_ids`` argument force-fetches
+specific rows, and must not be able to resolve principals that those filters
+deliberately hide.
+
+``include_ids`` exists so an edit form can render a value that is already
+associated with a record but falls outside the current page or search of the
+picker. Scoping it has an accepted consequence: a value that is already
+associated but has since been hidden by ``EXCLUDE_USERS_FROM_LISTS`` or
+``EXTRA_RELATED_QUERY_FILTERS`` stops resolving, so the form shows the bare id
+rather than a label. That is deliberate — a principal a deployment has chosen
+to hide must not be resolvable through a side door, and the association itself
+is untouched in the database.
+"""
+
+from typing import Any
+
+from flask_appbuilder.models.filters import BaseFilter
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+from pytest_mock import MockerFixture
+from sqlalchemy import Column, create_engine, Integer, String
+from sqlalchemy.orm import declarative_base, sessionmaker
+
+from superset.views.base_api import BaseSupersetModelRestApi
+
+Base = declarative_base()
+
+
+class Principal(Base):  # type: ignore[misc, valid-type]
+    __tablename__ = "principal"
+
+    id = Column(Integer, primary_key=True)
+    name = Column(String(50))
+
+    def __repr__(self) -> str:
+        return str(self.name)
+
+
+class ExcludeHiddenFilter(BaseFilter):  # pylint: 
disable=too-few-public-methods
+    """Stand-in for a deployment's principal-scoping filter."""
+
+    name = "Exclude hidden"
+    arg_name = "exclude_hidden"
+
+    def apply(self, query: Any, value: Any) -> Any:
+        return query.filter(Principal.name != "hidden")
+
+
+def _session():
+    engine = create_engine("sqlite://")
+    Base.metadata.create_all(engine)
+    session = sessionmaker(bind=engine)()
+    session.add_all(
+        [Principal(id=1, name="visible"), Principal(id=2, name="hidden")],
+    )
+    session.commit()
+    return session
+
+
+def _api(**overrides: Any) -> BaseSupersetModelRestApi:
+    api = BaseSupersetModelRestApi.__new__(BaseSupersetModelRestApi)
+    api.text_field_rel_fields = {}
+    api.extra_fields_rel_fields = {}
+    api.base_related_field_filters = {}
+    for key, value in overrides.items():
+        setattr(api, key, value)
+    return api
+
+
+def test_include_ids_cannot_resolve_a_filtered_out_principal(
+    mocker: MockerFixture,
+) -> None:
+    """Holds even when the principal is already associated with the record.
+
+    This is the case an edit form hits: the client sends the id of a current
+    owner, and a hidden principal must still not resolve to a label.
+    """
+    session = _session()
+    mocker.patch("superset.views.base_api.db").session = session
+    api = _api(
+        base_related_field_filters={
+            "owners": [["name", ExcludeHiddenFilter, None]],
+        },
+    )
+    result: list[dict[str, Any]] = []
+
+    api._add_extra_ids_to_result(  # noqa: SLF001
+        SQLAInterface(Principal, session), "owners", [1, 2], result
+    )
+
+    assert [row["value"] for row in result] == [1]
+
+
+def test_include_ids_still_resolves_when_no_filters_are_configured(
+    mocker: MockerFixture,
+) -> None:
+    session = _session()
+    mocker.patch("superset.views.base_api.db").session = session
+    api = _api()
+    result: list[dict[str, Any]] = []
+
+    api._add_extra_ids_to_result(  # noqa: SLF001
+        SQLAInterface(Principal, session), "owners", [1, 2], result
+    )
+
+    assert sorted(row["value"] for row in result) == [1, 2]
diff --git a/tests/unit_tests/views/test_user_schemas.py 
b/tests/unit_tests/views/test_user_schemas.py
new file mode 100644
index 00000000000..6fafc344d9c
--- /dev/null
+++ b/tests/unit_tests/views/test_user_schemas.py
@@ -0,0 +1,52 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+"""Unit tests for the current-user (``/api/v1/me/``) response schema."""
+
+from types import SimpleNamespace
+
+from superset.views.users.schemas import UserResponseSchema
+
+
+def _user(**overrides):
+    """Build a stand-in for a FAB ``User`` row."""
+    attrs = {
+        "id": 1,
+        "username": "alice",
+        "email": "[email protected]",
+        "first_name": "Alice",
+        "last_name": "Doe",
+        "is_active": True,
+        "is_anonymous": False,
+        "login_count": 3,
+        "groups": [],
+    }
+    attrs.update(overrides)
+    return SimpleNamespace(**attrs)
+
+
+def test_user_response_includes_the_callers_groups():
+    groups = [SimpleNamespace(id=7, name="tenant-a")]
+
+    result = UserResponseSchema().dump(_user(groups=groups))
+
+    assert result["groups"] == [{"id": 7, "name": "tenant-a"}]
+
+
+def test_user_response_groups_is_empty_list_when_user_has_no_groups():
+    result = UserResponseSchema().dump(_user(groups=[]))
+
+    assert result["groups"] == []

Reply via email to