diff --git a/changes/12984.enhance.md b/changes/12984.enhance.md new file mode 100644 index 00000000000..9a05d2d0ae6 --- /dev/null +++ b/changes/12984.enhance.md @@ -0,0 +1 @@ +Store `app_config_fragments.scope_id` as a nullable UUID rather than a string, with `NULL` for the ownerless public scope. diff --git a/src/ai/backend/common/data/app_config/types.py b/src/ai/backend/common/data/app_config/types.py index 0846d93caf1..9a30ecb3eb0 100644 --- a/src/ai/backend/common/data/app_config/types.py +++ b/src/ai/backend/common/data/app_config/types.py @@ -5,6 +5,7 @@ import enum from ai.backend.common.data.permission.types import RBACElementType, ScopeType +from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier __all__ = ("AppConfigScopeType",) @@ -49,13 +50,12 @@ def to_rbac_element_type(self) -> RBACElementType | None: case AppConfigScopeType.USER: return RBACElementType.USER - def to_rbac_scope_id(self, scope_id: str) -> str: - """The RBAC scope id for a write at this fragment scope. + def to_rbac_scope_id(self, scope_id: AppConfigScopeIdentifier | None) -> str: + """The RBAC scope id for a write at this fragment scope, in RBAC's string form. - ``public`` is system-wide (no per-entity scope id); ``domain`` / ``user`` carry - their own ``scope_id``. + ``public`` is system-wide and names no owner. """ - return "" if self is AppConfigScopeType.PUBLIC else scope_id + return "" if self is AppConfigScopeType.PUBLIC else str(scope_id) def default_rank(self) -> int: """Default merge rank for an allow-list entry at this scope type (BEP-1052). diff --git a/src/ai/backend/common/identifier/app_config.py b/src/ai/backend/common/identifier/app_config.py new file mode 100644 index 00000000000..76855dbc9f8 --- /dev/null +++ b/src/ai/backend/common/identifier/app_config.py @@ -0,0 +1,10 @@ +from typing import NewType +from uuid import UUID + +__all__ = ("AppConfigScopeIdentifier",) + + +# Who an app config fragment belongs to. Polymorphic across scope kinds (domain/user); the +# concrete kind is discriminated by the accompanying ``AppConfigScopeType``, and ``public`` +# has no owner at all, so its absence is spelled ``| None`` at each use. +AppConfigScopeIdentifier = NewType("AppConfigScopeIdentifier", UUID) diff --git a/src/ai/backend/manager/data/app_config_fragment/types.py b/src/ai/backend/manager/data/app_config_fragment/types.py index cd5f451acdd..dfc79f78b79 100644 --- a/src/ai/backend/manager/data/app_config_fragment/types.py +++ b/src/ai/backend/manager/data/app_config_fragment/types.py @@ -5,6 +5,7 @@ from typing import Any from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID @@ -15,7 +16,7 @@ class AppConfigFragmentData: id: AppConfigFragmentID config_name: str scope_type: AppConfigScopeType - scope_id: str + scope_id: AppConfigScopeIdentifier | None config: dict[str, Any] created_at: datetime updated_at: datetime diff --git a/src/ai/backend/manager/models/alembic/versions/e5b71c94d2a8_app_config_fragment_scope_id_to_uuid.py b/src/ai/backend/manager/models/alembic/versions/e5b71c94d2a8_app_config_fragment_scope_id_to_uuid.py new file mode 100644 index 00000000000..196db4f9140 --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/e5b71c94d2a8_app_config_fragment_scope_id_to_uuid.py @@ -0,0 +1,76 @@ +"""convert app_config_fragments.scope_id to a nullable UUID + +``scope_id`` was a ``VARCHAR`` holding a domain or user UUID, with ``''`` for +public only because the column was ``NOT NULL``. It becomes ``UUID NULL``, where +``NULL`` is public. + +Public rows are forced to ``NULL``; a domain or user id that is not a UUID aborts +the migration rather than being nulled, since that binding decides who can see +the fragment. + +``NULL``s are distinct to a unique constraint, so public rows get a partial +unique index (``UNIQUE NULLS NOT DISTINCT`` needs Postgres 15+; the test fixture +runs 13), and a check constraint keeps ``NULL`` and public in step. + +Revision ID: e5b71c94d2a8 +Revises: 577c7a215934 +Create Date: 2026-07-21 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "e5b71c94d2a8" +down_revision = "577c7a215934" +# Part of: NEXT_RELEASE_VERSION +branch_labels = None +depends_on = None + +_PUBLIC_INDEX = "uq_app_config_fragments_public_config_name" +# Bare name — the naming convention prefixes it with ck__. +_SCOPE_ID_CHECK = "scope_id_matches_scope_type" +_TABLE = "app_config_fragments" + + +def upgrade() -> None: + op.execute( + sa.text(""" + ALTER TABLE app_config_fragments + ALTER COLUMN scope_id DROP NOT NULL, + ALTER COLUMN scope_id TYPE UUID + USING ( + CASE + WHEN scope_type = 'public' THEN NULL + ELSE scope_id::uuid + END + ) + """) + ) + op.create_check_constraint( + _SCOPE_ID_CHECK, + _TABLE, + "(scope_type = 'public') = (scope_id IS NULL)", + ) + op.create_index( + _PUBLIC_INDEX, + _TABLE, + ["config_name", "scope_type"], + unique=True, + postgresql_where=sa.text("scope_id IS NULL"), + ) + + +def downgrade() -> None: + op.drop_index(_PUBLIC_INDEX, table_name=_TABLE) + op.drop_constraint(_SCOPE_ID_CHECK, _TABLE, type_="check") + # Public rows go back to the empty sentinel the NOT NULL column required. + op.execute( + sa.text(""" + ALTER TABLE app_config_fragments + ALTER COLUMN scope_id TYPE VARCHAR(255) + USING (COALESCE(scope_id::text, '')) + """) + ) + op.alter_column(_TABLE, "scope_id", nullable=False) diff --git a/src/ai/backend/manager/models/app_config_fragment/conditions.py b/src/ai/backend/manager/models/app_config_fragment/conditions.py index a8f4d836c13..e622e5d6f77 100644 --- a/src/ai/backend/manager/models/app_config_fragment/conditions.py +++ b/src/ai/backend/manager/models/app_config_fragment/conditions.py @@ -9,7 +9,9 @@ import sqlalchemy as sa from ai.backend.common.data.app_config.types import AppConfigScopeType -from ai.backend.common.data.filter_specs import StringMatchSpec +from ai.backend.common.data.filter_specs import StringMatchSpec, UUIDEqualMatchSpec +from ai.backend.common.identifier.domain import DomainID +from ai.backend.common.identifier.user import UserID from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow from ai.backend.manager.models.clauses import QueryCondition from ai.backend.manager.models.condition_utils import make_string_in_factory @@ -118,9 +120,12 @@ def inner() -> sa.sql.expression.ColumnElement[bool]: # --- scope_id filter --- @staticmethod - def by_scope_id_equals(scope_id: str) -> QueryCondition: + def by_scope_id_equals(spec: UUIDEqualMatchSpec) -> QueryCondition: def inner() -> sa.sql.expression.ColumnElement[bool]: - return AppConfigFragmentRow.scope_id == scope_id + condition = AppConfigFragmentRow.scope_id == spec.value + if spec.negated: + condition = sa.not_(condition) + return condition return inner @@ -136,19 +141,19 @@ def inner() -> sa.sql.expression.ColumnElement[bool]: return inner @staticmethod - def by_domain_visibility(domain: str) -> QueryCondition: - """The ``domain`` scope for ``domain``.""" + def by_domain_visibility(domain_id: DomainID) -> QueryCondition: + """The ``domain`` scope for ``domain_id``.""" def inner() -> sa.sql.expression.ColumnElement[bool]: return sa.and_( AppConfigFragmentRow.scope_type == AppConfigScopeType.DOMAIN, - AppConfigFragmentRow.scope_id == domain, + AppConfigFragmentRow.scope_id == domain_id, ) return inner @staticmethod - def by_user_visibility(user_id: str) -> QueryCondition: + def by_user_visibility(user_id: UserID) -> QueryCondition: """The ``user`` scope for ``user_id``.""" def inner() -> sa.sql.expression.ColumnElement[bool]: diff --git a/src/ai/backend/manager/models/app_config_fragment/row.py b/src/ai/backend/manager/models/app_config_fragment/row.py index 0a324f10652..3205c018ebb 100644 --- a/src/ai/backend/manager/models/app_config_fragment/row.py +++ b/src/ai/backend/manager/models/app_config_fragment/row.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID from ai.backend.manager.data.app_config_fragment.types import ( AppConfigFragmentData, @@ -32,6 +33,18 @@ class AppConfigFragmentRow(LifecycleTimestampsMixin, Base): # type: ignore[misc "scope_id", name="uq_app_config_fragments_config_name_scope_type_scope_id", ), + # NULLs are distinct to a unique constraint, so public rows need their own index. + sa.Index( + "uq_app_config_fragments_public_config_name", + "config_name", + "scope_type", + unique=True, + postgresql_where=sa.text("scope_id IS NULL"), + ), + sa.CheckConstraint( + "(scope_type = 'public') = (scope_id IS NULL)", + name="scope_id_matches_scope_type", + ), sa.ForeignKeyConstraint( ["config_name", "scope_type"], ["app_config_allow_list.config_name", "app_config_allow_list.scope_type"], @@ -56,10 +69,11 @@ class AppConfigFragmentRow(LifecycleTimestampsMixin, Base): # type: ignore[misc StrEnumType(AppConfigScopeType), nullable=False, ) - scope_id: Mapped[str] = mapped_column( + # NULL is public, which has no owner; domain and user carry their owner's id. + scope_id: Mapped[AppConfigScopeIdentifier | None] = mapped_column( "scope_id", - sa.String(length=255), - nullable=False, + GUID(AppConfigScopeIdentifier), + nullable=True, ) config: Mapped[dict[str, Any]] = mapped_column( "config", diff --git a/src/ai/backend/manager/repositories/app_config_fragment/creators.py b/src/ai/backend/manager/repositories/app_config_fragment/creators.py index 5e4c47b9d96..d7f5c503527 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/creators.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/creators.py @@ -7,6 +7,7 @@ from typing import Any, override from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier from ai.backend.manager.errors.app_config import AppConfigFragmentWriteNotAllowed from ai.backend.manager.errors.repository import ForeignKeyViolationError from ai.backend.manager.models.app_config_fragment.row import AppConfigFragmentRow @@ -23,7 +24,7 @@ class AppConfigFragmentCreatorSpec(CreatorSpec[AppConfigFragmentRow]): config_name: str scope_type: AppConfigScopeType - scope_id: str + scope_id: AppConfigScopeIdentifier | None config: dict[str, Any] @property diff --git a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py index a87ccd80f38..84ecb8ad724 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/db_source/db_source.py @@ -85,7 +85,9 @@ async def create(self, spec: AppConfigFragmentCreatorSpec) -> AppConfigFragmentD spec=spec, element_type=RBACElementType.APP_CONFIG_FRAGMENT, scope_ref=( - RBACElementRef(element_type, spec.scope_id) if element_type is not None else None + RBACElementRef(element_type, str(spec.scope_id)) + if element_type is not None + else None ), ) async with self._rbac_ops_provider.write_ops() as w: @@ -223,8 +225,8 @@ async def list_visible_fragments_bulk( scope_visibility = [AppConfigFragmentConditions.by_public_visibility()] if scope is not None: scope_visibility += [ - AppConfigFragmentConditions.by_domain_visibility(str(scope.domain_id)), - AppConfigFragmentConditions.by_user_visibility(str(scope.user_id)), + AppConfigFragmentConditions.by_domain_visibility(scope.domain_id), + AppConfigFragmentConditions.by_user_visibility(scope.user_id), ] querier = BatchQuerier( pagination=NoPagination(), diff --git a/src/ai/backend/manager/repositories/app_config_fragment/types.py b/src/ai/backend/manager/repositories/app_config_fragment/types.py index dc982d26eda..bab654a67c9 100644 --- a/src/ai/backend/manager/repositories/app_config_fragment/types.py +++ b/src/ai/backend/manager/repositories/app_config_fragment/types.py @@ -63,7 +63,7 @@ def to_condition(self) -> QueryCondition: def inner() -> sa.sql.expression.ColumnElement[bool]: return sa.and_( AppConfigFragmentRow.scope_type == AppConfigScopeType.DOMAIN, - AppConfigFragmentRow.scope_id == str(domain_id), + AppConfigFragmentRow.scope_id == domain_id, ) return inner @@ -87,7 +87,7 @@ def to_condition(self) -> QueryCondition: def inner() -> sa.sql.expression.ColumnElement[bool]: return sa.and_( AppConfigFragmentRow.scope_type == AppConfigScopeType.USER, - AppConfigFragmentRow.scope_id == str(user_id), + AppConfigFragmentRow.scope_id == user_id, ) return inner diff --git a/src/ai/backend/manager/services/app_config_fragment/actions/create.py b/src/ai/backend/manager/services/app_config_fragment/actions/create.py index b619a0ecb8e..ddcbff3101c 100644 --- a/src/ai/backend/manager/services/app_config_fragment/actions/create.py +++ b/src/ai/backend/manager/services/app_config_fragment/actions/create.py @@ -47,7 +47,8 @@ def target_element(self) -> RBACElementRef: element = self.creator_spec.scope_type.to_rbac_element_type() if element is None: return RBACElementRef(RBACElementType.APP_CONFIG_FRAGMENT, "") - return RBACElementRef(element, self.creator_spec.scope_id) + # A non-null element type means domain or user scope, which always carries an owner. + return RBACElementRef(element, str(self.creator_spec.scope_id)) @dataclass diff --git a/tests/unit/manager/repositories/app_config_allow_list/test_repository.py b/tests/unit/manager/repositories/app_config_allow_list/test_repository.py index 3275f76e39f..82f8d9fddbe 100644 --- a/tests/unit/manager/repositories/app_config_allow_list/test_repository.py +++ b/tests/unit/manager/repositories/app_config_allow_list/test_repository.py @@ -252,7 +252,7 @@ async def test_purge_cascades_to_fragments( AppConfigFragmentRow( config_name=existing_entry.config_name, scope_type=existing_entry.scope_type, - scope_id="public", + scope_id=None, config={"k": "v"}, ) ) @@ -282,7 +282,7 @@ async def test_definition_purge_cascades_to_allow_list_and_fragments( AppConfigFragmentRow( config_name=entry.config_name, scope_type=entry.scope_type, - scope_id="public", + scope_id=None, config={"k": "v"}, ) ) diff --git a/tests/unit/manager/repositories/app_config_fragment/test_repository.py b/tests/unit/manager/repositories/app_config_fragment/test_repository.py index de6d073f1d3..493f9925b36 100644 --- a/tests/unit/manager/repositories/app_config_fragment/test_repository.py +++ b/tests/unit/manager/repositories/app_config_fragment/test_repository.py @@ -10,7 +10,9 @@ import sqlalchemy as sa from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.data.filter_specs import UUIDEqualMatchSpec from ai.backend.common.data.permission.types import EntityType, ScopeType +from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID from ai.backend.common.identifier.domain import DomainID from ai.backend.common.identifier.user import UserID @@ -58,11 +60,16 @@ from ai.backend.manager.types import OptionalState from ai.backend.testutils.db import with_tables -_DOMAIN_UUID = uuid.uuid4() -_USER_UUID = uuid.uuid4() -_DOMAIN_ID = str(_DOMAIN_UUID) -_USER_ID = str(_USER_UUID) -_OTHER_USER_ID = str(uuid.uuid4()) +_DOMAIN_ID = DomainID(uuid.uuid4()) +_USER_ID = UserID(uuid.uuid4()) +_OTHER_DOMAIN_ID = DomainID(uuid.uuid4()) +_OTHER_USER_ID = UserID(uuid.uuid4()) + +# The same owners seen as a fragment's scope_id, which is polymorphic over scope kinds. +_DOMAIN_SCOPE_ID = AppConfigScopeIdentifier(_DOMAIN_ID) +_USER_SCOPE_ID = AppConfigScopeIdentifier(_USER_ID) +_OTHER_DOMAIN_SCOPE_ID = AppConfigScopeIdentifier(_OTHER_DOMAIN_ID) +_OTHER_USER_SCOPE_ID = AppConfigScopeIdentifier(_OTHER_USER_ID) @pytest.fixture @@ -129,7 +136,7 @@ async def domain_scoped_fragment(database: ExtendedAsyncSAEngine) -> AppConfigFr row = AppConfigFragmentRow( config_name="theme", scope_type=AppConfigScopeType.DOMAIN, - scope_id=_DOMAIN_ID, + scope_id=_DOMAIN_SCOPE_ID, config={"k": "v"}, ) db_sess.add(row) @@ -137,6 +144,31 @@ async def domain_scoped_fragment(database: ExtendedAsyncSAEngine) -> AppConfigFr return row.to_data() +@pytest.fixture +async def fragment_at_every_scope( + database: ExtendedAsyncSAEngine, theme_registered: None +) -> dict[AppConfigScopeType, AppConfigFragmentData]: + """Situation: ``theme`` already holds one fragment at each scope, keyed by that scope.""" + owners: dict[AppConfigScopeType, AppConfigScopeIdentifier | None] = { + AppConfigScopeType.PUBLIC: None, + AppConfigScopeType.DOMAIN: _DOMAIN_SCOPE_ID, + AppConfigScopeType.USER: _USER_SCOPE_ID, + } + async with database.begin_session() as db_sess: + rows = [ + AppConfigFragmentRow( + config_name="theme", + scope_type=scope_type, + scope_id=scope_id, + config={"k": "v"}, + ) + for scope_type, scope_id in owners.items() + ] + db_sess.add_all(rows) + await db_sess.flush() + return {row.scope_type: row.to_data() for row in rows} + + @pytest.fixture async def fragments_across_scopes(database: ExtendedAsyncSAEngine) -> list[AppConfigFragmentData]: """Situation: fragments across every scope_type, two domains, two users, two config_names. @@ -160,37 +192,37 @@ async def fragments_across_scopes(database: ExtendedAsyncSAEngine) -> list[AppCo AppConfigFragmentRow( config_name="theme", scope_type=AppConfigScopeType.PUBLIC, - scope_id="public", + scope_id=None, config={"k": "v"}, ), AppConfigFragmentRow( config_name="theme", scope_type=AppConfigScopeType.DOMAIN, - scope_id=_DOMAIN_ID, + scope_id=_DOMAIN_SCOPE_ID, config={"k": "v"}, ), AppConfigFragmentRow( config_name="theme", scope_type=AppConfigScopeType.DOMAIN, - scope_id="other", + scope_id=_OTHER_DOMAIN_SCOPE_ID, config={"k": "v"}, ), AppConfigFragmentRow( config_name="theme", scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, + scope_id=_USER_SCOPE_ID, config={"k": "v"}, ), AppConfigFragmentRow( config_name="theme", scope_type=AppConfigScopeType.USER, - scope_id=_OTHER_USER_ID, + scope_id=_OTHER_USER_SCOPE_ID, config={"k": "v"}, ), AppConfigFragmentRow( config_name="menu", scope_type=AppConfigScopeType.PUBLIC, - scope_id="public", + scope_id=None, config={"k": "v"}, ), ] @@ -207,7 +239,7 @@ async def test_create_then_get_by_id( AppConfigFragmentCreatorSpec( config_name="theme", scope_type=AppConfigScopeType.PUBLIC, - scope_id="public", + scope_id=None, config={"theme": "dark"}, ) ) @@ -229,22 +261,30 @@ async def test_create_rejected_when_not_allow_listed( AppConfigFragmentCreatorSpec( config_name="theme", scope_type=AppConfigScopeType.PUBLIC, - scope_id="public", + scope_id=None, config={"theme": "dark"}, ) ) - async def test_unique_constraint_violation( + @pytest.mark.parametrize( + "scope_type", + [AppConfigScopeType.PUBLIC, AppConfigScopeType.DOMAIN, AppConfigScopeType.USER], + ids=lambda scope_type: scope_type.value, + ) + async def test_a_second_fragment_at_the_same_scope_is_rejected( self, repository: AppConfigFragmentRepository, - domain_scoped_fragment: AppConfigFragmentData, + fragment_at_every_scope: dict[AppConfigScopeType, AppConfigFragmentData], + scope_type: AppConfigScopeType, ) -> None: + # public is carried by the partial index, domain and user by the unique constraint. + existing = fragment_at_every_scope[scope_type] with pytest.raises(UniqueConstraintViolationError): await repository.create( AppConfigFragmentCreatorSpec( - config_name=domain_scoped_fragment.config_name, - scope_type=domain_scoped_fragment.scope_type, - scope_id=domain_scoped_fragment.scope_id, + config_name=existing.config_name, + scope_type=existing.scope_type, + scope_id=existing.scope_id, config={"k": "v"}, ) ) @@ -328,18 +368,30 @@ async def test_filter_by_scope_type( } assert {item.id for item in result.items} == expected + @pytest.mark.parametrize("negated", [False, True], ids=["equals", "not-equals"]) async def test_filter_by_scope_id( self, repository: AppConfigFragmentRepository, fragments_across_scopes: list[AppConfigFragmentData], + negated: bool, ) -> None: result = await repository.admin_search( BatchQuerier( pagination=OffsetPagination(limit=10, offset=0), - conditions=[AppConfigFragmentConditions.by_scope_id_equals(_USER_ID)], + conditions=[ + AppConfigFragmentConditions.by_scope_id_equals( + UUIDEqualMatchSpec(value=_USER_SCOPE_ID, negated=negated) + ) + ], ) ) - expected = {f.id for f in fragments_across_scopes if f.scope_id == _USER_ID} + # Public rows hold NULL, and neither `= x` nor `NOT (= x)` is true of NULL, so they + # fall out of both directions of the filter. + expected = { + f.id + for f in fragments_across_scopes + if f.scope_id is not None and (f.scope_id == _USER_SCOPE_ID) is not negated + } assert {item.id for item in result.items} == expected @@ -351,13 +403,13 @@ async def test_domain_scope_returns_only_that_domain( ) -> None: result = await repository.scoped_search( BatchQuerier(pagination=OffsetPagination(limit=10, offset=0)), - [DomainAppConfigFragmentSearchScope(domain_id=DomainID(_DOMAIN_UUID))], + [DomainAppConfigFragmentSearchScope(domain_id=_DOMAIN_ID)], ) # Only domain-scoped fragments of that domain — not the other domain, public, or users. expected = { f.id for f in fragments_across_scopes - if f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_ID + if f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_SCOPE_ID } assert {item.id for item in result.items} == expected assert result.total_count == len(expected) @@ -369,12 +421,12 @@ async def test_user_scope_returns_only_that_user( ) -> None: result = await repository.scoped_search( BatchQuerier(pagination=OffsetPagination(limit=10, offset=0)), - [UserAppConfigFragmentSearchScope(user_id=UserID(_USER_UUID))], + [UserAppConfigFragmentSearchScope(user_id=_USER_ID)], ) expected = { f.id for f in fragments_across_scopes - if f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID + if f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_SCOPE_ID } assert {item.id for item in result.items} == expected @@ -386,15 +438,15 @@ async def test_scopes_or_combined_across_domain_and_user( result = await repository.scoped_search( BatchQuerier(pagination=OffsetPagination(limit=10, offset=0)), [ - DomainAppConfigFragmentSearchScope(domain_id=DomainID(_DOMAIN_UUID)), - UserAppConfigFragmentSearchScope(user_id=UserID(_USER_UUID)), + DomainAppConfigFragmentSearchScope(domain_id=_DOMAIN_ID), + UserAppConfigFragmentSearchScope(user_id=_USER_ID), ], ) expected = { f.id for f in fragments_across_scopes - if (f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_ID) - or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID) + if (f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_SCOPE_ID) + or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_SCOPE_ID) } assert {item.id for item in result.items} == expected @@ -432,13 +484,13 @@ async def two_fragments(database: ExtendedAsyncSAEngine) -> list[AppConfigFragme AppConfigFragmentRow( config_name="theme", scope_type=AppConfigScopeType.DOMAIN, - scope_id=_DOMAIN_ID, + scope_id=_DOMAIN_SCOPE_ID, config={"a": 1}, ), AppConfigFragmentRow( config_name="theme", scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, + scope_id=_USER_SCOPE_ID, config={"b": 2}, ), ] @@ -553,7 +605,7 @@ async def test_domain_visibility_selects_only_that_domain( expected = { f.id for f in fragments_across_scopes - if f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_ID + if f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_SCOPE_ID } assert {item.id for item in result.items} == expected @@ -571,7 +623,7 @@ async def test_user_visibility_selects_only_that_user( expected = { f.id for f in fragments_across_scopes - if f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID + if f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_SCOPE_ID } assert {item.id for item in result.items} == expected @@ -584,7 +636,7 @@ async def test_one_query_returns_public_domain_user_rank_ordered( ) -> None: applicable = await repository.list_visible_fragments_bulk( ["theme"], - ResolvedAppConfigScope(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)), + ResolvedAppConfigScope(domain_id=_DOMAIN_ID, user_id=_USER_ID), ) # public + the caller's domain + the caller's own user fragment, ordered by the # allow-list entries' ranks (scope-type defaults: public < domain < user). @@ -594,8 +646,8 @@ async def test_one_query_returns_public_domain_user_rank_ordered( if f.config_name == "theme" and ( f.scope_type is AppConfigScopeType.PUBLIC - or (f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_ID) - or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID) + or (f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_SCOPE_ID) + or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_SCOPE_ID) ) ] assert [f.id for f in applicable] == [f.id for f in expected] @@ -612,7 +664,7 @@ async def test_unknown_config_name_returns_empty( ) -> None: applicable = await repository.list_visible_fragments_bulk( ["unregistered"], - ResolvedAppConfigScope(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)), + ResolvedAppConfigScope(domain_id=_DOMAIN_ID, user_id=_USER_ID), ) assert applicable == [] @@ -623,7 +675,7 @@ async def test_bulk_returns_visible_fragments_for_all_names_ordered( ) -> None: applicable = await repository.list_visible_fragments_bulk( ["theme", "menu"], - ResolvedAppConfigScope(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)), + ResolvedAppConfigScope(domain_id=_DOMAIN_ID, user_id=_USER_ID), ) # public + the caller's domain + the caller's own user fragment, for both names. expected = { @@ -632,8 +684,8 @@ async def test_bulk_returns_visible_fragments_for_all_names_ordered( if f.config_name in ("theme", "menu") and ( f.scope_type is AppConfigScopeType.PUBLIC - or (f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_ID) - or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_ID) + or (f.scope_type is AppConfigScopeType.DOMAIN and f.scope_id == _DOMAIN_SCOPE_ID) + or (f.scope_type is AppConfigScopeType.USER and f.scope_id == _USER_SCOPE_ID) ) } assert {f.id for f in applicable} == expected @@ -650,7 +702,7 @@ async def test_bulk_empty_names_returns_empty( ) -> None: applicable = await repository.list_visible_fragments_bulk( [], - ResolvedAppConfigScope(domain_id=DomainID(_DOMAIN_UUID), user_id=UserID(_USER_UUID)), + ResolvedAppConfigScope(domain_id=_DOMAIN_ID, user_id=_USER_ID), ) assert applicable == [] @@ -672,7 +724,7 @@ class _FragmentScopeCase: """ scope_type: AppConfigScopeType - scope_id: str + scope_id: AppConfigScopeIdentifier | None expected_bindings: list[_ScopeBinding] = field(default_factory=list) @@ -699,15 +751,19 @@ async def _scope_bindings( [ _FragmentScopeCase( scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, - expected_bindings=[_ScopeBinding(scope_type=ScopeType.USER, scope_id=_USER_ID)], + scope_id=_USER_SCOPE_ID, + expected_bindings=[ + _ScopeBinding(scope_type=ScopeType.USER, scope_id=str(_USER_ID)) + ], ), _FragmentScopeCase( scope_type=AppConfigScopeType.DOMAIN, - scope_id=_DOMAIN_ID, - expected_bindings=[_ScopeBinding(scope_type=ScopeType.DOMAIN, scope_id=_DOMAIN_ID)], + scope_id=_DOMAIN_SCOPE_ID, + expected_bindings=[ + _ScopeBinding(scope_type=ScopeType.DOMAIN, scope_id=str(_DOMAIN_ID)) + ], ), - _FragmentScopeCase(scope_type=AppConfigScopeType.PUBLIC, scope_id="public"), + _FragmentScopeCase(scope_type=AppConfigScopeType.PUBLIC, scope_id=None), ], ids=lambda case: case.scope_type.value, ) @@ -733,15 +789,19 @@ async def test_create_binds_to_its_rbac_scope( [ _FragmentScopeCase( scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, - expected_bindings=[_ScopeBinding(scope_type=ScopeType.USER, scope_id=_USER_ID)], + scope_id=_USER_SCOPE_ID, + expected_bindings=[ + _ScopeBinding(scope_type=ScopeType.USER, scope_id=str(_USER_ID)) + ], ), _FragmentScopeCase( scope_type=AppConfigScopeType.DOMAIN, - scope_id=_DOMAIN_ID, - expected_bindings=[_ScopeBinding(scope_type=ScopeType.DOMAIN, scope_id=_DOMAIN_ID)], + scope_id=_DOMAIN_SCOPE_ID, + expected_bindings=[ + _ScopeBinding(scope_type=ScopeType.DOMAIN, scope_id=str(_DOMAIN_ID)) + ], ), - _FragmentScopeCase(scope_type=AppConfigScopeType.PUBLIC, scope_id="public"), + _FragmentScopeCase(scope_type=AppConfigScopeType.PUBLIC, scope_id=None), ], ids=lambda case: case.scope_type.value, ) @@ -780,12 +840,12 @@ async def test_bulk_purge_removes_the_scope_binding( AppConfigFragmentCreatorSpec( config_name="theme", scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, + scope_id=_USER_SCOPE_ID, config={"k": "v"}, ) ) assert await self._scope_bindings(database, str(created.id)) == [ - _ScopeBinding(scope_type=ScopeType.USER, scope_id=_USER_ID) + _ScopeBinding(scope_type=ScopeType.USER, scope_id=str(_USER_ID)) ] result = await repository.bulk_purge([AppConfigFragmentPurgerSpec(fragment_id=created.id)]) assert [p.id for p in result.succeeded] == [created.id] diff --git a/tests/unit/manager/services/app_config/test_service.py b/tests/unit/manager/services/app_config/test_service.py index 1f923ac8c9c..ffc5ab065fa 100644 --- a/tests/unit/manager/services/app_config/test_service.py +++ b/tests/unit/manager/services/app_config/test_service.py @@ -11,6 +11,7 @@ import pytest from ai.backend.common.data.app_config.types import AppConfigScopeType +from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID from ai.backend.common.identifier.domain import DomainID from ai.backend.common.identifier.user import UserID @@ -28,10 +29,17 @@ _USER_ID = UserID(uuid.uuid4()) _DOMAIN_ID = DomainID(uuid.uuid4()) + +# The same owners seen as a fragment's scope_id, which is polymorphic over scope kinds. +_USER_SCOPE_ID = AppConfigScopeIdentifier(_USER_ID) +_DOMAIN_SCOPE_ID = AppConfigScopeIdentifier(_DOMAIN_ID) _NOW = datetime.now(UTC) _SCOPE_ARGS = AppConfigScopeArguments(domain_id=_DOMAIN_ID) -FragmentFactory = Callable[[str, dict[str, Any], AppConfigScopeType, str], AppConfigFragmentData] +FragmentFactory = Callable[ + [str, dict[str, Any], AppConfigScopeType, AppConfigScopeIdentifier | None], + AppConfigFragmentData, +] @pytest.fixture @@ -45,7 +53,7 @@ def _make( config_name: str, config: dict[str, Any], scope_type: AppConfigScopeType, - scope_id: str, + scope_id: AppConfigScopeIdentifier | None, ) -> AppConfigFragmentData: return AppConfigFragmentData( id=AppConfigFragmentID(uuid.uuid4()), @@ -77,8 +85,10 @@ def deep_merge_fragments( ) -> list[AppConfigFragmentData]: # Rank-ordered (low -> high), so the user fragment overrides on merge. fragments = [ - make_fragment("theme", {"theme": "light", "lang": "en"}, AppConfigScopeType.PUBLIC, ""), - make_fragment("theme", {"theme": "dark"}, AppConfigScopeType.USER, str(_USER_ID)), + make_fragment( + "theme", {"theme": "light", "lang": "en"}, AppConfigScopeType.PUBLIC, None + ), + make_fragment("theme", {"theme": "dark"}, AppConfigScopeType.USER, _USER_SCOPE_ID), ] mock_fragment_repository.list_visible_fragments_bulk = AsyncMock(return_value=fragments) return fragments @@ -95,13 +105,13 @@ def list_replace_fragments( "ui", {"nav": ["home", "about", "contact"], "theme": {"light": True}}, AppConfigScopeType.PUBLIC, - "", + None, ), make_fragment( "ui", {"nav": ["dashboard"], "theme": {"dark": True}}, AppConfigScopeType.USER, - str(_USER_ID), + _USER_SCOPE_ID, ), ] mock_fragment_repository.list_visible_fragments_bulk = AsyncMock(return_value=fragments) @@ -124,9 +134,11 @@ def two_name_fragments( ) -> list[AppConfigFragmentData]: # Visible fragments for both names, (config_name, rank)-ordered. fragments = [ - make_fragment("theme", {"theme": "light", "lang": "en"}, AppConfigScopeType.PUBLIC, ""), - make_fragment("theme", {"theme": "dark"}, AppConfigScopeType.USER, str(_USER_ID)), - make_fragment("menu", {"items": ["a"]}, AppConfigScopeType.PUBLIC, ""), + make_fragment( + "theme", {"theme": "light", "lang": "en"}, AppConfigScopeType.PUBLIC, None + ), + make_fragment("theme", {"theme": "dark"}, AppConfigScopeType.USER, _USER_SCOPE_ID), + make_fragment("menu", {"items": ["a"]}, AppConfigScopeType.PUBLIC, None), ] mock_fragment_repository.list_visible_fragments_bulk = AsyncMock(return_value=fragments) return fragments @@ -137,7 +149,7 @@ def duplicate_name_fragments( make_fragment: FragmentFactory, mock_fragment_repository: MagicMock, ) -> list[AppConfigFragmentData]: - fragments = [make_fragment("theme", {"theme": "dark"}, AppConfigScopeType.PUBLIC, "")] + fragments = [make_fragment("theme", {"theme": "dark"}, AppConfigScopeType.PUBLIC, None)] mock_fragment_repository.list_visible_fragments_bulk = AsyncMock(return_value=fragments) return fragments @@ -149,7 +161,7 @@ def public_only_fragments( ) -> list[AppConfigFragmentData]: fragments = [ make_fragment( - "theme", {"theme": "light", "lang": "en"}, AppConfigScopeType.PUBLIC, "public" + "theme", {"theme": "light", "lang": "en"}, AppConfigScopeType.PUBLIC, None ) ] mock_fragment_repository.list_visible_fragments_bulk = AsyncMock(return_value=fragments) diff --git a/tests/unit/manager/services/app_config_fragment/test_create_action.py b/tests/unit/manager/services/app_config_fragment/test_create_action.py index a2a796c737b..8a9e82b35fb 100644 --- a/tests/unit/manager/services/app_config_fragment/test_create_action.py +++ b/tests/unit/manager/services/app_config_fragment/test_create_action.py @@ -7,12 +7,16 @@ from __future__ import annotations +import uuid from dataclasses import dataclass import pytest from ai.backend.common.data.app_config.types import AppConfigScopeType from ai.backend.common.data.permission.types import RBACElementType, ScopeType +from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier +from ai.backend.common.identifier.domain import DomainID +from ai.backend.common.identifier.user import UserID from ai.backend.manager.data.permission.types import RBACElementRef from ai.backend.manager.repositories.app_config_fragment.creators import ( AppConfigFragmentCreatorSpec, @@ -21,11 +25,16 @@ CreateAppConfigFragmentAction, ) +_VICTIM_USER_ID = UserID(uuid.uuid4()) +_DOMAIN_ID = DomainID(uuid.uuid4()) +_VICTIM_USER_SCOPE_ID = AppConfigScopeIdentifier(_VICTIM_USER_ID) +_DOMAIN_SCOPE_ID = AppConfigScopeIdentifier(_DOMAIN_ID) + def _make_action( *, scope_type: AppConfigScopeType, - scope_id: str, + scope_id: AppConfigScopeIdentifier | None, ) -> CreateAppConfigFragmentAction: return CreateAppConfigFragmentAction( creator_spec=AppConfigFragmentCreatorSpec( @@ -42,7 +51,7 @@ class _ScopeTarget: """A fragment scope, and the RBAC scope a create at it must authorize against.""" scope_type: AppConfigScopeType - scope_id: str + scope_id: AppConfigScopeIdentifier | None expected_element: RBACElementRef expected_scope_type: ScopeType @@ -55,19 +64,19 @@ class TestCreateTargetElement: [ _ScopeTarget( scope_type=AppConfigScopeType.USER, - scope_id="victim-user", - expected_element=RBACElementRef(RBACElementType.USER, "victim-user"), + scope_id=_VICTIM_USER_SCOPE_ID, + expected_element=RBACElementRef(RBACElementType.USER, str(_VICTIM_USER_ID)), expected_scope_type=ScopeType.USER, ), _ScopeTarget( scope_type=AppConfigScopeType.DOMAIN, - scope_id="default", - expected_element=RBACElementRef(RBACElementType.DOMAIN, "default"), + scope_id=_DOMAIN_SCOPE_ID, + expected_element=RBACElementRef(RBACElementType.DOMAIN, str(_DOMAIN_ID)), expected_scope_type=ScopeType.DOMAIN, ), _ScopeTarget( scope_type=AppConfigScopeType.PUBLIC, - scope_id="", + scope_id=None, expected_element=RBACElementRef(RBACElementType.APP_CONFIG_FRAGMENT, ""), expected_scope_type=ScopeType.GLOBAL, ), diff --git a/tests/unit/manager/services/app_config_fragment/test_service.py b/tests/unit/manager/services/app_config_fragment/test_service.py index c47a6eaa3c2..6f91383a17f 100644 --- a/tests/unit/manager/services/app_config_fragment/test_service.py +++ b/tests/unit/manager/services/app_config_fragment/test_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import uuid +from dataclasses import dataclass from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock @@ -10,6 +11,7 @@ from ai.backend.common.data.app_config.types import AppConfigScopeType from ai.backend.common.data.permission.types import ScopeType +from ai.backend.common.identifier.app_config import AppConfigScopeIdentifier from ai.backend.common.identifier.app_config_fragment import AppConfigFragmentID from ai.backend.common.identifier.domain import DomainID from ai.backend.common.identifier.user import UserID @@ -65,9 +67,26 @@ from ai.backend.manager.services.app_config_fragment.service import AppConfigFragmentService from ai.backend.manager.types import OptionalState -_USER_UUID = uuid.uuid4() -_USER_ID = str(_USER_UUID) -_DOMAIN_ID = str(uuid.uuid4()) +_USER_ID = UserID(uuid.uuid4()) +_DOMAIN_ID = DomainID(uuid.uuid4()) + +# The same owners seen as a fragment's scope_id, which is polymorphic over scope kinds. +_USER_SCOPE_ID = AppConfigScopeIdentifier(_USER_ID) +_DOMAIN_SCOPE_ID = AppConfigScopeIdentifier(_DOMAIN_ID) + + +@dataclass(frozen=True) +class _RBACScopeCase: + """A fragment scope, and the RBAC scope a create at it authorizes against. + + RBAC identifies scopes by string, so the expected id is the rendered form — empty for + public, which is global and names no owner. + """ + + scope_type: AppConfigScopeType + scope_id: AppConfigScopeIdentifier | None + expected_scope_type: ScopeType + expected_scope_id: str class TestAppConfigFragmentService: @@ -90,7 +109,7 @@ async def test_create_delegates_to_repository( id=AppConfigFragmentID(uuid.uuid4()), config_name="theme", scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, + scope_id=_USER_SCOPE_ID, config={"k": "v"}, created_at=datetime.now(UTC), updated_at=datetime.now(UTC), @@ -99,7 +118,7 @@ async def test_create_delegates_to_repository( spec = AppConfigFragmentCreatorSpec( config_name="theme", scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, + scope_id=_USER_SCOPE_ID, config={"k": "v"}, ) @@ -115,7 +134,7 @@ async def test_get(self, service: AppConfigFragmentService, mock_repository: Mag id=AppConfigFragmentID(uuid.uuid4()), config_name="theme", scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, + scope_id=_USER_SCOPE_ID, config={"k": "v"}, created_at=datetime.now(UTC), updated_at=datetime.now(UTC), @@ -145,7 +164,7 @@ async def test_admin_search( id=AppConfigFragmentID(uuid.uuid4()), config_name="theme", scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, + scope_id=_USER_SCOPE_ID, config={"k": "v"}, created_at=datetime.now(UTC), updated_at=datetime.now(UTC), @@ -173,7 +192,7 @@ async def test_scoped_search_builds_domain_and_user_scopes( id=AppConfigFragmentID(uuid.uuid4()), config_name="theme", scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, + scope_id=_USER_SCOPE_ID, config={"k": "v"}, created_at=datetime.now(UTC), updated_at=datetime.now(UTC), @@ -193,7 +212,7 @@ async def test_scoped_search_builds_domain_and_user_scopes( ScopedSearchAppConfigFragmentAction( items=[ DomainAppConfigFragmentTarget(domain_id=domain_id), - UserAppConfigFragmentTarget(user_id=UserID(_USER_UUID)), + UserAppConfigFragmentTarget(user_id=_USER_ID), ], querier=querier, ) @@ -201,12 +220,15 @@ async def test_scoped_search_builds_domain_and_user_scopes( assert result.data == [fragment] # queried_refs preserve the scoped principals (domain, then user). - assert [ref.element_id for ref in result.queried_refs] == [str(domain_id), _USER_ID] + assert [ref.element_id for ref in result.queried_refs] == [ + str(domain_id), + str(_USER_ID), + ] mock_repository.scoped_search.assert_called_once() called_querier, called_scopes = mock_repository.scoped_search.call_args.args assert called_querier is querier assert called_scopes[0].domain_id == domain_id - assert called_scopes[1].user_id == _USER_UUID + assert called_scopes[1].user_id == _USER_ID # --- update --- @@ -217,7 +239,7 @@ async def test_update_delegates_to_repository( id=AppConfigFragmentID(uuid.uuid4()), config_name="theme", scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, + scope_id=_USER_SCOPE_ID, config={"k": "v"}, created_at=datetime.now(UTC), updated_at=datetime.now(UTC), @@ -242,7 +264,7 @@ async def test_purge_delegates_to_repository( id=AppConfigFragmentID(uuid.uuid4()), config_name="theme", scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, + scope_id=_USER_SCOPE_ID, config={"k": "v"}, created_at=datetime.now(UTC), updated_at=datetime.now(UTC), @@ -265,7 +287,7 @@ async def test_bulk_update_delegates_to_repository( id=AppConfigFragmentID(uuid.uuid4()), config_name="theme", scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, + scope_id=_USER_SCOPE_ID, config={"k": "v"}, created_at=datetime.now(UTC), updated_at=datetime.now(UTC), @@ -296,7 +318,7 @@ async def test_bulk_purge_delegates_to_repository( id=AppConfigFragmentID(uuid.uuid4()), config_name="theme", scope_type=AppConfigScopeType.USER, - scope_id=_USER_ID, + scope_id=_USER_SCOPE_ID, config={"k": "v"}, created_at=datetime.now(UTC), updated_at=datetime.now(UTC), @@ -324,27 +346,37 @@ class TestCreateActionScope: """The create action acts at the fragment's own scope — not admin-only/global.""" @pytest.mark.parametrize( - ("scope_type", "scope_id", "expected_scope_type", "expected_scope_id"), + "case", [ - (AppConfigScopeType.PUBLIC, "public", ScopeType.GLOBAL, ""), - (AppConfigScopeType.DOMAIN, "default", ScopeType.DOMAIN, "default"), - (AppConfigScopeType.USER, _USER_ID, ScopeType.USER, _USER_ID), + _RBACScopeCase( + scope_type=AppConfigScopeType.PUBLIC, + scope_id=None, + expected_scope_type=ScopeType.GLOBAL, + expected_scope_id="", + ), + _RBACScopeCase( + scope_type=AppConfigScopeType.DOMAIN, + scope_id=_DOMAIN_SCOPE_ID, + expected_scope_type=ScopeType.DOMAIN, + expected_scope_id=str(_DOMAIN_ID), + ), + _RBACScopeCase( + scope_type=AppConfigScopeType.USER, + scope_id=_USER_SCOPE_ID, + expected_scope_type=ScopeType.USER, + expected_scope_id=str(_USER_ID), + ), ], + ids=lambda case: case.scope_type.value, ) - def test_scope_follows_fragment_scope( - self, - scope_type: AppConfigScopeType, - scope_id: str, - expected_scope_type: ScopeType, - expected_scope_id: str, - ) -> None: + def test_scope_follows_fragment_scope(self, case: _RBACScopeCase) -> None: action = CreateAppConfigFragmentAction( creator_spec=AppConfigFragmentCreatorSpec( config_name="theme", - scope_type=scope_type, - scope_id=scope_id, + scope_type=case.scope_type, + scope_id=case.scope_id, config={"k": "v"}, ), ) - assert action.scope_type() == expected_scope_type - assert action.scope_id() == expected_scope_id + assert action.scope_type() == case.expected_scope_type + assert action.scope_id() == case.expected_scope_id