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