From 37f4ec8320e52240e2f63ba366ce65c814ff0833 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Wed, 4 Mar 2026 23:42:11 +0900 Subject: [PATCH 1/8] refactor(BA-2274): derive scope from business logic, remove _scope_type/_scope_id fields Change VFolderScopeAction pattern to compute scope from business context instead of storing it in separate fields: - Remove _scope_type and _scope_id fields from VFolder scope actions: * CreateVFolderAction * ListVFolderAction * ListVFolderActionResult - Each concrete class now computes scope directly from its business fields: * scope_type() always returns ScopeType.USER * scope_id() returns str(self.user_uuid) * target_element() uses USER scope with user_uuid - Remove scope calculation logic from API handlers: * handler.py: Remove scope_type/scope_id computation * vfolder.py: Remove scope_type/scope_id computation - Update VFolder service to not pass removed fields to ActionResult Benefits: - Eliminates redundant fields (_scope_type, _scope_id) - Scope derivation logic co-located with action definition - Enforces "always USER scope" requirement at type level - API handlers now only need to provide user_uuid, not compute scope This follows the RBAC validator pattern established in BA-2946: "always use user id for scope, never use GLOBAL scope". Related: BA-2946 (parent issue), BA-2946-RBAC-validator-implementation-guide.md Co-Authored-By: Claude Sonnet 4.5 --- ...946-RBAC-validator-implementation-guide.md | 584 ++++++++++++++++++ .../manager/api/rest/vfolder/handler.py | 19 - src/ai/backend/manager/api/vfolder.py | 19 - .../manager/services/vfolder/actions/base.py | 27 +- .../services/vfolder/services/vfolder.py | 2 - 5 files changed, 594 insertions(+), 57 deletions(-) create mode 100644 proposals/BA-2946-RBAC-validator-implementation-guide.md diff --git a/proposals/BA-2946-RBAC-validator-implementation-guide.md b/proposals/BA-2946-RBAC-validator-implementation-guide.md new file mode 100644 index 00000000000..e0238c03715 --- /dev/null +++ b/proposals/BA-2946-RBAC-validator-implementation-guide.md @@ -0,0 +1,584 @@ +# BA-2946 RBAC Validator Implementation Guide + +**For AI Agents implementing RBAC validators on other entities** + +## Overview + +This guide explains how to apply RBAC action validators to Backend.AI entities following the BEP-1048 RBAC model. The Session entity implementation (completed) serves as the reference example. + +## Prerequisites + +### Required Knowledge + +1. **BEP-1048 RBAC Model**: Read `proposals/BEP-1048-RBAC-entity-relationship-model.md` + - Understand the 3-type model: guarded, auto, ref + - Know the entity relationship design + +2. **Action Architecture**: Read `src/ai/backend/manager/actions/README.md` + - BaseAction, BaseScopeAction, BaseSingleEntityAction + - ActionProcessor and validators + - ActionValidator interface + +3. **Validator Infrastructure**: Review existing validators + - `src/ai/backend/manager/actions/validators/rbac/scope.py` (ScopeActionRBACValidator) + - `src/ai/backend/manager/actions/validators/rbac/single_entity.py` (SingleEntityActionRBACValidator) + +### Verify Infrastructure + +Before starting, confirm these components exist: +- [ ] `ScopeActionRBACValidator` implemented +- [ ] `SingleEntityActionRBACValidator` implemented +- [ ] `PermissionControllerRepository` available +- [ ] Base action classes support RBAC methods + +## Implementation Steps + +### Step 1: Identify Entity Type and Actions + +**Determine the entity's RBAC type** from BEP-1048: +- **Guarded**: No automatic scope association (Session, Endpoint) +- **Auto**: Automatic scope association via DB (Image → ContainerRegistry, Agent → ResourceGroup) +- **Ref**: Reference-based association via DB (User → Group) + +**List all actions** for the entity and categorize them: + +| Category | Action Type | Description | Example | +|----------|-------------|-------------|---------| +| Scope | BaseScopeAction | Operations within a scope (domain/project) | create, search, list | +| Single Entity | BaseSingleEntityAction | Operations on a specific entity | get, update, delete, execute | +| Batch | BaseBatchAction | Operations on multiple entities | batch_delete, batch_update | +| Internal/Legacy | BaseAction | No RBAC validation needed | internal operations, legacy endpoints | + +**Session entity example**: +```python +# Scope actions (6) +- create_cluster, create_from_params, create_from_template +- match_sessions, search_kernels, search_sessions + +# Single entity actions (4) +- destroy_session, execute_session, get_session_info, modify_session + +# Internal/legacy (15) +- commit_session, complete, convert_session_to_image, ... +``` + +### Step 2: Review Existing Action Base Classes + +Check `src/ai/backend/manager/services/{entity}/base.py` for existing base classes. + +**Required base classes**: + +```python +@dataclass +class EntityScopeAction(BaseScopeAction): + """Base class for entity actions that operate within a scope (domain/project).""" + + @override + @classmethod + def entity_type(cls) -> EntityType: + return EntityType.YOUR_ENTITY + +@dataclass +class EntitySingleEntityAction(BaseSingleEntityAction): + """Base class for entity actions that operate on a single entity.""" + + @override + @classmethod + def entity_type(cls) -> EntityType: + return EntityType.YOUR_ENTITY +``` + +**If they don't exist**: Create them in `base.py` (see Session example below). + +**If they exist but incomplete**: Ensure they implement required methods: +- `entity_type()` - returns the EntityType enum +- For ScopeAction: `scope_type()`, `scope_id()`, `target_element()` +- For SingleEntityAction: `target_entity_id()` + +### Step 3: Update Action Definitions + +For each action file in `src/ai/backend/manager/services/{entity}/actions/`: + +#### 3.1 Scope Actions + +Ensure the action inherits from `EntityScopeAction`: + +```python +@dataclass +class CreateEntityAction(EntityScopeAction): + user_id: UserID # Required for USER scope + # other fields... + + @override + def scope_type(self) -> ScopeType: + return ScopeType.USER # Almost always USER, never GLOBAL + + @override + def scope_id(self) -> str: + return str(self.user_id) + + @override + def target_element(self) -> str: + return f"user:{self.user_id}" +``` + +**CRITICAL RULES**: +- ❌ NEVER use `ScopeType.GLOBAL` - use `ScopeType.USER` with user_id +- ✅ Always derive scope from business logic (user_id, domain_name, project_id) +- ❌ Do NOT add `_scope_type`, `_scope_id` fields + +#### 3.2 Single Entity Actions + +Ensure the action inherits from `EntitySingleEntityAction`: + +```python +@dataclass +class UpdateEntityAction(EntitySingleEntityAction): + entity_id: EntityID # Explicit entity ID + user_id: UserID + # other fields... + + @override + def target_entity_id(self) -> str: + return str(self.entity_id) +``` + +**KEY PATTERNS**: + +**Pattern 1: ID available at action creation (GraphQL)** +```python +@dataclass +class ModifyEntityAction(EntitySingleEntityAction): + entity_id: uuid.UUID # Not nullable, explicitly provided + + @override + def target_entity_id(self) -> str: + return str(self.entity_id) +``` + +**Pattern 2: Name-based lookup (REST API)** +```python +@dataclass +class UpdateEntityAction(EntitySingleEntityAction): + entity_name: str # Only name available from URL + owner_access_key: AccessKey + + @override + def target_entity_id(self) -> str | None: + return None # Will be resolved by validator via service method +``` + +**Pattern 3: Complex ID resolution** +```python +@dataclass +class DeleteEntityAction(EntitySingleEntityAction): + entity_name: str + user_uuid: uuid.UUID + + @override + def target_entity_id(self) -> str | None: + return None # Validator will query DB to resolve entity_id +``` + +**IMPORTANT**: If `target_entity_id()` returns `None`, the SingleEntityActionRBACValidator will: +1. Call `service.get_entity_for_permission_check(action)` +2. Extract `entity_id` from the result +3. Use that ID for permission checking + +### Step 4: Update Service Methods (if needed) + +If any action returns `None` from `target_entity_id()`, ensure the service class implements a helper method: + +```python +class EntityService: + async def get_entity_for_permission_check( + self, action: GetEntityAction + ) -> EntityData: + """ + Resolve entity by name/key for permission checking. + + This method is called by SingleEntityActionRBACValidator when + action.target_entity_id() returns None. + """ + # Query DB to find entity by name/key + # Return minimal EntityData with entity_id populated + ... +``` + +**Session example**: Most session actions use session_name (REST API), so many return `None` from `target_entity_id()`. + +### Step 5: Connect Validators to Processors + +Update `src/ai/backend/manager/services/{entity}/processors.py`: + +#### 5.1 Add imports + +```python +from typing import cast, override + +from ai.backend.manager.actions.validator.base import ActionValidator +from ai.backend.manager.actions.validators.rbac.scope import ScopeActionRBACValidator +from ai.backend.manager.actions.validators.rbac.single_entity import SingleEntityActionRBACValidator +from ai.backend.manager.repositories.permission_controller.repository import ( + PermissionControllerRepository, +) +``` + +#### 5.2 Update `__init__` signature + +```python +def __init__( + self, + service: EntityService, + action_monitors: list[ActionMonitor], + permission_repository: PermissionControllerRepository, # Add this +) -> None: +``` + +#### 5.3 Create validator instances + +```python +# Create RBAC validators +scope_validator = ScopeActionRBACValidator(permission_repository) +single_entity_validator = SingleEntityActionRBACValidator(permission_repository) +``` + +#### 5.4 Reorganize processor initialization + +Group processors into three sections with clear comments: + +```python +# Actions without RBAC validation (internal/legacy) +self.internal_action = ActionProcessor(service.internal_action, action_monitors) +self.legacy_action = ActionProcessor(service.legacy_action, action_monitors) + +# Scope actions with RBAC validation +self.create_entity = ActionProcessor( + service.create_entity, + action_monitors, + validators=[cast(ActionValidator, scope_validator)], +) +self.search_entities = ActionProcessor( + service.search, + action_monitors, + validators=[cast(ActionValidator, scope_validator)] +) + +# Single entity actions with RBAC validation +self.get_entity = ActionProcessor( + service.get_entity, + action_monitors, + validators=[cast(ActionValidator, single_entity_validator)], +) +self.update_entity = ActionProcessor( + service.update_entity, + action_monitors, + validators=[cast(ActionValidator, single_entity_validator)], +) +``` + +**Why cast()?**: `ActionProcessor` expects `ActionValidator` protocol, but validators are typed as their concrete classes. The cast satisfies the type checker. + +### Step 6: Wire up in main Processors + +Update `src/ai/backend/manager/services/processors.py`: + +```python +entity_processors = EntityProcessors( + services.entity, + action_monitors, + args.service_args.repositories.permission_controller.repository, # Pass this +) +``` + +### Step 7: Quality Checks + +Run these commands and fix all errors: + +```bash +# Format +pants fmt :: + +# Auto-fix linting issues +pants fix :: + +# Lint changed files +pants lint --changed-since=origin/main + +# Type check changed files +pants check --changed-since=origin/main + +# Run tests +pants test --changed-since=origin/main +``` + +**CRITICAL**: Fix all errors - never use `# noqa` or `# type: ignore`. + +## Common Patterns + +### Pattern 1: User-Scoped Actions (Most Common) + +```python +@dataclass +class CreateSessionAction(SessionScopeAction): + user_id: UserID + domain_name: str + project_name: str | None + + @override + def scope_type(self) -> ScopeType: + return ScopeType.USER # Always USER for user-initiated actions + + @override + def scope_id(self) -> str: + return str(self.user_id) + + @override + def target_element(self) -> str: + return f"user:{self.user_id}" +``` + +### Pattern 2: GraphQL Actions with UUID + +```python +@dataclass +class ModifySessionAction(SessionSingleEntityAction): + session_id: SessionId # uuid.UUID, not nullable + + @override + def target_entity_id(self) -> str: + return str(self.session_id) +``` + +**GraphQL handler**: +```python +_, raw_session_id = cast(ResolvedGlobalID, input["id"]) +session_id = SessionId(uuid.UUID(raw_session_id)) + +result = await processors.session.modify_session.wait_for_complete( + ModifySessionAction( + session_id=session_id, # Explicit UUID from Global ID + updater=Updater(...), + ) +) +``` + +### Pattern 3: REST API Actions with Name + +```python +@dataclass +class RestartSessionAction(SessionSingleEntityAction): + session_name: str # From URL path /{session_name} + owner_access_key: AccessKey + + @override + def target_entity_id(self) -> str | None: + return None # Validator will resolve via service +``` + +**REST handler**: +```python +@server.delete("/{session_name}") +async def restart(request: web.Request, session_name: str) -> web.Response: + result = await processors.session.restart_session.wait_for_complete( + RestartSessionAction( + session_name=session_name, # Only name from URL + owner_access_key=request["access_key"], + ) + ) +``` + +**Service helper**: +```python +async def get_session_for_permission_check( + self, action: RestartSessionAction +) -> SessionData: + # Query DB by session_name to get session_id + ... +``` + +### Pattern 4: Batch Actions + +```python +@dataclass +class BatchDeleteEntitiesAction(EntityBatchAction): + entity_ids: list[uuid.UUID] + user_id: UserID + + @override + def target_entity_ids(self) -> list[str]: + return [str(eid) for eid in self.entity_ids] +``` + +**Note**: Batch action validators may need custom implementation - check if `BatchActionRBACValidator` exists. + +## Testing + +### Unit Tests + +Create tests in `tests/unit/manager/services/{entity}/actions/`: + +```python +async def test_create_entity_action_rbac_validation(): + """Test that ScopeActionRBACValidator is applied to create action.""" + # Arrange + mock_repository = Mock(spec=PermissionControllerRepository) + mock_repository.check_scope_permission.return_value = True + + processors = EntityProcessors( + service=Mock(spec=EntityService), + action_monitors=[], + permission_repository=mock_repository, + ) + + # Act + action = CreateEntityAction(user_id=UserID(uuid.uuid4()), ...) + await processors.create_entity.wait_for_complete(action) + + # Assert + mock_repository.check_scope_permission.assert_called_once() +``` + +### Integration Tests + +Add tests in `tests/integration/`: + +```python +async def test_create_entity_without_permission( + api_client: TestClient, + user_without_permissions: UserFixture, +): + """Test that creating entity without permission fails with 403.""" + response = await api_client.post( + "/entities", + json={"name": "test"}, + headers={"Authorization": f"Bearer {user_without_permissions.token}"}, + ) + assert response.status == 403 +``` + +## Troubleshooting + +### Issue 1: Type Check Error - "Missing named argument" + +**Error**: +``` +error: Missing named argument "entity_id" for "GetEntityAction" +``` + +**Cause**: Action requires `entity_id` but handler only has `entity_name`. + +**Solutions**: +1. Make `target_entity_id()` return `None` and implement service helper method +2. If ID is always available, ensure it's passed from handler + +### Issue 2: Validator Not Called + +**Symptom**: Permission checks not happening. + +**Debug steps**: +1. Verify validator is passed to `ActionProcessor` constructor +2. Check `cast(ActionValidator, validator)` is used +3. Ensure action inherits from correct base class (ScopeAction/SingleEntityAction) +4. Confirm base class implements required RBAC methods + +### Issue 3: Scope Type is GLOBAL + +**Error**: Actions use `ScopeType.GLOBAL` instead of `ScopeType.USER`. + +**Fix**: Change all scope actions to use `ScopeType.USER` with `user_id`: + +```python +# ❌ WRONG +def scope_type(self) -> ScopeType: + return ScopeType.GLOBAL + +# ✅ CORRECT +def scope_type(self) -> ScopeType: + return ScopeType.USER + +def scope_id(self) -> str: + return str(self.user_id) +``` + +### Issue 4: REST API Handler Breaks + +**Error**: Handler can't provide required `entity_id` field. + +**Cause**: REST API endpoints use URL patterns like `/{entity_name}`, not `/{entity_id}`. + +**Solution**: This is an architectural limitation: +1. Document as blocker in JIRA +2. Make `target_entity_id()` return `None` +3. Implement service helper to resolve ID +4. Consider future API refactoring (out of scope for validator work) + +## Session Entity Reference + +The complete Session entity implementation is available for reference: + +- **Base classes**: `src/ai/backend/manager/services/session/base.py` +- **Actions**: `src/ai/backend/manager/services/session/actions/*.py` +- **Processors**: `src/ai/backend/manager/services/session/processors.py` +- **Service**: `src/ai/backend/manager/services/session/service.py` +- **PR**: https://github.com/lablup/backend.ai/pull/9624 + +## Checklist + +Use this checklist for each entity: + +### Analysis Phase +- [ ] Read BEP-1048 to understand entity's RBAC type +- [ ] List all actions and categorize (scope/single/batch/internal) +- [ ] Identify which actions need RBAC validation +- [ ] Check if REST API uses names vs UUIDs in URLs + +### Implementation Phase +- [ ] Create/update base classes in `{entity}/base.py` +- [ ] Update scope actions to inherit from `EntityScopeAction` +- [ ] Update single entity actions to inherit from `EntitySingleEntityAction` +- [ ] Implement `target_entity_id()` for all single entity actions +- [ ] Add service helper methods if needed (for name-based lookups) +- [ ] Update processors `__init__` to accept `permission_repository` +- [ ] Create validator instances in processors +- [ ] Connect validators to appropriate ActionProcessors +- [ ] Wire up permission_repository in main `Processors` class + +### Quality Phase +- [ ] Run `pants fmt ::` - no changes +- [ ] Run `pants fix ::` - no changes +- [ ] Run `pants lint --changed-since=origin/main` - all pass +- [ ] Run `pants check --changed-since=origin/main` - all pass +- [ ] Run `pants test --changed-since=origin/main` - all pass +- [ ] Review all changes manually + +### Documentation Phase +- [ ] Update entity README if needed +- [ ] Add docstrings to new/modified methods +- [ ] Document any REST API blockers in JIRA +- [ ] Update PR description with validator connection details + +## Related Issues + +- **BA-2946**: Parent issue (Apply RBAC validators to all entities) +- **BA-4865**: Validator connection work (Session - completed as reference) +- **BA-4617**: Infrastructure work (completed) + +## Questions? + +If you encounter patterns not covered here: +1. Review Session entity implementation (PR #9624) +2. Check existing validators in `actions/validators/rbac/` +3. Read BEP-1048 for RBAC design details +4. Consult `actions/README.md` for action architecture + +## Summary + +**Core Principle**: Every action that represents a user-initiated operation should have RBAC validation. The validator checks if the user has permission before allowing the action to proceed. + +**Three-Step Pattern**: +1. **Action Definition**: Inherit from correct base class, implement RBAC methods +2. **Service Support**: Add helper methods for ID resolution if needed +3. **Processor Wiring**: Connect validators to ActionProcessors + +Follow this guide carefully, use Session as reference, and run all quality checks before submitting. diff --git a/src/ai/backend/manager/api/rest/vfolder/handler.py b/src/ai/backend/manager/api/rest/vfolder/handler.py index f21435ca50e..4f0e9da4299 100644 --- a/src/ai/backend/manager/api/rest/vfolder/handler.py +++ b/src/ai/backend/manager/api/rest/vfolder/handler.py @@ -106,7 +106,6 @@ check_vfolder_status, resolve_vfolder_rows, ) -from ai.backend.manager.data.permission.types import ScopeType from ai.backend.manager.dto.context import ( ProcessorsCtx, RequestCtx, @@ -230,13 +229,6 @@ async def create( folder_host = params.folder_host unmanaged_path = params.unmanaged_path - if group_id_or_name is not None: - scope_type = ScopeType.PROJECT - scope_id = str(group_id_or_name) - else: - scope_type = ScopeType.USER - scope_id = str(ctx.user_uuid) - try: result = await processors_ctx.processors.vfolder.create_vfolder.wait_for_complete( CreateVFolderAction( @@ -252,8 +244,6 @@ async def create( user_uuid=ctx.user_uuid, user_role=user_role, creator_email=ctx.user_email, - _scope_type=scope_type, - _scope_id=scope_id, ) ) except (VFolderInvalidParameter, VFolderAlreadyExists) as e: @@ -314,18 +304,9 @@ async def list_folders( if params.owner_user_email else None, ) - group_id = params.group_id - if group_id is not None: - scope_type = ScopeType.PROJECT - scope_id = str(group_id) - else: - scope_type = ScopeType.USER - scope_id = str(owner_user_uuid) result = await processors_ctx.processors.vfolder.list_vfolder.wait_for_complete( ListVFolderAction( user_uuid=owner_user_uuid, - _scope_type=scope_type, - _scope_id=scope_id, ) ) items: list[VFolderItemField] = [] diff --git a/src/ai/backend/manager/api/vfolder.py b/src/ai/backend/manager/api/vfolder.py index 2477ef9ec25..a34cb610d8d 100644 --- a/src/ai/backend/manager/api/vfolder.py +++ b/src/ai/backend/manager/api/vfolder.py @@ -51,7 +51,6 @@ from ai.backend.manager.data.agent.types import AgentStatus from ai.backend.manager.data.kernel.types import KernelStatus from ai.backend.manager.data.model_serving.types import EndpointLifecycle -from ai.backend.manager.data.permission.types import ScopeType from ai.backend.manager.errors.api import InvalidAPIParameters from ai.backend.manager.errors.auth import InsufficientPrivilege from ai.backend.manager.errors.common import InternalServerError, ObjectNotFound @@ -447,13 +446,6 @@ async def create(request: web.Request, params: CreateRequestModel) -> web.Respon folder_host = params.folder_host unmanaged_path = params.unmanaged_path - if group_id_or_name is not None: - scope_type = ScopeType.PROJECT - scope_id = str(group_id_or_name) - else: - scope_type = ScopeType.USER - scope_id = str(user_uuid) - try: result = await root_ctx.processors.vfolder.create_vfolder.wait_for_complete( CreateVFolderAction( @@ -469,8 +461,6 @@ async def create(request: web.Request, params: CreateRequestModel) -> web.Respon user_uuid=user_uuid, user_role=user_role, creator_email=request["user"]["email"], - _scope_type=scope_type, - _scope_id=scope_id, ) ) except (VFolderInvalidParameter, VFolderAlreadyExists) as e: @@ -519,18 +509,9 @@ async def list_folders(request: web.Request, params: Any) -> web.Response: request["keypair"]["access_key"], ) owner_user_uuid, owner_user_role = await get_user_scopes(request, params) - group_id = params["group_id"] - if group_id is not None: - scope_type = ScopeType.PROJECT - scope_id = str(group_id) - else: - scope_type = ScopeType.USER - scope_id = str(owner_user_uuid) result = await root_ctx.processors.vfolder.list_vfolder.wait_for_complete( ListVFolderAction( user_uuid=owner_user_uuid, - _scope_type=scope_type, - _scope_id=scope_id, ) ) resp = [] diff --git a/src/ai/backend/manager/services/vfolder/actions/base.py b/src/ai/backend/manager/services/vfolder/actions/base.py index a186caf3286..78abed2ef8c 100644 --- a/src/ai/backend/manager/services/vfolder/actions/base.py +++ b/src/ai/backend/manager/services/vfolder/actions/base.py @@ -96,9 +96,6 @@ class CreateVFolderAction(VFolderScopeAction): usage_mode: VFolderUsageMode cloneable: bool - _scope_id: str - _scope_type: ScopeType - # User identifier # TODO: Distinguish between creator and owner user_uuid: uuid.UUID @@ -116,17 +113,17 @@ def operation_type(cls) -> ActionOperationType: @override def scope_type(self) -> ScopeType: - return self._scope_type + return ScopeType.USER @override def scope_id(self) -> str: - return self._scope_id + return str(self.user_uuid) @override def target_element(self) -> RBACElementRef: return RBACElementRef( - element_type=RBACElementType(self._scope_type.value), - element_id=self._scope_id, + element_type=RBACElementType.USER, + element_id=str(self.user_uuid), ) @@ -244,8 +241,6 @@ def target_entity_id(self) -> str: @dataclass class ListVFolderAction(VFolderScopeAction): user_uuid: uuid.UUID - _scope_type: ScopeType - _scope_id: str @override def entity_id(self) -> str | None: @@ -258,17 +253,17 @@ def operation_type(cls) -> ActionOperationType: @override def scope_type(self) -> ScopeType: - return self._scope_type + return ScopeType.USER @override def scope_id(self) -> str: - return self._scope_id + return str(self.user_uuid) @override def target_element(self) -> RBACElementRef: return RBACElementRef( - element_type=RBACElementType(self._scope_type.value), - element_id=self._scope_id, + element_type=RBACElementType.USER, + element_id=str(self.user_uuid), ) @@ -276,8 +271,6 @@ def target_element(self) -> RBACElementRef: class ListVFolderActionResult(VFolderScopeActionResult): user_uuid: uuid.UUID vfolders: list[tuple[VFolderBaseInfo, VFolderOwnershipInfo]] - _scope_type: ScopeType - _scope_id: str @override def entity_id(self) -> str | None: @@ -285,11 +278,11 @@ def entity_id(self) -> str | None: @override def scope_type(self) -> ScopeType: - return self._scope_type + return ScopeType.USER @override def scope_id(self) -> str: - return str(self._scope_id) + return str(self.user_uuid) @dataclass diff --git a/src/ai/backend/manager/services/vfolder/services/vfolder.py b/src/ai/backend/manager/services/vfolder/services/vfolder.py index 6a52cc52db0..65abac870e4 100644 --- a/src/ai/backend/manager/services/vfolder/services/vfolder.py +++ b/src/ai/backend/manager/services/vfolder/services/vfolder.py @@ -517,8 +517,6 @@ async def list(self, action: ListVFolderAction) -> ListVFolderActionResult: return ListVFolderActionResult( user_uuid=action.user_uuid, vfolders=vfolders, - _scope_type=action.scope_type(), - _scope_id=action.scope_id(), ) async def move_to_trash( From 8b582dd61a3eb17bbedd8ba85c7a78880db8e154 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Wed, 4 Mar 2026 23:46:04 +0900 Subject: [PATCH 2/8] changelog: add news fragment for PR #9628 --- changes/9628.enhance.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/9628.enhance.md diff --git a/changes/9628.enhance.md b/changes/9628.enhance.md new file mode 100644 index 00000000000..54ea8b0b7c3 --- /dev/null +++ b/changes/9628.enhance.md @@ -0,0 +1 @@ +Refactor VFolder scope actions to derive scope from user_uuid instead of storing redundant _scope_type/_scope_id fields From 8fb821c2f8cfbd7c6f9fb34256648b6f483cc40a Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Wed, 4 Mar 2026 23:48:23 +0900 Subject: [PATCH 3/8] chore: remove implementation guide (should not be in PR) --- ...946-RBAC-validator-implementation-guide.md | 584 ------------------ 1 file changed, 584 deletions(-) delete mode 100644 proposals/BA-2946-RBAC-validator-implementation-guide.md diff --git a/proposals/BA-2946-RBAC-validator-implementation-guide.md b/proposals/BA-2946-RBAC-validator-implementation-guide.md deleted file mode 100644 index e0238c03715..00000000000 --- a/proposals/BA-2946-RBAC-validator-implementation-guide.md +++ /dev/null @@ -1,584 +0,0 @@ -# BA-2946 RBAC Validator Implementation Guide - -**For AI Agents implementing RBAC validators on other entities** - -## Overview - -This guide explains how to apply RBAC action validators to Backend.AI entities following the BEP-1048 RBAC model. The Session entity implementation (completed) serves as the reference example. - -## Prerequisites - -### Required Knowledge - -1. **BEP-1048 RBAC Model**: Read `proposals/BEP-1048-RBAC-entity-relationship-model.md` - - Understand the 3-type model: guarded, auto, ref - - Know the entity relationship design - -2. **Action Architecture**: Read `src/ai/backend/manager/actions/README.md` - - BaseAction, BaseScopeAction, BaseSingleEntityAction - - ActionProcessor and validators - - ActionValidator interface - -3. **Validator Infrastructure**: Review existing validators - - `src/ai/backend/manager/actions/validators/rbac/scope.py` (ScopeActionRBACValidator) - - `src/ai/backend/manager/actions/validators/rbac/single_entity.py` (SingleEntityActionRBACValidator) - -### Verify Infrastructure - -Before starting, confirm these components exist: -- [ ] `ScopeActionRBACValidator` implemented -- [ ] `SingleEntityActionRBACValidator` implemented -- [ ] `PermissionControllerRepository` available -- [ ] Base action classes support RBAC methods - -## Implementation Steps - -### Step 1: Identify Entity Type and Actions - -**Determine the entity's RBAC type** from BEP-1048: -- **Guarded**: No automatic scope association (Session, Endpoint) -- **Auto**: Automatic scope association via DB (Image → ContainerRegistry, Agent → ResourceGroup) -- **Ref**: Reference-based association via DB (User → Group) - -**List all actions** for the entity and categorize them: - -| Category | Action Type | Description | Example | -|----------|-------------|-------------|---------| -| Scope | BaseScopeAction | Operations within a scope (domain/project) | create, search, list | -| Single Entity | BaseSingleEntityAction | Operations on a specific entity | get, update, delete, execute | -| Batch | BaseBatchAction | Operations on multiple entities | batch_delete, batch_update | -| Internal/Legacy | BaseAction | No RBAC validation needed | internal operations, legacy endpoints | - -**Session entity example**: -```python -# Scope actions (6) -- create_cluster, create_from_params, create_from_template -- match_sessions, search_kernels, search_sessions - -# Single entity actions (4) -- destroy_session, execute_session, get_session_info, modify_session - -# Internal/legacy (15) -- commit_session, complete, convert_session_to_image, ... -``` - -### Step 2: Review Existing Action Base Classes - -Check `src/ai/backend/manager/services/{entity}/base.py` for existing base classes. - -**Required base classes**: - -```python -@dataclass -class EntityScopeAction(BaseScopeAction): - """Base class for entity actions that operate within a scope (domain/project).""" - - @override - @classmethod - def entity_type(cls) -> EntityType: - return EntityType.YOUR_ENTITY - -@dataclass -class EntitySingleEntityAction(BaseSingleEntityAction): - """Base class for entity actions that operate on a single entity.""" - - @override - @classmethod - def entity_type(cls) -> EntityType: - return EntityType.YOUR_ENTITY -``` - -**If they don't exist**: Create them in `base.py` (see Session example below). - -**If they exist but incomplete**: Ensure they implement required methods: -- `entity_type()` - returns the EntityType enum -- For ScopeAction: `scope_type()`, `scope_id()`, `target_element()` -- For SingleEntityAction: `target_entity_id()` - -### Step 3: Update Action Definitions - -For each action file in `src/ai/backend/manager/services/{entity}/actions/`: - -#### 3.1 Scope Actions - -Ensure the action inherits from `EntityScopeAction`: - -```python -@dataclass -class CreateEntityAction(EntityScopeAction): - user_id: UserID # Required for USER scope - # other fields... - - @override - def scope_type(self) -> ScopeType: - return ScopeType.USER # Almost always USER, never GLOBAL - - @override - def scope_id(self) -> str: - return str(self.user_id) - - @override - def target_element(self) -> str: - return f"user:{self.user_id}" -``` - -**CRITICAL RULES**: -- ❌ NEVER use `ScopeType.GLOBAL` - use `ScopeType.USER` with user_id -- ✅ Always derive scope from business logic (user_id, domain_name, project_id) -- ❌ Do NOT add `_scope_type`, `_scope_id` fields - -#### 3.2 Single Entity Actions - -Ensure the action inherits from `EntitySingleEntityAction`: - -```python -@dataclass -class UpdateEntityAction(EntitySingleEntityAction): - entity_id: EntityID # Explicit entity ID - user_id: UserID - # other fields... - - @override - def target_entity_id(self) -> str: - return str(self.entity_id) -``` - -**KEY PATTERNS**: - -**Pattern 1: ID available at action creation (GraphQL)** -```python -@dataclass -class ModifyEntityAction(EntitySingleEntityAction): - entity_id: uuid.UUID # Not nullable, explicitly provided - - @override - def target_entity_id(self) -> str: - return str(self.entity_id) -``` - -**Pattern 2: Name-based lookup (REST API)** -```python -@dataclass -class UpdateEntityAction(EntitySingleEntityAction): - entity_name: str # Only name available from URL - owner_access_key: AccessKey - - @override - def target_entity_id(self) -> str | None: - return None # Will be resolved by validator via service method -``` - -**Pattern 3: Complex ID resolution** -```python -@dataclass -class DeleteEntityAction(EntitySingleEntityAction): - entity_name: str - user_uuid: uuid.UUID - - @override - def target_entity_id(self) -> str | None: - return None # Validator will query DB to resolve entity_id -``` - -**IMPORTANT**: If `target_entity_id()` returns `None`, the SingleEntityActionRBACValidator will: -1. Call `service.get_entity_for_permission_check(action)` -2. Extract `entity_id` from the result -3. Use that ID for permission checking - -### Step 4: Update Service Methods (if needed) - -If any action returns `None` from `target_entity_id()`, ensure the service class implements a helper method: - -```python -class EntityService: - async def get_entity_for_permission_check( - self, action: GetEntityAction - ) -> EntityData: - """ - Resolve entity by name/key for permission checking. - - This method is called by SingleEntityActionRBACValidator when - action.target_entity_id() returns None. - """ - # Query DB to find entity by name/key - # Return minimal EntityData with entity_id populated - ... -``` - -**Session example**: Most session actions use session_name (REST API), so many return `None` from `target_entity_id()`. - -### Step 5: Connect Validators to Processors - -Update `src/ai/backend/manager/services/{entity}/processors.py`: - -#### 5.1 Add imports - -```python -from typing import cast, override - -from ai.backend.manager.actions.validator.base import ActionValidator -from ai.backend.manager.actions.validators.rbac.scope import ScopeActionRBACValidator -from ai.backend.manager.actions.validators.rbac.single_entity import SingleEntityActionRBACValidator -from ai.backend.manager.repositories.permission_controller.repository import ( - PermissionControllerRepository, -) -``` - -#### 5.2 Update `__init__` signature - -```python -def __init__( - self, - service: EntityService, - action_monitors: list[ActionMonitor], - permission_repository: PermissionControllerRepository, # Add this -) -> None: -``` - -#### 5.3 Create validator instances - -```python -# Create RBAC validators -scope_validator = ScopeActionRBACValidator(permission_repository) -single_entity_validator = SingleEntityActionRBACValidator(permission_repository) -``` - -#### 5.4 Reorganize processor initialization - -Group processors into three sections with clear comments: - -```python -# Actions without RBAC validation (internal/legacy) -self.internal_action = ActionProcessor(service.internal_action, action_monitors) -self.legacy_action = ActionProcessor(service.legacy_action, action_monitors) - -# Scope actions with RBAC validation -self.create_entity = ActionProcessor( - service.create_entity, - action_monitors, - validators=[cast(ActionValidator, scope_validator)], -) -self.search_entities = ActionProcessor( - service.search, - action_monitors, - validators=[cast(ActionValidator, scope_validator)] -) - -# Single entity actions with RBAC validation -self.get_entity = ActionProcessor( - service.get_entity, - action_monitors, - validators=[cast(ActionValidator, single_entity_validator)], -) -self.update_entity = ActionProcessor( - service.update_entity, - action_monitors, - validators=[cast(ActionValidator, single_entity_validator)], -) -``` - -**Why cast()?**: `ActionProcessor` expects `ActionValidator` protocol, but validators are typed as their concrete classes. The cast satisfies the type checker. - -### Step 6: Wire up in main Processors - -Update `src/ai/backend/manager/services/processors.py`: - -```python -entity_processors = EntityProcessors( - services.entity, - action_monitors, - args.service_args.repositories.permission_controller.repository, # Pass this -) -``` - -### Step 7: Quality Checks - -Run these commands and fix all errors: - -```bash -# Format -pants fmt :: - -# Auto-fix linting issues -pants fix :: - -# Lint changed files -pants lint --changed-since=origin/main - -# Type check changed files -pants check --changed-since=origin/main - -# Run tests -pants test --changed-since=origin/main -``` - -**CRITICAL**: Fix all errors - never use `# noqa` or `# type: ignore`. - -## Common Patterns - -### Pattern 1: User-Scoped Actions (Most Common) - -```python -@dataclass -class CreateSessionAction(SessionScopeAction): - user_id: UserID - domain_name: str - project_name: str | None - - @override - def scope_type(self) -> ScopeType: - return ScopeType.USER # Always USER for user-initiated actions - - @override - def scope_id(self) -> str: - return str(self.user_id) - - @override - def target_element(self) -> str: - return f"user:{self.user_id}" -``` - -### Pattern 2: GraphQL Actions with UUID - -```python -@dataclass -class ModifySessionAction(SessionSingleEntityAction): - session_id: SessionId # uuid.UUID, not nullable - - @override - def target_entity_id(self) -> str: - return str(self.session_id) -``` - -**GraphQL handler**: -```python -_, raw_session_id = cast(ResolvedGlobalID, input["id"]) -session_id = SessionId(uuid.UUID(raw_session_id)) - -result = await processors.session.modify_session.wait_for_complete( - ModifySessionAction( - session_id=session_id, # Explicit UUID from Global ID - updater=Updater(...), - ) -) -``` - -### Pattern 3: REST API Actions with Name - -```python -@dataclass -class RestartSessionAction(SessionSingleEntityAction): - session_name: str # From URL path /{session_name} - owner_access_key: AccessKey - - @override - def target_entity_id(self) -> str | None: - return None # Validator will resolve via service -``` - -**REST handler**: -```python -@server.delete("/{session_name}") -async def restart(request: web.Request, session_name: str) -> web.Response: - result = await processors.session.restart_session.wait_for_complete( - RestartSessionAction( - session_name=session_name, # Only name from URL - owner_access_key=request["access_key"], - ) - ) -``` - -**Service helper**: -```python -async def get_session_for_permission_check( - self, action: RestartSessionAction -) -> SessionData: - # Query DB by session_name to get session_id - ... -``` - -### Pattern 4: Batch Actions - -```python -@dataclass -class BatchDeleteEntitiesAction(EntityBatchAction): - entity_ids: list[uuid.UUID] - user_id: UserID - - @override - def target_entity_ids(self) -> list[str]: - return [str(eid) for eid in self.entity_ids] -``` - -**Note**: Batch action validators may need custom implementation - check if `BatchActionRBACValidator` exists. - -## Testing - -### Unit Tests - -Create tests in `tests/unit/manager/services/{entity}/actions/`: - -```python -async def test_create_entity_action_rbac_validation(): - """Test that ScopeActionRBACValidator is applied to create action.""" - # Arrange - mock_repository = Mock(spec=PermissionControllerRepository) - mock_repository.check_scope_permission.return_value = True - - processors = EntityProcessors( - service=Mock(spec=EntityService), - action_monitors=[], - permission_repository=mock_repository, - ) - - # Act - action = CreateEntityAction(user_id=UserID(uuid.uuid4()), ...) - await processors.create_entity.wait_for_complete(action) - - # Assert - mock_repository.check_scope_permission.assert_called_once() -``` - -### Integration Tests - -Add tests in `tests/integration/`: - -```python -async def test_create_entity_without_permission( - api_client: TestClient, - user_without_permissions: UserFixture, -): - """Test that creating entity without permission fails with 403.""" - response = await api_client.post( - "/entities", - json={"name": "test"}, - headers={"Authorization": f"Bearer {user_without_permissions.token}"}, - ) - assert response.status == 403 -``` - -## Troubleshooting - -### Issue 1: Type Check Error - "Missing named argument" - -**Error**: -``` -error: Missing named argument "entity_id" for "GetEntityAction" -``` - -**Cause**: Action requires `entity_id` but handler only has `entity_name`. - -**Solutions**: -1. Make `target_entity_id()` return `None` and implement service helper method -2. If ID is always available, ensure it's passed from handler - -### Issue 2: Validator Not Called - -**Symptom**: Permission checks not happening. - -**Debug steps**: -1. Verify validator is passed to `ActionProcessor` constructor -2. Check `cast(ActionValidator, validator)` is used -3. Ensure action inherits from correct base class (ScopeAction/SingleEntityAction) -4. Confirm base class implements required RBAC methods - -### Issue 3: Scope Type is GLOBAL - -**Error**: Actions use `ScopeType.GLOBAL` instead of `ScopeType.USER`. - -**Fix**: Change all scope actions to use `ScopeType.USER` with `user_id`: - -```python -# ❌ WRONG -def scope_type(self) -> ScopeType: - return ScopeType.GLOBAL - -# ✅ CORRECT -def scope_type(self) -> ScopeType: - return ScopeType.USER - -def scope_id(self) -> str: - return str(self.user_id) -``` - -### Issue 4: REST API Handler Breaks - -**Error**: Handler can't provide required `entity_id` field. - -**Cause**: REST API endpoints use URL patterns like `/{entity_name}`, not `/{entity_id}`. - -**Solution**: This is an architectural limitation: -1. Document as blocker in JIRA -2. Make `target_entity_id()` return `None` -3. Implement service helper to resolve ID -4. Consider future API refactoring (out of scope for validator work) - -## Session Entity Reference - -The complete Session entity implementation is available for reference: - -- **Base classes**: `src/ai/backend/manager/services/session/base.py` -- **Actions**: `src/ai/backend/manager/services/session/actions/*.py` -- **Processors**: `src/ai/backend/manager/services/session/processors.py` -- **Service**: `src/ai/backend/manager/services/session/service.py` -- **PR**: https://github.com/lablup/backend.ai/pull/9624 - -## Checklist - -Use this checklist for each entity: - -### Analysis Phase -- [ ] Read BEP-1048 to understand entity's RBAC type -- [ ] List all actions and categorize (scope/single/batch/internal) -- [ ] Identify which actions need RBAC validation -- [ ] Check if REST API uses names vs UUIDs in URLs - -### Implementation Phase -- [ ] Create/update base classes in `{entity}/base.py` -- [ ] Update scope actions to inherit from `EntityScopeAction` -- [ ] Update single entity actions to inherit from `EntitySingleEntityAction` -- [ ] Implement `target_entity_id()` for all single entity actions -- [ ] Add service helper methods if needed (for name-based lookups) -- [ ] Update processors `__init__` to accept `permission_repository` -- [ ] Create validator instances in processors -- [ ] Connect validators to appropriate ActionProcessors -- [ ] Wire up permission_repository in main `Processors` class - -### Quality Phase -- [ ] Run `pants fmt ::` - no changes -- [ ] Run `pants fix ::` - no changes -- [ ] Run `pants lint --changed-since=origin/main` - all pass -- [ ] Run `pants check --changed-since=origin/main` - all pass -- [ ] Run `pants test --changed-since=origin/main` - all pass -- [ ] Review all changes manually - -### Documentation Phase -- [ ] Update entity README if needed -- [ ] Add docstrings to new/modified methods -- [ ] Document any REST API blockers in JIRA -- [ ] Update PR description with validator connection details - -## Related Issues - -- **BA-2946**: Parent issue (Apply RBAC validators to all entities) -- **BA-4865**: Validator connection work (Session - completed as reference) -- **BA-4617**: Infrastructure work (completed) - -## Questions? - -If you encounter patterns not covered here: -1. Review Session entity implementation (PR #9624) -2. Check existing validators in `actions/validators/rbac/` -3. Read BEP-1048 for RBAC design details -4. Consult `actions/README.md` for action architecture - -## Summary - -**Core Principle**: Every action that represents a user-initiated operation should have RBAC validation. The validator checks if the user has permission before allowing the action to proceed. - -**Three-Step Pattern**: -1. **Action Definition**: Inherit from correct base class, implement RBAC methods -2. **Service Support**: Add helper methods for ID resolution if needed -3. **Processor Wiring**: Connect validators to ActionProcessors - -Follow this guide carefully, use Session as reference, and run all quality checks before submitting. From 72a07277129da0ded0803d55f26c56869aa58785 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Wed, 4 Mar 2026 23:56:45 +0900 Subject: [PATCH 4/8] feat(BA-2274): connect RBAC validators to VFolderProcessors Apply RBAC permission validation to VFolder scope and single-entity actions: Changes: - Add ScopeActionRBACValidator and SingleEntityActionRBACValidator to VFolderProcessors - Add permission_repository parameter to VFolderProcessors.__init__ - Connect validators to ActionProcessor instances: * Scope actions (2): create_vfolder, list_vfolder * Single entity actions (8): get_vfolder, update_vfolder_attribute, move_to_trash_vfolder, restore_vfolder_from_trash, delete_forever_vfolder, purge_vfolder, force_delete_vfolder, clone_vfolder - Update Processors.create() to pass permission_repository to VFolderProcessors - Replace ScopeActionProcessor/SingleEntityActionProcessor with ActionProcessor Implementation follows the pattern established in BA-2946 session RBAC work: - Three-tier organization: RBAC scope actions, RBAC single-entity actions, internal/legacy actions without validation - Validators check USER scope permissions before allowing operations - Storage ops and internal actions remain without RBAC validation Benefits: - Enforces permission checks at processor level - Prevents unauthorized vfolder access/modification - Consistent with session RBAC implementation - Separates permission logic from business logic Related: BA-2946, BA-2946-RBAC-validator-implementation-guide.md Co-Authored-By: Claude Sonnet 4.5 --- src/ai/backend/manager/services/processors.py | 6 +- .../services/vfolder/processors/vfolder.py | 114 ++++++++++++------ 2 files changed, 84 insertions(+), 36 deletions(-) diff --git a/src/ai/backend/manager/services/processors.py b/src/ai/backend/manager/services/processors.py index bd8cc379adb..551995b1218 100644 --- a/src/ai/backend/manager/services/processors.py +++ b/src/ai/backend/manager/services/processors.py @@ -549,7 +549,11 @@ def create(cls, args: ProcessorArgs, action_monitors: list[ActionMonitor]) -> Se container_registry_processors = ContainerRegistryProcessors( services.container_registry, action_monitors ) - vfolder_processors = VFolderProcessors(services.vfolder, action_monitors) + vfolder_processors = VFolderProcessors( + services.vfolder, + action_monitors, + args.service_args.repositories.permission_controller.repository, + ) vfolder_file_processors = VFolderFileProcessors(services.vfolder_file, action_monitors) vfolder_invite_processors = VFolderInviteProcessors( services.vfolder_invite, action_monitors diff --git a/src/ai/backend/manager/services/vfolder/processors/vfolder.py b/src/ai/backend/manager/services/vfolder/processors/vfolder.py index ac672725df9..cc5eb7b516e 100644 --- a/src/ai/backend/manager/services/vfolder/processors/vfolder.py +++ b/src/ai/backend/manager/services/vfolder/processors/vfolder.py @@ -1,10 +1,14 @@ -from typing import override +from typing import cast, override from ai.backend.manager.actions.monitors.monitor import ActionMonitor from ai.backend.manager.actions.processor import ActionProcessor -from ai.backend.manager.actions.processor.scope import ScopeActionProcessor -from ai.backend.manager.actions.processor.single_entity import SingleEntityActionProcessor from ai.backend.manager.actions.types import AbstractProcessorPackage, ActionSpec +from ai.backend.manager.actions.validator.base import ActionValidator +from ai.backend.manager.actions.validators.rbac.scope import ScopeActionRBACValidator +from ai.backend.manager.actions.validators.rbac.single_entity import SingleEntityActionRBACValidator +from ai.backend.manager.repositories.permission_controller.repository import ( + PermissionControllerRepository, +) from ai.backend.manager.services.vfolder.actions.base import ( CloneVFolderAction, CloneVFolderActionResult, @@ -61,27 +65,23 @@ class VFolderProcessors(AbstractProcessorPackage): - create_vfolder: ScopeActionProcessor[CreateVFolderAction, CreateVFolderActionResult] - get_vfolder: SingleEntityActionProcessor[GetVFolderAction, GetVFolderActionResult] - list_vfolder: ScopeActionProcessor[ListVFolderAction, ListVFolderActionResult] - update_vfolder_attribute: SingleEntityActionProcessor[ + create_vfolder: ActionProcessor[CreateVFolderAction, CreateVFolderActionResult] + get_vfolder: ActionProcessor[GetVFolderAction, GetVFolderActionResult] + list_vfolder: ActionProcessor[ListVFolderAction, ListVFolderActionResult] + update_vfolder_attribute: ActionProcessor[ UpdateVFolderAttributeAction, UpdateVFolderAttributeActionResult ] - move_to_trash_vfolder: SingleEntityActionProcessor[ - MoveToTrashVFolderAction, MoveToTrashVFolderActionResult - ] - restore_vfolder_from_trash: SingleEntityActionProcessor[ + move_to_trash_vfolder: ActionProcessor[MoveToTrashVFolderAction, MoveToTrashVFolderActionResult] + restore_vfolder_from_trash: ActionProcessor[ RestoreVFolderFromTrashAction, RestoreVFolderFromTrashActionResult ] - delete_forever_vfolder: SingleEntityActionProcessor[ + delete_forever_vfolder: ActionProcessor[ DeleteForeverVFolderAction, DeleteForeverVFolderActionResult ] - purge_vfolder: SingleEntityActionProcessor[PurgeVFolderAction, PurgeVFolderActionResult] - force_delete_vfolder: SingleEntityActionProcessor[ - ForceDeleteVFolderAction, ForceDeleteVFolderActionResult - ] - clone_vfolder: SingleEntityActionProcessor[CloneVFolderAction, CloneVFolderActionResult] - get_task_logs: SingleEntityActionProcessor[GetTaskLogsAction, GetTaskLogsActionResult] + purge_vfolder: ActionProcessor[PurgeVFolderAction, PurgeVFolderActionResult] + force_delete_vfolder: ActionProcessor[ForceDeleteVFolderAction, ForceDeleteVFolderActionResult] + clone_vfolder: ActionProcessor[CloneVFolderAction, CloneVFolderActionResult] + get_task_logs: ActionProcessor[GetTaskLogsAction, GetTaskLogsActionResult] list_allowed_types: ActionProcessor[ListAllowedTypesAction, ListAllowedTypesActionResult] list_all_hosts: ActionProcessor[ListAllHostsAction, ListAllHostsActionResult] get_volume_perf_metric: ActionProcessor[ @@ -100,28 +100,72 @@ class VFolderProcessors(AbstractProcessorPackage): umount_host: ActionProcessor[UmountHostAction, UmountHostActionResult] get_fstab_contents: ActionProcessor[GetFstabContentsAction, GetFstabContentsActionResult] - def __init__(self, service: VFolderService, action_monitors: list[ActionMonitor]) -> None: - self.create_vfolder = ScopeActionProcessor(service.create, action_monitors) - self.get_vfolder = SingleEntityActionProcessor(service.get, action_monitors) - self.list_vfolder = ScopeActionProcessor(service.list, action_monitors) - self.update_vfolder_attribute = SingleEntityActionProcessor( - service.update_attribute, action_monitors + def __init__( + self, + service: VFolderService, + action_monitors: list[ActionMonitor], + permission_repository: PermissionControllerRepository, + ) -> None: + # Create RBAC validators + scope_validator = ScopeActionRBACValidator(permission_repository) + single_entity_validator = SingleEntityActionRBACValidator(permission_repository) + + # Scope actions with RBAC validation + self.create_vfolder = ActionProcessor( + service.create, + action_monitors, + validators=[cast(ActionValidator, scope_validator)], + ) + self.list_vfolder = ActionProcessor( + service.list, + action_monitors, + validators=[cast(ActionValidator, scope_validator)], ) - self.move_to_trash_vfolder = SingleEntityActionProcessor( - service.move_to_trash, action_monitors + + # Single entity actions with RBAC validation + self.get_vfolder = ActionProcessor( + service.get, + action_monitors, + validators=[cast(ActionValidator, single_entity_validator)], ) - self.restore_vfolder_from_trash = SingleEntityActionProcessor( - service.restore, action_monitors + self.update_vfolder_attribute = ActionProcessor( + service.update_attribute, + action_monitors, + validators=[cast(ActionValidator, single_entity_validator)], ) - self.delete_forever_vfolder = SingleEntityActionProcessor( - service.delete_forever, action_monitors + self.move_to_trash_vfolder = ActionProcessor( + service.move_to_trash, + action_monitors, + validators=[cast(ActionValidator, single_entity_validator)], ) - self.purge_vfolder = SingleEntityActionProcessor(service.purge, action_monitors) - self.force_delete_vfolder = SingleEntityActionProcessor( - service.force_delete, action_monitors + self.restore_vfolder_from_trash = ActionProcessor( + service.restore, + action_monitors, + validators=[cast(ActionValidator, single_entity_validator)], ) - self.clone_vfolder = SingleEntityActionProcessor(service.clone, action_monitors) - self.get_task_logs = SingleEntityActionProcessor(service.get_task_logs, action_monitors) + self.delete_forever_vfolder = ActionProcessor( + service.delete_forever, + action_monitors, + validators=[cast(ActionValidator, single_entity_validator)], + ) + self.purge_vfolder = ActionProcessor( + service.purge, + action_monitors, + validators=[cast(ActionValidator, single_entity_validator)], + ) + self.force_delete_vfolder = ActionProcessor( + service.force_delete, + action_monitors, + validators=[cast(ActionValidator, single_entity_validator)], + ) + self.clone_vfolder = ActionProcessor( + service.clone, + action_monitors, + validators=[cast(ActionValidator, single_entity_validator)], + ) + + # Actions without RBAC validation (internal/legacy/storage ops) + self.get_task_logs = ActionProcessor(service.get_task_logs, action_monitors) self.list_allowed_types = ActionProcessor(service.list_allowed_types, action_monitors) self.list_all_hosts = ActionProcessor(service.list_all_hosts, action_monitors) self.get_volume_perf_metric = ActionProcessor( From 0e3f695a17c84f4b9e095a91167a0740504d7c8f Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Wed, 4 Mar 2026 23:58:54 +0900 Subject: [PATCH 5/8] changelog: add feature news fragment for PR #9628 --- changes/9628.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/9628.feature.md diff --git a/changes/9628.feature.md b/changes/9628.feature.md new file mode 100644 index 00000000000..386b5c0f3bd --- /dev/null +++ b/changes/9628.feature.md @@ -0,0 +1 @@ +Apply RBAC permission validation to VFolder operations (create, get, list, update, delete, clone) From 8262a5ff3f7b9628ccc1d8b04b09d6190d85a1a9 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Thu, 5 Mar 2026 00:00:43 +0900 Subject: [PATCH 6/8] chore: remove duplicate changelog file --- changes/9628.enhance.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 changes/9628.enhance.md diff --git a/changes/9628.enhance.md b/changes/9628.enhance.md deleted file mode 100644 index 54ea8b0b7c3..00000000000 --- a/changes/9628.enhance.md +++ /dev/null @@ -1 +0,0 @@ -Refactor VFolder scope actions to derive scope from user_uuid instead of storing redundant _scope_type/_scope_id fields From 93ff53e3483d7b40baa29b51ba219eec2b578773 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Thu, 5 Mar 2026 00:05:27 +0900 Subject: [PATCH 7/8] Revert "refactor(BA-2274): derive scope from business logic, remove _scope_type/_scope_id fields" This reverts commit 37f4ec8320e52240e2f63ba366ce65c814ff0833. --- .../manager/api/rest/vfolder/handler.py | 19 +++++++++++++ src/ai/backend/manager/api/vfolder.py | 19 +++++++++++++ .../manager/services/vfolder/actions/base.py | 27 ++++++++++++------- .../services/vfolder/services/vfolder.py | 2 ++ 4 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/ai/backend/manager/api/rest/vfolder/handler.py b/src/ai/backend/manager/api/rest/vfolder/handler.py index 4f0e9da4299..f21435ca50e 100644 --- a/src/ai/backend/manager/api/rest/vfolder/handler.py +++ b/src/ai/backend/manager/api/rest/vfolder/handler.py @@ -106,6 +106,7 @@ check_vfolder_status, resolve_vfolder_rows, ) +from ai.backend.manager.data.permission.types import ScopeType from ai.backend.manager.dto.context import ( ProcessorsCtx, RequestCtx, @@ -229,6 +230,13 @@ async def create( folder_host = params.folder_host unmanaged_path = params.unmanaged_path + if group_id_or_name is not None: + scope_type = ScopeType.PROJECT + scope_id = str(group_id_or_name) + else: + scope_type = ScopeType.USER + scope_id = str(ctx.user_uuid) + try: result = await processors_ctx.processors.vfolder.create_vfolder.wait_for_complete( CreateVFolderAction( @@ -244,6 +252,8 @@ async def create( user_uuid=ctx.user_uuid, user_role=user_role, creator_email=ctx.user_email, + _scope_type=scope_type, + _scope_id=scope_id, ) ) except (VFolderInvalidParameter, VFolderAlreadyExists) as e: @@ -304,9 +314,18 @@ async def list_folders( if params.owner_user_email else None, ) + group_id = params.group_id + if group_id is not None: + scope_type = ScopeType.PROJECT + scope_id = str(group_id) + else: + scope_type = ScopeType.USER + scope_id = str(owner_user_uuid) result = await processors_ctx.processors.vfolder.list_vfolder.wait_for_complete( ListVFolderAction( user_uuid=owner_user_uuid, + _scope_type=scope_type, + _scope_id=scope_id, ) ) items: list[VFolderItemField] = [] diff --git a/src/ai/backend/manager/api/vfolder.py b/src/ai/backend/manager/api/vfolder.py index a34cb610d8d..2477ef9ec25 100644 --- a/src/ai/backend/manager/api/vfolder.py +++ b/src/ai/backend/manager/api/vfolder.py @@ -51,6 +51,7 @@ from ai.backend.manager.data.agent.types import AgentStatus from ai.backend.manager.data.kernel.types import KernelStatus from ai.backend.manager.data.model_serving.types import EndpointLifecycle +from ai.backend.manager.data.permission.types import ScopeType from ai.backend.manager.errors.api import InvalidAPIParameters from ai.backend.manager.errors.auth import InsufficientPrivilege from ai.backend.manager.errors.common import InternalServerError, ObjectNotFound @@ -446,6 +447,13 @@ async def create(request: web.Request, params: CreateRequestModel) -> web.Respon folder_host = params.folder_host unmanaged_path = params.unmanaged_path + if group_id_or_name is not None: + scope_type = ScopeType.PROJECT + scope_id = str(group_id_or_name) + else: + scope_type = ScopeType.USER + scope_id = str(user_uuid) + try: result = await root_ctx.processors.vfolder.create_vfolder.wait_for_complete( CreateVFolderAction( @@ -461,6 +469,8 @@ async def create(request: web.Request, params: CreateRequestModel) -> web.Respon user_uuid=user_uuid, user_role=user_role, creator_email=request["user"]["email"], + _scope_type=scope_type, + _scope_id=scope_id, ) ) except (VFolderInvalidParameter, VFolderAlreadyExists) as e: @@ -509,9 +519,18 @@ async def list_folders(request: web.Request, params: Any) -> web.Response: request["keypair"]["access_key"], ) owner_user_uuid, owner_user_role = await get_user_scopes(request, params) + group_id = params["group_id"] + if group_id is not None: + scope_type = ScopeType.PROJECT + scope_id = str(group_id) + else: + scope_type = ScopeType.USER + scope_id = str(owner_user_uuid) result = await root_ctx.processors.vfolder.list_vfolder.wait_for_complete( ListVFolderAction( user_uuid=owner_user_uuid, + _scope_type=scope_type, + _scope_id=scope_id, ) ) resp = [] diff --git a/src/ai/backend/manager/services/vfolder/actions/base.py b/src/ai/backend/manager/services/vfolder/actions/base.py index 78abed2ef8c..a186caf3286 100644 --- a/src/ai/backend/manager/services/vfolder/actions/base.py +++ b/src/ai/backend/manager/services/vfolder/actions/base.py @@ -96,6 +96,9 @@ class CreateVFolderAction(VFolderScopeAction): usage_mode: VFolderUsageMode cloneable: bool + _scope_id: str + _scope_type: ScopeType + # User identifier # TODO: Distinguish between creator and owner user_uuid: uuid.UUID @@ -113,17 +116,17 @@ def operation_type(cls) -> ActionOperationType: @override def scope_type(self) -> ScopeType: - return ScopeType.USER + return self._scope_type @override def scope_id(self) -> str: - return str(self.user_uuid) + return self._scope_id @override def target_element(self) -> RBACElementRef: return RBACElementRef( - element_type=RBACElementType.USER, - element_id=str(self.user_uuid), + element_type=RBACElementType(self._scope_type.value), + element_id=self._scope_id, ) @@ -241,6 +244,8 @@ def target_entity_id(self) -> str: @dataclass class ListVFolderAction(VFolderScopeAction): user_uuid: uuid.UUID + _scope_type: ScopeType + _scope_id: str @override def entity_id(self) -> str | None: @@ -253,17 +258,17 @@ def operation_type(cls) -> ActionOperationType: @override def scope_type(self) -> ScopeType: - return ScopeType.USER + return self._scope_type @override def scope_id(self) -> str: - return str(self.user_uuid) + return self._scope_id @override def target_element(self) -> RBACElementRef: return RBACElementRef( - element_type=RBACElementType.USER, - element_id=str(self.user_uuid), + element_type=RBACElementType(self._scope_type.value), + element_id=self._scope_id, ) @@ -271,6 +276,8 @@ def target_element(self) -> RBACElementRef: class ListVFolderActionResult(VFolderScopeActionResult): user_uuid: uuid.UUID vfolders: list[tuple[VFolderBaseInfo, VFolderOwnershipInfo]] + _scope_type: ScopeType + _scope_id: str @override def entity_id(self) -> str | None: @@ -278,11 +285,11 @@ def entity_id(self) -> str | None: @override def scope_type(self) -> ScopeType: - return ScopeType.USER + return self._scope_type @override def scope_id(self) -> str: - return str(self.user_uuid) + return str(self._scope_id) @dataclass diff --git a/src/ai/backend/manager/services/vfolder/services/vfolder.py b/src/ai/backend/manager/services/vfolder/services/vfolder.py index 65abac870e4..6a52cc52db0 100644 --- a/src/ai/backend/manager/services/vfolder/services/vfolder.py +++ b/src/ai/backend/manager/services/vfolder/services/vfolder.py @@ -517,6 +517,8 @@ async def list(self, action: ListVFolderAction) -> ListVFolderActionResult: return ListVFolderActionResult( user_uuid=action.user_uuid, vfolders=vfolders, + _scope_type=action.scope_type(), + _scope_id=action.scope_id(), ) async def move_to_trash( From 1029fb3697d0cecbd463d74380f35345514e9666 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Thu, 5 Mar 2026 00:33:41 +0900 Subject: [PATCH 8/8] feat(BA-4866): extract RBACValidators injection to dependency layer VFolderProcessors previously created RBAC validators internally from PermissionControllerRepository, violating dependency injection principles. Changes: - Create RBACValidators dataclass in rbac/__init__.py - Update VFolderProcessors to accept RBACValidators via constructor - Add rbac_validators field to ProcessorArgs - Inject validators from server.py and composer.py This follows the same pattern as action_monitors injection, moving validator instantiation to the dependency injection layer. Co-Authored-By: Claude Sonnet 4.5 --- .../manager/actions/validators/rbac/__init__.py | 10 ++++++++++ .../manager/dependencies/processing/composer.py | 13 +++++++++++++ .../manager/dependencies/processing/processors.py | 7 ++++++- src/ai/backend/manager/server.py | 13 +++++++++++++ src/ai/backend/manager/services/processors.py | 4 +++- .../manager/services/vfolder/processors/vfolder.py | 14 +++++--------- 6 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/ai/backend/manager/actions/validators/rbac/__init__.py b/src/ai/backend/manager/actions/validators/rbac/__init__.py index e69de29bb2d..a60ea87d9a8 100644 --- a/src/ai/backend/manager/actions/validators/rbac/__init__.py +++ b/src/ai/backend/manager/actions/validators/rbac/__init__.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from ai.backend.manager.actions.validators.rbac.scope import ScopeActionRBACValidator +from ai.backend.manager.actions.validators.rbac.single_entity import SingleEntityActionRBACValidator + + +@dataclass +class RBACValidators: + scope: ScopeActionRBACValidator + single_entity: SingleEntityActionRBACValidator diff --git a/src/ai/backend/manager/dependencies/processing/composer.py b/src/ai/backend/manager/dependencies/processing/composer.py index 9b88a3d5967..4e7c3b0c6f0 100644 --- a/src/ai/backend/manager/dependencies/processing/composer.py +++ b/src/ai/backend/manager/dependencies/processing/composer.py @@ -27,6 +27,9 @@ from ai.backend.manager.actions.monitors.audit_log import AuditLogMonitor from ai.backend.manager.actions.monitors.prometheus import PrometheusMonitor from ai.backend.manager.actions.monitors.reporter import ReporterMonitor +from ai.backend.manager.actions.validators.rbac import RBACValidators +from ai.backend.manager.actions.validators.rbac.scope import ScopeActionRBACValidator +from ai.backend.manager.actions.validators.rbac.single_entity import SingleEntityActionRBACValidator from ai.backend.manager.agent_cache import AgentRPCCache from ai.backend.manager.clients.agent.pool import AgentClientPool from ai.backend.manager.clients.appproxy.client import AppProxyClientPool @@ -203,6 +206,15 @@ async def compose( prometheus_monitor = PrometheusMonitor() audit_log_monitor = AuditLogMonitor(setup_input.repositories.audit_log.repository) + rbac_validators = RBACValidators( + scope=ScopeActionRBACValidator( + setup_input.repositories.permission_controller.repository, + ), + single_entity=SingleEntityActionRBACValidator( + setup_input.repositories.permission_controller.repository, + ), + ) + service_args = ServiceArgs( db=setup_input.db, repositories=setup_input.repositories, @@ -236,6 +248,7 @@ async def compose( ProcessorsProviderInput( service_args=service_args, action_monitors=[reporter_monitor, prometheus_monitor, audit_log_monitor], + rbac_validators=rbac_validators, ), ) diff --git a/src/ai/backend/manager/dependencies/processing/processors.py b/src/ai/backend/manager/dependencies/processing/processors.py index b61950e25dc..3985403d5d5 100644 --- a/src/ai/backend/manager/dependencies/processing/processors.py +++ b/src/ai/backend/manager/dependencies/processing/processors.py @@ -6,6 +6,7 @@ from ai.backend.common.dependencies import NonMonitorableDependencyProvider from ai.backend.manager.actions.monitors.monitor import ActionMonitor +from ai.backend.manager.actions.validators.rbac import RBACValidators from ai.backend.manager.services.processors import ProcessorArgs, Processors, ServiceArgs @@ -15,6 +16,7 @@ class ProcessorsProviderInput: service_args: ServiceArgs action_monitors: list[ActionMonitor] + rbac_validators: RBACValidators class ProcessorsDependency(NonMonitorableDependencyProvider[ProcessorsProviderInput, Processors]): @@ -31,7 +33,10 @@ def stage_name(self) -> str: @asynccontextmanager async def provide(self, setup_input: ProcessorsProviderInput) -> AsyncIterator[Processors]: processors = Processors.create( - ProcessorArgs(service_args=setup_input.service_args), + ProcessorArgs( + service_args=setup_input.service_args, + rbac_validators=setup_input.rbac_validators, + ), setup_input.action_monitors, ) yield processors diff --git a/src/ai/backend/manager/server.py b/src/ai/backend/manager/server.py index 91278cb3884..3681858672d 100644 --- a/src/ai/backend/manager/server.py +++ b/src/ai/backend/manager/server.py @@ -145,6 +145,9 @@ from .actions.monitors.audit_log import AuditLogMonitor from .actions.monitors.prometheus import PrometheusMonitor from .actions.monitors.reporter import ReporterMonitor +from .actions.validators.rbac import RBACValidators +from .actions.validators.rbac.scope import ScopeActionRBACValidator +from .actions.validators.rbac.single_entity import SingleEntityActionRBACValidator from .agent_cache import AgentRPCCache from .api import ManagerStatus from .api.context import RootContext @@ -644,6 +647,15 @@ async def processors_ctx(root_ctx: RootContext) -> AsyncIterator[None]: prometheus_monitor = PrometheusMonitor() audit_log_monitor = AuditLogMonitor(root_ctx.repositories.audit_log.repository) + rbac_validators = RBACValidators( + scope=ScopeActionRBACValidator( + root_ctx.repositories.permission_controller.repository, + ), + single_entity=SingleEntityActionRBACValidator( + root_ctx.repositories.permission_controller.repository, + ), + ) + root_ctx.processors = Processors.create( ProcessorArgs( service_args=ServiceArgs( @@ -673,6 +685,7 @@ async def processors_ctx(root_ctx: RootContext) -> AsyncIterator[None]: prometheus_client=root_ctx.prometheus_client, registry_quota_service=root_ctx.services_ctx.per_project_container_registries_quota, ), + rbac_validators=rbac_validators, ), [reporter_monitor, prometheus_monitor, audit_log_monitor], ) diff --git a/src/ai/backend/manager/services/processors.py b/src/ai/backend/manager/services/processors.py index 551995b1218..bfebcdf9d6e 100644 --- a/src/ai/backend/manager/services/processors.py +++ b/src/ai/backend/manager/services/processors.py @@ -16,6 +16,7 @@ from ai.backend.common.plugin.monitor import ErrorPluginContext from ai.backend.manager.actions.monitors.monitor import ActionMonitor from ai.backend.manager.actions.types import AbstractProcessorPackage, ActionSpec +from ai.backend.manager.actions.validators.rbac import RBACValidators from ai.backend.manager.agent_cache import AgentRPCCache from ai.backend.manager.clients.appproxy.client import AppProxyClientPool from ai.backend.manager.config.provider import ManagerConfigProvider @@ -484,6 +485,7 @@ def create(cls, args: ServiceArgs) -> Self: @dataclass class ProcessorArgs: service_args: ServiceArgs + rbac_validators: RBACValidators @dataclass @@ -552,7 +554,7 @@ def create(cls, args: ProcessorArgs, action_monitors: list[ActionMonitor]) -> Se vfolder_processors = VFolderProcessors( services.vfolder, action_monitors, - args.service_args.repositories.permission_controller.repository, + args.rbac_validators, ) vfolder_file_processors = VFolderFileProcessors(services.vfolder_file, action_monitors) vfolder_invite_processors = VFolderInviteProcessors( diff --git a/src/ai/backend/manager/services/vfolder/processors/vfolder.py b/src/ai/backend/manager/services/vfolder/processors/vfolder.py index cc5eb7b516e..d391bd7b11b 100644 --- a/src/ai/backend/manager/services/vfolder/processors/vfolder.py +++ b/src/ai/backend/manager/services/vfolder/processors/vfolder.py @@ -4,11 +4,7 @@ from ai.backend.manager.actions.processor import ActionProcessor from ai.backend.manager.actions.types import AbstractProcessorPackage, ActionSpec from ai.backend.manager.actions.validator.base import ActionValidator -from ai.backend.manager.actions.validators.rbac.scope import ScopeActionRBACValidator -from ai.backend.manager.actions.validators.rbac.single_entity import SingleEntityActionRBACValidator -from ai.backend.manager.repositories.permission_controller.repository import ( - PermissionControllerRepository, -) +from ai.backend.manager.actions.validators.rbac import RBACValidators from ai.backend.manager.services.vfolder.actions.base import ( CloneVFolderAction, CloneVFolderActionResult, @@ -104,11 +100,11 @@ def __init__( self, service: VFolderService, action_monitors: list[ActionMonitor], - permission_repository: PermissionControllerRepository, + rbac_validators: RBACValidators, ) -> None: - # Create RBAC validators - scope_validator = ScopeActionRBACValidator(permission_repository) - single_entity_validator = SingleEntityActionRBACValidator(permission_repository) + # Extract RBAC validators + scope_validator = rbac_validators.scope + single_entity_validator = rbac_validators.single_entity # Scope actions with RBAC validation self.create_vfolder = ActionProcessor(