Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/12984.enhance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Store `app_config_fragments.scope_id` as a nullable UUID rather than a string, with `NULL` for the ownerless public scope.
10 changes: 5 additions & 5 deletions src/ai/backend/common/data/app_config/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",)

Expand Down Expand Up @@ -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).
Expand Down
10 changes: 10 additions & 0 deletions src/ai/backend/common/identifier/app_config.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 2 additions & 1 deletion src/ai/backend/manager/data/app_config_fragment/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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_<table>_.
_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)
19 changes: 12 additions & 7 deletions src/ai/backend/manager/models/app_config_fragment/conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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]:
Expand Down
20 changes: 17 additions & 3 deletions src/ai/backend/manager/models/app_config_fragment/row.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"),
Comment thread
jopemachine marked this conversation as resolved.
Comment thread
jopemachine marked this conversation as resolved.
),
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"],
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
)
)
Expand Down Expand Up @@ -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"},
)
)
Expand Down
Loading
Loading