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

EnxDev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 069605828d1 feat(subjects): scope principal listings and default new 
assets to creator groups (#42472)
069605828d1 is described below

commit 069605828d109cfc73b38f0424128eb446fc434c
Author: Enzo Martellucci <[email protected]>
AuthorDate: Wed Jul 29 14:36:45 2026 +0200

    feat(subjects): scope principal listings and default new assets to creator 
groups (#42472)
    
    Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
 .github/workflows/superset-frontend.yml            |   6 +
 UPDATING.md                                        |  24 +
 docs/admin_docs/security/security.mdx              |  45 ++
 docs/static/feature-flags.json                     |   7 +
 scripts/change_detector.py                         |  47 +-
 superset/commands/chart/importers/v1/__init__.py   |   9 +-
 superset/commands/chart/importers/v1/utils.py      |  23 +-
 .../commands/dashboard/importers/v1/__init__.py    |  15 +-
 superset/commands/dashboard/importers/v1/utils.py  |  17 +-
 superset/commands/dataset/create.py                |   4 +-
 superset/commands/importers/v1/assets.py           |  13 +-
 superset/commands/importers/v1/examples.py         |   7 +
 superset/commands/utils.py                         |   8 +-
 superset/config.py                                 |   9 +
 superset/daos/dashboard.py                         |  24 +-
 .../dashboard/tool/generate_dashboard.py           |   8 +-
 superset/models/dashboard.py                       |  10 +-
 superset/models/helpers.py                         |  11 +-
 superset/security/manager.py                       |  10 +-
 superset/subjects/api.py                           |   3 +-
 superset/subjects/filters.py                       |  16 +
 superset/subjects/utils.py                         | 206 ++++++++-
 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      |  84 ++++
 .../subjects/test_creator_group_call_sites.py      | 508 +++++++++++++++++++++
 .../subjects/test_creator_group_defaults.py        | 293 ++++++++++++
 tests/unit_tests/subjects/test_utils.py            | 190 ++++++++
 tests/unit_tests/views/test_related_include_ids.py | 124 +++++
 tests/unit_tests/views/test_user_schemas.py        |  52 +++
 38 files changed, 2035 insertions(+), 35 deletions(-)

diff --git a/.github/workflows/superset-frontend.yml 
b/.github/workflows/superset-frontend.yml
index 46cb237c4d8..a7e20ab8235 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/UPDATING.md b/UPDATING.md
index aac6792c8fb..c5eb722118f 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -24,6 +24,30 @@ assists people when migrating to a new version.
 
 ## Next
 
+### Principal listing APIs now honour related-field filters
+
+Two authorization-related listing behaviors changed for API clients. Neither
+affects the Superset UI — `include_ids` and `security/subject` have no frontend
+callers — but external clients will notice:
+
+- `GET /api/v1/<resource>/related/<column>?include_ids=...` now applies the
+  endpoint's `base_related_field_filters`. Previously the forced IDs were 
fetched
+  with no filtering, so a caller could resolve principals the related-field
+  filters deliberately hide; those IDs are now omitted from the response.
+- `GET /api/v1/security/subject/` now honours `EXCLUDE_USERS_FROM_LISTS` and
+  `EXTRA_RELATED_QUERY_FILTERS`, matching every other principal-listing 
endpoint.
+  List counts may drop, and fetching an excluded principal via
+  `GET /api/v1/security/subject/<id>` now returns `404`.
+
+### v1 chart import no longer re-adds the importer as editor on overwrite
+
+Re-importing over an existing chart (an overwrite or a soft-delete restore) no
+longer appends the importing user to the chart's `editors`, matching the
+dashboard importer's behavior. This changes anything only for a user who was 
not
+already an editor of that chart — typically an admin overwriting a chart they 
do
+not own, who was previously added as an editor as a side effect of the import.
+Newly-created charts are unaffected.
+
 ### Dashboard "Export Data to Excel" requires a Celery worker and S3 bucket
 
 A new dashboard action exports every chart's data to a single multi-sheet
diff --git a/docs/admin_docs/security/security.mdx 
b/docs/admin_docs/security/security.mdx
index e6c11a7ae85..0045f25241e 100644
--- a/docs/admin_docs/security/security.mdx
+++ b/docs/admin_docs/security/security.mdx
@@ -247,6 +247,51 @@ 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
+FEATURE_FLAGS = {
+    "ENABLE_VIEWERS": True,
+    "ASSIGN_CREATOR_GROUPS_AS_VIEWERS": True,
+}
+```
+
+This requires `ENABLE_VIEWERS` — it has no effect on its own, since it works 
by populating the
+`viewers` relationship that `ENABLE_VIEWERS` governs.
+
+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.
+
+For dashboards, viewer access additionally requires the dashboard to be 
**published** — the same
+rule that governs every dashboard viewer. Until a new dashboard is published 
its creator's groups
+see nothing, and because it now has viewers the dataset-based fallback no 
longer applies to it
+either; group members gain access only once it is published. Charts have no 
such publish gate, so a
+new chart's viewer grant takes effect immediately. Note, though, that the 
viewer relationship
+governs access to the chart itself; rendering its **data** still runs the 
normal datasource
+permission check unless `VIEWER_PROMISCUOUS_MODE` is enabled — a group member 
without access to the
+underlying dataset can open the chart but won't load its data.
+
+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 feature flag is enabled are affected.
+
+The same narrowing affects background execution as a fixed identity. Alerts, 
reports, and
+thumbnails that run as a `FixedExecutor` service account (via 
`ALERT_REPORTS_EXECUTE_AS` or
+`THUMBNAIL_EXECUTE_AS`) reach a chart or dashboard through the dataset 
fallback when that account
+is neither an editor nor a viewer. Once a new asset is scoped to the creator's 
groups that
+fallback is gone, so rendering fails `raise_for_access` unless the executor 
account is an admin,
+an editor, or shares one of those groups. If a fixed executor must render 
assets it does not own,
+grant it the needed access (make it an admin or add it to the relevant groups) 
— or leave the
+feature flag off.
+
+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/docs/static/feature-flags.json b/docs/static/feature-flags.json
index 5646315efd9..7a7bcad85e5 100644
--- a/docs/static/feature-flags.json
+++ b/docs/static/feature-flags.json
@@ -138,6 +138,13 @@
         "lifecycle": "testing",
         "description": "Allow users to export full CSV of table viz type. 
Warning: Could cause server memory/compute issues with large datasets."
       },
+      {
+        "name": "ASSIGN_CREATOR_GROUPS_AS_VIEWERS",
+        "default": false,
+        "lifecycle": "testing",
+        "description": "Share a newly created dashboard or chart read-only 
with every group its creator belongs to, unless the create payload names 
viewers explicitly. Requires ENABLE_VIEWERS. Narrows access: an asset with no 
viewers falls back to datasource permissions; one with viewers is limited to 
its editors and viewers, and dashboards must also be published before those 
viewers gain access.",
+        "category": "security"
+      },
       {
         "name": "AWS_DATABASE_IAM_AUTH",
         "default": false,
diff --git a/scripts/change_detector.py b/scripts/change_detector.py
index fac6caa3ba4..5e96078327b 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 = {
@@ -69,6 +72,37 @@ 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:
+        detail = json.loads(body).get("message") or body
+    except (ValueError, AttributeError):
+        detail = body
+    # Bound the output so a large/HTML error page can't flood the CI log.
+    return detail[:500]
+
+
 def fetch_files_github_api(url: str):  # type: ignore
     """Fetches data using GitHub API, retrying on transient failures."""
     req = Request(url)  # noqa: S310
@@ -88,9 +122,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/__init__.py 
b/superset/commands/chart/importers/v1/__init__.py
index 1b28e19df5a..3f0ea68608a 100644
--- a/superset/commands/chart/importers/v1/__init__.py
+++ b/superset/commands/chart/importers/v1/__init__.py
@@ -35,6 +35,7 @@ from superset.daos.chart import ChartDAO
 from superset.databases.schemas import ImportV1DatabaseSchema
 from superset.datasets.schemas import ImportV1DatasetSchema
 from superset.extensions import feature_flag_manager
+from superset.subjects.utils import get_default_viewers_for_current_user
 
 
 class ImportChartsCommand(ImportModelsCommand):
@@ -88,6 +89,10 @@ class ImportChartsCommand(ImportModelsCommand):
                 dataset = import_dataset(config, overwrite=False)
                 datasets[str(dataset.uuid)] = dataset
 
+        # Resolve the creator's default viewers once for the whole bundle
+        # rather than once per chart (a membership query each).
+        default_viewers = get_default_viewers_for_current_user()
+
         # import charts with the correct parent ref
         for file_name, config in configs.items():
             if file_name.startswith("charts/") and config["dataset_uuid"] in 
datasets:
@@ -103,7 +108,9 @@ class ImportChartsCommand(ImportModelsCommand):
                     "datasource_name": dataset.table_name,
                 }
                 config = update_chart_config_dataset(config, dataset_dict)
-                chart = import_chart(config, overwrite=overwrite)
+                chart = import_chart(
+                    config, overwrite=overwrite, 
default_viewers=default_viewers
+                )
 
                 # Handle tags using import_tag function
                 if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
diff --git a/superset/commands/chart/importers/v1/utils.py 
b/superset/commands/chart/importers/v1/utils.py
index a4e0bba2e1b..5488777a8a7 100644
--- a/superset/commands/chart/importers/v1/utils.py
+++ b/superset/commands/chart/importers/v1/utils.py
@@ -25,6 +25,7 @@ from superset.commands.importers.v1.utils import 
find_existing_for_import
 from superset.migrations.shared.migrate_viz import processors
 from superset.migrations.shared.migrate_viz.base import MigrateViz
 from superset.models.slice import Slice
+from superset.subjects.models import Subject
 from superset.utils import json
 from superset.utils.core import AnnotationType, get_user
 
@@ -137,6 +138,7 @@ def import_chart(
     config: dict[str, Any],
     overwrite: bool = False,
     ignore_permissions: bool = False,
+    default_viewers: list[Subject] | None = None,
 ) -> Slice:
     """Import a chart from a config dict, handling existing matches.
 
@@ -201,12 +203,29 @@ def import_chart(
     if chart.id is None:
         db.session.flush()
 
-    if user:
-        from superset.subjects.utils import get_user_subject
+    # Only newly created charts inherit the creator's editor/viewer defaults;
+    # re-importing over an existing chart (overwrite or soft-delete restore)
+    # must not silently grant the importer's groups access. Mirrors the
+    # dashboard importer's ``not existing`` guard.
+    if not existing and user:
+        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)
+        # Resolved once by bulk importers and passed in; recomputed here only
+        # for direct callers that omit it (one membership query per asset).
+        viewers = (
+            default_viewers
+            if default_viewers is not None
+            else get_default_viewers_for_new_asset(user.id)
+        )
+        for viewer in viewers:
+            if viewer not in chart.viewers:
+                chart.viewers.append(viewer)
 
     return chart
 
diff --git a/superset/commands/dashboard/importers/v1/__init__.py 
b/superset/commands/dashboard/importers/v1/__init__.py
index 53a253a6953..787ea74a8e0 100644
--- a/superset/commands/dashboard/importers/v1/__init__.py
+++ b/superset/commands/dashboard/importers/v1/__init__.py
@@ -49,6 +49,7 @@ from superset.extensions import feature_flag_manager
 from superset.migrations.shared.native_filters import migrate_dashboard
 from superset.models.dashboard import Dashboard, dashboard_slices
 from superset.models.slice import Slice
+from superset.subjects.utils import get_default_viewers_for_current_user
 from superset.themes.schemas import ImportV1ThemeSchema
 from superset.utils.decorators import transaction
 
@@ -163,6 +164,10 @@ class ImportDashboardsCommand(ImportModelsCommand):
                     "datasource_name": dataset.table_name,
                 }
 
+        # Resolve the creator's default viewers once for the whole bundle
+        # rather than once per chart/dashboard (a membership query each).
+        default_viewers = get_default_viewers_for_current_user()
+
         # import charts with the correct parent ref
         charts = []
         chart_ids: dict[str, int] = {}
@@ -175,7 +180,11 @@ class ImportDashboardsCommand(ImportModelsCommand):
                 dataset_dict = dataset_info[config["dataset_uuid"]]
                 config = update_chart_config_dataset(config, dataset_dict)
 
-                chart = import_chart(config, overwrite=overwrite_assets)
+                chart = import_chart(
+                    config,
+                    overwrite=overwrite_assets,
+                    default_viewers=default_viewers,
+                )
                 charts.append(chart)
                 chart_ids[str(chart.uuid)] = chart.id
 
@@ -221,7 +230,9 @@ class ImportDashboardsCommand(ImportModelsCommand):
                     # Theme not found, set to None for graceful fallback
                     config["theme_id"] = None
                     del config["theme_uuid"]
-                dashboard = import_dashboard(config, overwrite=overwrite)
+                dashboard = import_dashboard(
+                    config, overwrite=overwrite, 
default_viewers=default_viewers
+                )
                 dashboards.append(dashboard)
 
                 # Resolve the dashboard's chart membership from the imported
diff --git a/superset/commands/dashboard/importers/v1/utils.py 
b/superset/commands/dashboard/importers/v1/utils.py
index e556674d8bd..9941eb123b6 100644
--- a/superset/commands/dashboard/importers/v1/utils.py
+++ b/superset/commands/dashboard/importers/v1/utils.py
@@ -23,6 +23,7 @@ from superset.commands.exceptions import ImportFailedError
 from superset.commands.importers.v1.utils import find_existing_for_import
 from superset.daos.dashboard import DashboardDAO
 from superset.models.dashboard import Dashboard
+from superset.subjects.models import Subject
 from superset.utils import json
 from superset.utils.core import get_user
 
@@ -277,6 +278,7 @@ def import_dashboard(  # noqa: C901
     config: dict[str, Any],
     overwrite: bool = False,
     ignore_permissions: bool = False,
+    default_viewers: list[Subject] | None = None,
 ) -> Dashboard:
     """Import a dashboard from a config dict, handling existing matches.
 
@@ -449,10 +451,23 @@ 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)
+        # Resolved once by bulk importers and passed in; recomputed here only
+        # for direct callers that omit it (one membership query per asset).
+        viewers = (
+            default_viewers
+            if default_viewers is not None
+            else get_default_viewers_for_new_asset(user.id)
+        )
+        for viewer in viewers:
+            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/importers/v1/assets.py 
b/superset/commands/importers/v1/assets.py
index 97f394b920b..25cd33c7d25 100644
--- a/superset/commands/importers/v1/assets.py
+++ b/superset/commands/importers/v1/assets.py
@@ -52,6 +52,7 @@ from superset.models.dashboard import Dashboard
 from superset.models.slice import Slice
 from superset.models.sql_lab import SavedQuery
 from superset.queries.saved_queries.schemas import ImportV1SavedQuerySchema
+from superset.subjects.utils import get_default_viewers_for_current_user
 from superset.utils.decorators import on_error, transaction
 
 
@@ -140,13 +141,19 @@ class ImportAssetsCommand(BaseCommand):
                     "datasource_name": dataset.table_name,
                 }
 
+        # Resolve the creator's default viewers once for the whole bundle
+        # rather than once per chart/dashboard (a membership query each).
+        default_viewers = get_default_viewers_for_current_user()
+
         # import charts
         charts = []
         for file_name, config in configs.items():
             if file_name.startswith("charts/"):
                 dataset_dict = dataset_info[config["dataset_uuid"]]
                 config = update_chart_config_dataset(config, dataset_dict)
-                chart = import_chart(config, overwrite=overwrite)
+                chart = import_chart(
+                    config, overwrite=overwrite, 
default_viewers=default_viewers
+                )
                 charts.append(chart)
                 chart_ids[str(chart.uuid)] = chart.id
 
@@ -161,7 +168,9 @@ class ImportAssetsCommand(BaseCommand):
         for file_name, config in configs.items():
             if file_name.startswith("dashboards/"):
                 config = update_id_refs(config, chart_ids, dataset_info)
-                dashboard = import_dashboard(config, overwrite=overwrite)
+                dashboard = import_dashboard(
+                    config, overwrite=overwrite, 
default_viewers=default_viewers
+                )
 
                 # set ref in the dashboard_slices table
                 # Use ORM-level reassignment instead of Core
diff --git a/superset/commands/importers/v1/examples.py 
b/superset/commands/importers/v1/examples.py
index 19fe811044a..773fc3c1bcf 100644
--- a/superset/commands/importers/v1/examples.py
+++ b/superset/commands/importers/v1/examples.py
@@ -46,6 +46,7 @@ from superset.datasets.schemas import ImportV1DatasetSchema
 from superset.exceptions import QueryClauseValidationException
 from superset.models.core import Database
 from superset.sql.parse import transpile_to_dialect
+from superset.subjects.utils import get_default_viewers_for_current_user
 from superset.utils.core import get_example_default_schema
 from superset.utils.decorators import transaction
 
@@ -199,6 +200,10 @@ class ImportExamplesCommand(ImportModelsCommand):
                     "datasource_name": dataset.table_name,
                 }
 
+        # Resolve the creator's default viewers once for the whole bundle
+        # rather than once per chart/dashboard (a membership query each).
+        default_viewers = get_default_viewers_for_current_user()
+
         # import charts
         chart_ids: dict[str, int] = {}
         for file_name, config in configs.items():
@@ -212,6 +217,7 @@ class ImportExamplesCommand(ImportModelsCommand):
                     config,
                     overwrite=overwrite,
                     ignore_permissions=True,
+                    default_viewers=default_viewers,
                 )
                 chart_ids[str(chart.uuid)] = chart.id
 
@@ -228,6 +234,7 @@ class ImportExamplesCommand(ImportModelsCommand):
                     config,
                     overwrite=overwrite,
                     ignore_permissions=True,
+                    default_viewers=default_viewers,
                 )
                 dashboard.published = True
 
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 b7c71f6b610..d77c26c1002 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -849,6 +849,15 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
     # @lifecycle: testing
     # @category: security
     "ENABLE_VIEWERS": False,
+    # Share a newly created dashboard or chart read-only with every group its
+    # creator belongs to, unless the create payload names viewers explicitly.
+    # Requires ENABLE_VIEWERS. Narrows access: an asset with no viewers falls
+    # back to datasource permissions; one with viewers is limited to its 
editors
+    # and viewers, and dashboards must also be published before those viewers
+    # gain access.
+    # @lifecycle: testing
+    # @category: security
+    "ASSIGN_CREATOR_GROUPS_AS_VIEWERS": False,
     # Supports simultaneous data and dashboard virtualization for backend 
performance
     # @lifecycle: stable
     # @category: runtime_config
diff --git a/superset/daos/dashboard.py b/superset/daos/dashboard.py
index 40267df21ca..336176a66f8 100644
--- a/superset/daos/dashboard.py
+++ b/superset/daos/dashboard.py
@@ -429,11 +429,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")
 
@@ -443,11 +454,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..5748c04e097 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,12 @@ 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. Defaults never replace
+            # viewers already present on the instance, matching the create
+            # commands' "explicit viewers win" rule.
+            if hasattr(self, "viewers") and not self.viewers:  # type: 
ignore[has-type]
+                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 ff214c50908..803c65bcd90 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..b6cccc8150c 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 has_app_context
 from sqlalchemy import select, union_all
 
 from superset import db
@@ -79,6 +80,125 @@ 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.
+
+    Any group whose Subject row is missing is backfilled on demand (see
+    :func:`get_or_create_group_subject`) rather than skipped: a single viewer
+    disables an asset's datasource-access fallback, so a partial viewer set
+    would silently lock out the members of an unsynced group.
+    """
+    from flask_appbuilder.security.sqla.models import assoc_user_group
+
+    subjects = (
+        db.session.query(Subject)
+        .filter(Subject.id.in_(get_user_group_subject_ids_subquery(user_id)))
+        .all()
+    )
+    member_group_ids = {
+        row[0]
+        for row in db.session.execute(
+            select(assoc_user_group.c.group_id).where(
+                assoc_user_group.c.user_id == user_id
+            )
+        )
+    }
+    for group_id in member_group_ids - {subject.group_id for subject in 
subjects}:
+        if subject := get_or_create_group_subject(group_id):
+            subjects.append(subject)
+    return subjects
+
+
+def _assigns_creator_groups_as_viewers() -> bool:
+    from superset import is_feature_enabled
+
+    # Also requires ENABLE_VIEWERS. ``raise_for_access`` enforces any non-empty
+    # ``viewers`` relationship regardless of that flag (a viewer list removes 
the
+    # datasource fallback), so assigning viewers while the viewers feature is 
off
+    # would silently restrict new assets to their editors/viewers.
+    return (
+        has_app_context()
+        and is_feature_enabled("ENABLE_VIEWERS")
+        and is_feature_enabled("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.
+
+    Resolution is all-or-nothing: if any group lacks a Subject row (an unsynced
+    group is a real deployment state — see the ``superset sync-subjects`` CLI),
+    the whole result is discarded and ``[]`` returned. A *partial* viewer set
+    would switch off the asset's datasource-access fallback
+    (``security_manager.raise_for_access``) while granting the unsynced group's
+    members nothing, locking them out; no viewers keeps the fallback intact and
+    is strictly safer. On-demand backfill isn't an option here — this runs in a
+    flush event, where :func:`get_or_create_group_subject`'s flush is unsafe.
+    """
+    if not groups or not _assigns_creator_groups_as_viewers():
+        return []
+    subjects = subjects_from_groups(groups)
+    if {subject.group_id for subject in subjects} != {group.id for group in 
groups}:
+        return []
+    return subjects
+
+
+def get_default_viewers_for_current_user() -> list[Subject]:
+    """Default viewers for the acting user, resolved once for bulk callers.
+
+    The v1 importers create many assets in a single request; resolving the
+    creator's groups once here and passing the result down avoids one
+    membership query per imported asset. Mirrors the ``user = get_user()``
+    lookup the importers do internally, so the result is identical to letting
+    each call resolve it. Returns ``[]`` when there is no request user.
+    """
+    from superset.utils.core import get_user
+
+    user = get_user()
+    return get_default_viewers_for_new_asset(user.id) if user else []
+
+
 def get_user_subject_ids(user_id: int) -> list[int]:
     """Return all Subject IDs that a user represents.
 
@@ -176,6 +296,88 @@ def get_or_create_role_subject(role_id: int) -> Subject | 
None:
     return get_role_subject(role_id)
 
 
+def get_group_subject(group_id: int) -> Subject | None:
+    """Get the GROUP-type Subject for a given group ID."""
+    return (
+        db.session.query(Subject)
+        .filter_by(
+            group_id=group_id,
+            type=SubjectType.GROUP,
+        )
+        .first()
+    )
+
+
+def get_or_create_group_subject(group_id: int) -> Subject | None:
+    """Get or create the GROUP-type Subject for a given group ID.
+
+    Mirrors ``get_or_create_role_subject``: a group that exists but has no
+    Subject row yet (e.g. created before the sync hooks were installed and not
+    yet backfilled) is synced on demand rather than skipped. Returns ``None``
+    only when no such group exists.
+
+    The insert runs inside a SAVEPOINT. ``Subject.group_id`` is unique, so a
+    concurrent request backfilling the same group would otherwise fail this
+    flush with an ``IntegrityError`` and poison the surrounding transaction. On
+    that conflict the savepoint rolls back and the row the other request wrote
+    is reloaded. Any ``IntegrityError`` that did *not* leave a matching row is
+    re-raised rather than swallowed — it isn't the expected unique-key race, so
+    masking it would silently drop the group from the resulting viewer set.
+
+    Unrelated pending work is flushed *before* the SAVEPOINT, so entering it
+    doesn't flush the caller's other objects into the ``except`` below where an
+    unrelated integrity failure could be mistaken for this group's conflict.
+    """
+    if subject := get_group_subject(group_id):
+        return subject
+
+    from sqlalchemy.exc import IntegrityError
+
+    from superset import security_manager
+    from superset.subjects.sync import sync_group_subject
+
+    group = db.session.get(security_manager.group_model, group_id)
+    if not group:
+        return None
+
+    # Surface any unrelated pending failure here, outside the SAVEPOINT, so the
+    # savepoint's own flush carries only the Subject insert and the ``except``
+    # sees only this group's uniqueness conflict.
+    db.session.flush()
+    try:
+        with db.session.begin_nested():
+            sync_group_subject(group)
+    except IntegrityError:
+        if subject := get_group_subject(group_id):
+            return subject
+        raise
+    return get_group_subject(group_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. Groups without a matching Subject row are
+    omitted from the result; the viewer-defaulting caller
+    (:func:`get_default_viewers_for_groups`) detects that shortfall and 
discards
+    the partial result rather than applying it, so no partial viewer set leaks
+    out. On-demand backfill happens instead in :func:`get_user_group_subjects`,
+    the path that does not run inside a flush event.
+    """
+    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..777a2d4e307
--- /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(
+            "superset.subjects.utils._assigns_creator_groups_as_viewers",
+            return_value=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 af60b1eb4a4..18ca457008b 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
 
@@ -96,6 +97,68 @@ def test_fetch_gives_up_after_max_retries() -> None:
     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
+
+
 def test_image_tag_compose_changes_trigger_python_tests() -> None:
     assert change_detector.detect_changes(
         ["docker-compose-image-tag.yml"],
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..c30d10ecaf6
--- /dev/null
+++ b/tests/unit_tests/subjects/test_api_filters.py
@@ -0,0 +1,84 @@
+# 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 on the NOT IN predicate specifically: a plain "service_account"
+    # membership check would pass for an (incorrect) IN filter just as well.
+    assert "NOT IN ('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..2d2f6b13080
--- /dev/null
+++ b/tests/unit_tests/subjects/test_creator_group_call_sites.py
@@ -0,0 +1,508 @@
+# 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_chart_importer_skips_default_viewers_on_reimport(app_context) -> None:
+    """Re-importing over an existing chart must not grant the importer's 
groups.
+
+    Unlike a fresh import, an overwrite / soft-delete restore falls through to
+    an in-place update; applying "new asset" defaults there would silently
+    widen an existing chart's read access.
+    """
+    from superset.commands.chart.importers.v1 import utils as chart_utils
+
+    chart = SimpleNamespace(id=1, editors=[], viewers=[])
+
+    with (
+        # A matching row that falls through to an in-place update (return 
None).
+        patch.object(chart_utils, "find_existing_for_import", 
return_value=MagicMock()),
+        patch.object(
+            chart_utils, "_prepare_existing_chart_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=[_group_subject()],
+        ) as mock_viewers,
+    ):
+        result = chart_utils.import_chart(
+            {"uuid": "x", "params": {}}, overwrite=True, 
ignore_permissions=True
+        )
+
+    assert result.viewers == []
+    mock_viewers.assert_not_called()
+
+
+def test_chart_importer_uses_passed_default_viewers_without_recomputing(
+    app_context,
+) -> None:
+    """Bulk importers resolve the creator's viewers once and pass them down;
+    a per-asset call must reuse that list rather than re-query membership."""
+    from superset.commands.chart.importers.v1 import utils as chart_utils
+
+    passed = _group_subject(7)
+    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",
+        ) as mock_compute,
+    ):
+        result = chart_utils.import_chart(
+            {"uuid": "x", "params": {}},
+            ignore_permissions=True,
+            default_viewers=[passed],
+        )
+
+    assert result.viewers == [passed]
+    mock_compute.assert_not_called()
+
+
+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]
+
+
+def test_reset_ownership_preserves_explicitly_set_viewers(app_context) -> None:
+    """A viewer list already on the instance is not replaced by group 
defaults."""
+    from superset.models.helpers import ImportExportMixin
+
+    explicit = _group_subject(99)
+
+    class _Asset(ImportExportMixin):
+        def __init__(self) -> None:
+            self.editors: list[Any] = []
+            self.viewers: list[Any] = [explicit]
+
+    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=[_group_subject(11)],
+        ) as mock_viewers,
+    ):
+        asset.reset_ownership()
+
+    assert asset.viewers == [explicit]
+    mock_viewers.assert_not_called()
+
+
+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..a2951a473b6
--- /dev/null
+++ b/tests/unit_tests/subjects/test_creator_group_defaults.py
@@ -0,0 +1,293 @@
+# 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 (
+    _assigns_creator_groups_as_viewers,
+    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_gate_resolves_the_assign_creator_groups_feature_flag(app_context) -> 
None:
+    """The gate is a feature flag read via ``is_feature_enabled`` and 
additionally
+    requires ``ENABLE_VIEWERS``."""
+    with patch("superset.is_feature_enabled", return_value=True) as mock_flag:
+        assert _assigns_creator_groups_as_viewers() is True
+    mock_flag.assert_any_call("ENABLE_VIEWERS")
+    mock_flag.assert_any_call("ASSIGN_CREATOR_GROUPS_AS_VIEWERS")
+
+    with patch("superset.is_feature_enabled", return_value=False):
+        assert _assigns_creator_groups_as_viewers() is False
+
+    # ASSIGN on but ENABLE_VIEWERS off → the gate stays closed.
+    with patch(
+        "superset.is_feature_enabled",
+        side_effect=lambda flag: flag != "ENABLE_VIEWERS",
+    ):
+        assert _assigns_creator_groups_as_viewers() is False
+
+
+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(
+        "superset.subjects.utils._assigns_creator_groups_as_viewers",
+        return_value=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(
+        "superset.subjects.utils._assigns_creator_groups_as_viewers",
+        return_value=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(
+        "superset.subjects.utils._assigns_creator_groups_as_viewers",
+        return_value=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(
+        "superset.subjects.utils._assigns_creator_groups_as_viewers",
+        return_value=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(
+        "superset.subjects.utils._assigns_creator_groups_as_viewers",
+        return_value=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(
+        "superset.subjects.utils._assigns_creator_groups_as_viewers",
+        return_value=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."""
+    group = MagicMock()
+    group.id = 11
+    subject = _group_subject(11)
+    subject.group_id = 11
+    mock_from_groups.return_value = [subject]
+
+    with patch(
+        "superset.subjects.utils._assigns_creator_groups_as_viewers",
+        return_value=True,
+    ):
+        assert get_default_viewers_for_groups([group]) == [subject]
+
+
+@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(
+        "superset.subjects.utils._assigns_creator_groups_as_viewers",
+        return_value=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(
+        "superset.subjects.utils._assigns_creator_groups_as_viewers",
+        return_value=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(
+        "superset.subjects.utils._assigns_creator_groups_as_viewers",
+        return_value=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(
+        "superset.subjects.utils._assigns_creator_groups_as_viewers",
+        return_value=True,
+    ):
+        assert get_default_viewers_for_new_asset(5) == []
diff --git a/tests/unit_tests/subjects/test_utils.py 
b/tests/unit_tests/subjects/test_utils.py
index 285b87707cf..e5ebf4323e5 100644
--- a/tests/unit_tests/subjects/test_utils.py
+++ b/tests/unit_tests/subjects/test_utils.py
@@ -30,7 +30,10 @@ from superset.subjects.models import Subject
 from superset.subjects.types import SubjectType
 from superset.subjects.utils import (
     get_current_user_subject_ids,
+    get_default_viewers_for_groups,
+    get_or_create_group_subject,
     get_or_create_role_subject,
+    get_user_group_subjects,
     get_user_subject_ids_subquery,
 )
 
@@ -143,6 +146,193 @@ def 
test_get_or_create_role_subject_missing_role_returns_none() -> None:
     mock_sync.assert_not_called()
 
 
+# --------------------------------------------------------------------------
+# get_or_create_group_subject / get_user_group_subjects backfill
+# --------------------------------------------------------------------------
+
+
+@patch("superset.subjects.utils.get_group_subject")
+def test_get_or_create_group_subject_returns_existing(
+    mock_get_group_subject: MagicMock,
+) -> None:
+    """An already-synced group resolves to its Subject with no sync."""
+    existing = _make_subject(10, SubjectType.GROUP)
+    mock_get_group_subject.return_value = existing
+
+    assert get_or_create_group_subject(5) is existing
+    mock_get_group_subject.assert_called_once_with(5)
+
+
+def test_get_or_create_group_subject_syncs_unsynced_group() -> None:
+    """A group that exists but has no Subject row yet is synced on demand
+    rather than treated as nonexistent."""
+    created = _make_subject(11, SubjectType.GROUP)
+    group = MagicMock()
+    group.id = 5
+
+    with (
+        patch(
+            "superset.subjects.utils.get_group_subject",
+            side_effect=[None, created],
+        ) as mock_get_group_subject,
+        patch("superset.subjects.utils.db") as mock_db,
+        patch("superset.subjects.sync.sync_group_subject") as mock_sync,
+    ):
+        mock_db.session.get.return_value = group
+
+        assert get_or_create_group_subject(5) is created
+
+    mock_sync.assert_called_once_with(group)
+    # The insert is wrapped in a SAVEPOINT so a concurrent conflict can't
+    # poison the surrounding transaction.
+    mock_db.session.begin_nested.assert_called_once()
+    assert mock_get_group_subject.call_count == 2
+
+
+def test_get_or_create_group_subject_handles_concurrent_insert() -> None:
+    """A concurrent backfill (IntegrityError in the savepoint) is swallowed and
+    the row the other request wrote is reloaded, keeping the transaction 
usable."""
+    from sqlalchemy.exc import IntegrityError
+
+    winner = _make_subject(12, SubjectType.GROUP)
+    group = MagicMock()
+    group.id = 7
+
+    with (
+        patch(
+            "superset.subjects.utils.get_group_subject",
+            side_effect=[None, winner],
+        ) as mock_get_group_subject,
+        patch("superset.subjects.utils.db") as mock_db,
+        patch(
+            "superset.subjects.sync.sync_group_subject",
+            side_effect=IntegrityError("duplicate group_id", None, 
Exception()),
+        ) as mock_sync,
+    ):
+        mock_db.session.get.return_value = group
+        # The mocked savepoint must not suppress the error raised inside its 
body.
+        mock_db.session.begin_nested.return_value.__exit__.return_value = False
+
+        assert get_or_create_group_subject(7) is winner
+
+    # The insert (via ``sync``) ran and conflicted; the reload returns the 
winner.
+    mock_sync.assert_called_once_with(group)
+    assert mock_get_group_subject.call_count == 2
+
+
+def test_get_or_create_group_subject_reraises_unrelated_integrity_error() -> 
None:
+    """An IntegrityError that left no matching row isn't the expected 
unique-key
+    race, so it propagates instead of silently dropping the group."""
+    from sqlalchemy.exc import IntegrityError
+
+    group = MagicMock()
+    group.id = 7
+
+    with (
+        patch("superset.subjects.utils.get_group_subject", return_value=None),
+        patch("superset.subjects.utils.db") as mock_db,
+        patch(
+            "superset.subjects.sync.sync_group_subject",
+            side_effect=IntegrityError("unrelated failure", None, Exception()),
+        ) as mock_sync,
+    ):
+        mock_db.session.get.return_value = group
+        mock_db.session.begin_nested.return_value.__exit__.return_value = False
+
+        with pytest.raises(IntegrityError):
+            get_or_create_group_subject(7)
+
+    mock_sync.assert_called_once_with(group)
+
+
+def test_get_or_create_group_subject_missing_group_returns_none() -> None:
+    """A group ID with no matching group at all resolves to None."""
+    with (
+        patch("superset.subjects.utils.get_group_subject", return_value=None),
+        patch("superset.subjects.utils.db") as mock_db,
+        patch("superset.subjects.sync.sync_group_subject") as mock_sync,
+    ):
+        mock_db.session.get.return_value = None
+
+        assert get_or_create_group_subject(999) is None
+
+    mock_sync.assert_not_called()
+
+
+def test_get_user_group_subjects_backfills_missing_group(app_context) -> None:
+    """A group the user belongs to but which has no Subject row is backfilled,
+    not skipped: a partial viewer set would silently lock out its members."""
+    synced = _make_subject(10, SubjectType.GROUP)
+    synced.group_id = 1
+    backfilled = _make_subject(11, SubjectType.GROUP)
+    backfilled.group_id = 2
+
+    with (
+        patch("superset.subjects.utils.db") as mock_db,
+        patch(
+            "superset.subjects.utils.get_or_create_group_subject",
+            return_value=backfilled,
+        ) as mock_get_or_create,
+    ):
+        
mock_db.session.query.return_value.filter.return_value.all.return_value = [
+            synced
+        ]
+        # The user belongs to groups 1 (synced) and 2 (missing a Subject row).
+        mock_db.session.execute.return_value = [(1,), (2,)]
+
+        result = get_user_group_subjects(5)
+
+    assert result == [synced, backfilled]
+    mock_get_or_create.assert_called_once_with(2)
+
+
+def test_get_default_viewers_for_groups_discards_partial_result(app_context) 
-> None:
+    """If any group lacks a Subject row, return [] so the datasource fallback
+    stays intact rather than a partial viewer set that locks its members 
out."""
+    g1 = MagicMock()
+    g1.id = 1
+    g2 = MagicMock()
+    g2.id = 2
+    subject_for_g1 = _make_subject(10, SubjectType.GROUP)
+    subject_for_g1.group_id = 1
+
+    with (
+        patch(
+            "superset.subjects.utils._assigns_creator_groups_as_viewers",
+            return_value=True,
+        ),
+        patch(
+            "superset.subjects.utils.subjects_from_groups",
+            return_value=[subject_for_g1],
+        ),
+    ):
+        assert get_default_viewers_for_groups([g1, g2]) == []
+
+
+def test_get_default_viewers_for_groups_applies_when_all_resolve(app_context) 
-> None:
+    """When every group resolves to a Subject, all are returned as viewers."""
+    g1 = MagicMock()
+    g1.id = 1
+    g2 = MagicMock()
+    g2.id = 2
+    s1 = _make_subject(10, SubjectType.GROUP)
+    s1.group_id = 1
+    s2 = _make_subject(11, SubjectType.GROUP)
+    s2.group_id = 2
+
+    with (
+        patch(
+            "superset.subjects.utils._assigns_creator_groups_as_viewers",
+            return_value=True,
+        ),
+        patch(
+            "superset.subjects.utils.subjects_from_groups",
+            return_value=[s1, s2],
+        ),
+    ):
+        assert get_default_viewers_for_groups([g1, g2]) == [s1, s2]
+
+
 # --------------------------------------------------------------------------
 # populate_subject_list
 # --------------------------------------------------------------------------
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