Skip to content
1 change: 1 addition & 0 deletions changes/9544.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add RBAC scope-entity combination constants based on BEP-1048 entity-edge-catalog.
60 changes: 60 additions & 0 deletions src/ai/backend/common/data/permission/scope_entity_combinations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Valid RBAC scope-entity combinations based on BEP-1048/entity-edge-catalog.md.

This module is the single source of truth for which entity types are valid
under each scope type. It is used for:
- Frontend UI filtering (which entity types to show for a given scope)
- Server-side validation (reject invalid combinations)
"""

from __future__ import annotations

from collections.abc import Mapping

from ai.backend.common.data.permission.types import RBACElementType

VALID_SCOPE_ENTITY_COMBINATIONS: Mapping[RBACElementType, frozenset[RBACElementType]] = {
Comment thread
fregataa marked this conversation as resolved.
RBACElementType.DOMAIN: frozenset({
RBACElementType.RESOURCE_GROUP,
RBACElementType.CONTAINER_REGISTRY,
RBACElementType.USER,
RBACElementType.PROJECT,
RBACElementType.NETWORK,
RBACElementType.STORAGE_HOST,
}),
RBACElementType.PROJECT: frozenset({
RBACElementType.RESOURCE_GROUP,
RBACElementType.CONTAINER_REGISTRY,
RBACElementType.SESSION,
RBACElementType.VFOLDER,
RBACElementType.DEPLOYMENT,
RBACElementType.NETWORK,
RBACElementType.USER,
RBACElementType.STORAGE_HOST,
}),
RBACElementType.USER: frozenset({
RBACElementType.RESOURCE_GROUP,
RBACElementType.SESSION,
RBACElementType.VFOLDER,
RBACElementType.DEPLOYMENT,
RBACElementType.KEYPAIR,
}),
RBACElementType.RESOURCE_GROUP: frozenset({
RBACElementType.AGENT,
}),
RBACElementType.AGENT: frozenset({
RBACElementType.KERNEL,
}),
RBACElementType.SESSION: frozenset({
RBACElementType.KERNEL,
}),
RBACElementType.MODEL_DEPLOYMENT: frozenset({
RBACElementType.ROUTING,
RBACElementType.SESSION,
}),
RBACElementType.CONTAINER_REGISTRY: frozenset({
RBACElementType.IMAGE,
}),
RBACElementType.STORAGE_HOST: frozenset({
RBACElementType.VFOLDER,
}),
}
3 changes: 3 additions & 0 deletions src/ai/backend/common/data/permission/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,9 @@ class RBACElementType(enum.StrEnum):
RESOURCE_GROUP = "resource_group"
CONTAINER_REGISTRY = "container_registry"
STORAGE_HOST = "storage_host"
AGENT = "agent"
KERNEL = "kernel"
ROUTING = "routing"
Comment thread
fregataa marked this conversation as resolved.
IMAGE = "image"
ARTIFACT = "artifact"
ARTIFACT_REGISTRY = "artifact_registry"
Expand Down
3 changes: 3 additions & 0 deletions src/ai/backend/manager/api/gql/rbac/types/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ async def entity(
| RBACElementType.PROJECT_RESOURCE_POLICY
| RBACElementType.AUDIT_LOG
| RBACElementType.EVENT_LOG
| RBACElementType.AGENT
| RBACElementType.KERNEL
| RBACElementType.ROUTING
):
return None

Expand Down
3 changes: 3 additions & 0 deletions src/ai/backend/manager/api/gql/rbac/types/permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,9 @@ async def scope(
| RBACElementType.AUDIT_LOG
| RBACElementType.EVENT_LOG
| RBACElementType.NOTIFICATION_RULE
| RBACElementType.AGENT
| RBACElementType.KERNEL
| RBACElementType.ROUTING
):
return None

Expand Down
1 change: 1 addition & 0 deletions tests/common/data/permission/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
python_tests()
115 changes: 115 additions & 0 deletions tests/common/data/permission/test_scope_entity_combinations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Tests for RBAC scope-entity combination constants."""

from __future__ import annotations

import pytest

from ai.backend.common.data.permission.scope_entity_combinations import (
VALID_SCOPE_ENTITY_COMBINATIONS,
)
from ai.backend.common.data.permission.types import RBACElementType


class TestValidScopeEntityCombinations:
"""Tests for the VALID_SCOPE_ENTITY_COMBINATIONS constant."""

def test_domain_scope_entities(self) -> None:
entities = VALID_SCOPE_ENTITY_COMBINATIONS[RBACElementType.DOMAIN]
assert entities == {
RBACElementType.RESOURCE_GROUP,
RBACElementType.CONTAINER_REGISTRY,
RBACElementType.USER,
RBACElementType.PROJECT,
RBACElementType.NETWORK,
RBACElementType.STORAGE_HOST,
}

def test_project_scope_entities(self) -> None:
entities = VALID_SCOPE_ENTITY_COMBINATIONS[RBACElementType.PROJECT]
assert entities == {
RBACElementType.RESOURCE_GROUP,
RBACElementType.CONTAINER_REGISTRY,
RBACElementType.SESSION,
RBACElementType.VFOLDER,
RBACElementType.DEPLOYMENT,
RBACElementType.NETWORK,
RBACElementType.USER,
RBACElementType.STORAGE_HOST,
}

def test_user_scope_entities(self) -> None:
entities = VALID_SCOPE_ENTITY_COMBINATIONS[RBACElementType.USER]
assert entities == {
RBACElementType.RESOURCE_GROUP,
RBACElementType.SESSION,
RBACElementType.VFOLDER,
RBACElementType.DEPLOYMENT,
RBACElementType.KEYPAIR,
}

def test_resource_group_scope_entities(self) -> None:
entities = VALID_SCOPE_ENTITY_COMBINATIONS[RBACElementType.RESOURCE_GROUP]
assert entities == {
RBACElementType.AGENT,
}

def test_agent_scope_entities(self) -> None:
entities = VALID_SCOPE_ENTITY_COMBINATIONS[RBACElementType.AGENT]
assert entities == {
RBACElementType.KERNEL,
}

def test_session_scope_entities(self) -> None:
entities = VALID_SCOPE_ENTITY_COMBINATIONS[RBACElementType.SESSION]
assert entities == {
RBACElementType.KERNEL,
}

def test_model_deployment_scope_entities(self) -> None:
entities = VALID_SCOPE_ENTITY_COMBINATIONS[RBACElementType.MODEL_DEPLOYMENT]
assert entities == {
RBACElementType.ROUTING,
RBACElementType.SESSION,
}

def test_container_registry_scope_entities(self) -> None:
entities = VALID_SCOPE_ENTITY_COMBINATIONS[RBACElementType.CONTAINER_REGISTRY]
assert entities == {
RBACElementType.IMAGE,
}

def test_storage_host_scope_entities(self) -> None:
entities = VALID_SCOPE_ENTITY_COMBINATIONS[RBACElementType.STORAGE_HOST]
assert entities == {
RBACElementType.VFOLDER,
}

def test_scope_keys(self) -> None:
expected_scopes = {
RBACElementType.DOMAIN,
RBACElementType.PROJECT,
RBACElementType.USER,
RBACElementType.RESOURCE_GROUP,
RBACElementType.AGENT,
RBACElementType.SESSION,
RBACElementType.MODEL_DEPLOYMENT,
RBACElementType.CONTAINER_REGISTRY,
RBACElementType.STORAGE_HOST,
}
assert set(VALID_SCOPE_ENTITY_COMBINATIONS.keys()) == expected_scopes

@pytest.mark.parametrize(
("scope", "entity"),
[
(RBACElementType.DOMAIN, RBACElementType.SESSION),
(RBACElementType.DOMAIN, RBACElementType.KEYPAIR),
(RBACElementType.PROJECT, RBACElementType.KEYPAIR),
(RBACElementType.USER, RBACElementType.PROJECT),
(RBACElementType.USER, RBACElementType.CONTAINER_REGISTRY),
(RBACElementType.SESSION, RBACElementType.VFOLDER),
(RBACElementType.SESSION, RBACElementType.SESSION),
],
)
def test_invalid_combinations(self, scope: RBACElementType, entity: RBACElementType) -> None:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure whether parametrize is necessary for this test, since there can be virtually unlimited invalid combinations anyway. Unless there is a specific reason to test all combinations separately.

entities = VALID_SCOPE_ENTITY_COMBINATIONS.get(scope, frozenset())
assert entity not in entities
Loading