diff --git a/.specs/FR-2209-project-admin-management/dev-plan.md b/.specs/FR-2209-project-admin-management/dev-plan.md new file mode 100644 index 0000000000..7ae6f953ea --- /dev/null +++ b/.specs/FR-2209-project-admin-management/dev-plan.md @@ -0,0 +1,197 @@ +# Dev Plan: FR-2209 Project Admin Management + +## Spec Reference + +`.specs/FR-2209-project-admin-management/spec.md` (merged 2026-03-26, 4th review) + +## Epic + +**FR-2209** — WebUI 프로젝트 관리자 기능 (Project Admin Management) + +Related upstream spec PR (open): #6026 — FR-2314 "RBAC admin menu behavior" (FR-1692 epic). This dev plan absorbs FR-2314's UI requirements (3-tier menu, header selector, badge, switch confirm) into FR-2209 implementation. + +--- + +## Backend Reality Check (verified against `lablup/backend.ai` HEAD) + +| Capability | Status | Notes | +|---|---|---| +| `myRoles` query (with permissions) | ✅ available since 26.3.0 | Local `data/schema.graphql` may need re-pull | +| `projectRoles(projectId)` | ✅ 26.4.0 | Lists roles in a project scope | +| `PROJECT_ADMIN_PAGE` element type | ✅ in `RBACElementType` enum | **Primary signal for "is project admin"** — preferred over role-name parsing | +| `compute_session_nodes(scope_id: "project:")` RBAC filtering | ✅ | | +| `vfolder_nodes(scope_id: "project:")` RBAC filtering | ✅ | | +| `endpoint_list` / Serving RBAC | ❌ **gap** — still legacy `project: UUID` arg, no `endpoint_nodes` Relay node | PR-2b blocked or interim implementation | +| `unassignUsersFromProjectV2` callable by project admin | ✅ 26.4.0 | Out of initial scope (no member mutations in v1) | +| Project-member listing by UUID | ⚠️ `adminUsersV2` filters by project **name** only | Use `projectRoles → role.users` connection or name filter | + +**`scope_id` format**: `project:` (verified in `models/rbac/__init__.py`). + +--- + +## WebUI Current State + +- `useCurrentUserRole()` exists (`react/src/hooks/backendai.tsx`) — returns string role +- Admin pages exist as **superadmin global views**, no project scoping yet: + - `AdminComputeSessionListPage.tsx`, `AdminServingPage.tsx`, `AdminVFolderNodeListPage.tsx` +- `useCurrentUserProjectRoles` / `useEffectiveAdminRole` — **do not exist** +- `ProjectSelect.tsx` — no admin badge +- `WebUISider.tsx` `hasAdminCategoryRole` — simple role-string check +- `WebUIHeader.tsx` — project selector always visible + +--- + +## Phased PR Stack (Graphite) + +``` +main + └── PR-0 Schema sync + useCurrentUserProjectRoles (FR-2209-A) + ├── PR-1a 3-tier menu visibility + ProjectSelect badge (FR-2209-B) + │ └── PR-1b Header selector + transition confirm (FR-2209-C) + ├── PR-2a Admin Sessions project scope (FR-2209-D) + ├── PR-2b Admin Serving (project scope) (FR-2209-E) ⚠ backend gap + ├── PR-2c Admin VFolder + project folder creation (FR-2209-F) + ├── PR-2d Project Members read-only page (FR-2209-G) + └── PR-3 E2E + i18n + final verification (FR-2209-H) +``` + +PR-1a/b and PR-2a/c/d can be developed in parallel after PR-0 lands. PR-2b is gated on backend. + +--- + +## Sub-tasks + +### PR-0 — Schema sync + role detection hook (BLOCKING) + +- **Title**: `feat(FR-2209): add useCurrentUserProjectRoles hook with myRoles RBAC query` +- **Scope**: + - Pull latest schema (`data/schema.graphql`) so `myRoles`/`projectRoles` are present; regenerate Relay artifacts. + - Create `react/src/hooks/useCurrentUserProjectRoles.ts`: + - `useLazyLoadQuery` against `myRoles` + - Returns `{ isSuperAdmin, domainAdminDomains: string[], projectAdminIds: string[], rawAssignments }` + - Detection: collect `scopeId` where `permissions[].scopeType === 'PROJECT' && permissions[].entityType === 'PROJECT_ADMIN_PAGE'` + - Fallback to role-name parsing (`role_project_<8-hex>_admin`) if `PROJECT_ADMIN_PAGE` permissions not present + - Graceful fallback when `myRoles` query is unsupported (older core) + - Create `useEffectiveAdminRole()` derived hook returning `'superadmin' | 'domainAdmin' | 'projectAdmin' | 'none'` (priority: super > domain > project) + - Unit tests covering super, domain, project, mixed, and no-role cases +- **Files**: `react/src/hooks/useCurrentUserProjectRoles.ts` (new), `data/schema.graphql` +- **Dependencies**: none (stack base) +- **Acceptance**: + - Hook returns correct admin role for each test fixture + - `myRoles` query failure does not break general pages (returns `none`) + - `pnpm run relay && bash scripts/verify.sh` passes + +### PR-1a — 3-tier menu visibility + ProjectSelect badge + +- **Title**: `feat(FR-2209): add project-admin tier to admin menu and project selector` +- **Scope**: + - `useWebUIMenuItems.tsx` add a `PROJECT_ADMIN_PAGE_KEYS` subset (Session, Serving, Data, Members) + - `WebUISider.tsx` `hasAdminCategoryRole = useEffectiveAdminRole() !== 'none'` + - `ProjectSelect.tsx` render "Project Admin" badge for projects in `projectAdminIds` (hidden if user is also superadmin/domainAdmin) +- **Files**: `react/src/hooks/useWebUIMenuItems.tsx`, `react/src/components/MainLayout/WebUISider.tsx`, `react/src/components/ProjectSelect.tsx` +- **Dependencies**: PR-0 +- **Acceptance** (FR-2314 spec): + - Project-admin-only user sees Admin menu category when current project has admin rights + - Domain admin / superadmin always see Admin menu + - Badge shows on admin projects for project-admin users only + +### PR-1b — Header selector + transition confirm + +- **Title**: `feat(FR-2209): control header project selector by admin role and confirm switch out of admin scope` +- **Scope**: + - `WebUIHeader.tsx`: in admin mode, show project selector only for project admins; hide for domain/super admin + - On project change in admin mode, if target project not in `projectAdminIds`, show confirm dialog ("You don't have admin rights for this project. Switching will exit admin mode.") + - Accept → switch project + leave admin mode; cancel → restore selector +- **Files**: `react/src/components/MainLayout/WebUIHeader.tsx` +- **Dependencies**: PR-1a +- **Acceptance** (FR-2314 spec): switch confirm flow + selector visibility per role + +### PR-2a — Admin Sessions project scope + +- **Title**: `feat(FR-2209): scope admin sessions by project for project admins` +- **Scope**: + - `AdminComputeSessionListPage.tsx`: derive `scope_id` from `useEffectiveAdminRole` × `useCurrentProject` + - projectAdmin → `project:` + - domainAdmin → domain-level filter (TBD per backend; may need TODO marker if no domain-scope arg) + - superadmin → no scope (current behavior) + - Owner column always visible; Agent column superadmin only (existing `hideAgents` rule) + - Do **not** change `SessionActionButtons.tsx` `isOwner` checks — spec confirms App launcher / terminal / SFTP / commit / rename remain owner-only +- **Files**: `react/src/pages/AdminComputeSessionListPage.tsx` +- **Dependencies**: PR-0 +- **Acceptance**: spec §스토리 1 acceptance criteria + +### PR-2b — Admin Serving project scope (⚠ backend gap) + +- **Title**: `feat(FR-2209): scope admin serving by project for project admins` +- **Scope**: + - `AdminServingPage.tsx`: pass project filter + - **Path A (preferred)**: wait for backend `endpoint_list(scope_id)` or `endpoint_nodes` Relay node, then use it + - **Path B (interim)**: use legacy `endpoint_list(project=)` arg with `TODO(needs-backend): FR-2313` marker for domain admin scope and full RBAC validation + - "Start Service" button hidden in admin mode (DN-4 confirmed) + - Modify/delete remain enabled (current `EndpointList` already has no owner gating for these) +- **Files**: `react/src/pages/AdminServingPage.tsx` +- **Dependencies**: PR-0; backend coordination +- **Acceptance**: spec §스토리 2 acceptance criteria +- **Risk**: backend gap; file backend follow-up issue + +### PR-2c — Admin VFolder + project folder creation + +- **Title**: `feat(FR-2209): scope admin vfolders by project and allow project folder creation in admin mode` +- **Scope**: + - `AdminVFolderNodeListPage.tsx`: `scope_id: "project:"` + `ownership_type === 'project'` filter + - Add `FolderCreateModal` integration: type locked to `project`, project locked to current project (DN-5) + - Permission management entry points (invite/remove users, change access level) preserved +- **Files**: `react/src/pages/AdminVFolderNodeListPage.tsx`, related modal usage +- **Dependencies**: PR-0 +- **Acceptance**: spec §스토리 3 acceptance criteria + +### PR-2d — Project Members read-only page + +- **Title**: `feat(FR-2209): add project members list page for project admins` +- **Scope**: + - New page `react/src/pages/ProjectMemberListPage.tsx` + - Route `/admin-members` (or under existing menu key); add to admin menu for project/domain/super admins + - Query strategy: `projectRoles(projectId) → role.users` collection (preferred), with `adminUsersV2(filter: {project: {name}})` fallback + - Columns: name, email, project role (admin/member) + - **No CUD UI** (initial scope); spec §스토리 4 +- **Files**: new page, route entry, menu entry, i18n keys +- **Dependencies**: PR-0 +- **Acceptance**: spec §스토리 4 acceptance criteria + +### PR-3 — E2E + i18n + final verification + +- **Title**: `test(FR-2209): add E2E coverage and finalize translations for project admin` +- **Scope**: + - Playwright tests using project-admin account (`project-admin-for-default@lablup.com`) on test server `http://10.122.10.215:8090`: + - Login → Admin menu visible → each Admin page shows only `default` project data + - Project selector badge visible + - Switch to non-admin project → confirm dialog appears + - i18n keys for all new strings (badge, confirm dialog, page titles) + - `bash scripts/verify.sh` clean across stack + - Run `/fw:batch-review` against the stack +- **Dependencies**: all prior PRs + +--- + +## Open Questions & Risks + +1. **Schema sync procedure**: how is `data/schema.graphql` refreshed (make target / manual fetch)? Confirm before PR-0. +2. **Serving backend gap**: file follow-up backend issue (or coordinate with backend team) — spec claims confirmed but schema disagrees. +3. **`PROJECT_ADMIN_PAGE` permission grant**: verify at runtime that the `role_project__admin` actually carries this permission; if not, role-name fallback is the actual signal. +4. **Domain admin scope arg**: which GraphQL arg (or RBAC scope string) does the WebUI pass for `domain:` filtering on session/vfolder queries? +5. **Project member query**: validate that `projectRoles → role.users` connection is reliable in production data. +6. **PR #6026 disposition**: with FR-2314 absorbed here, decide whether to close #6026 or merge it as historical context (with PO). + +--- + +## Verification + +Test target (per user instruction): + +``` +E2E_WEBSERVER_ENDPOINT=http://10.122.10.215:8090 +E2E_USER_EMAIL=project-admin-for-default@lablup.com +E2E_USER_PASSWORD="CWR0y4s5E9p#" +``` + +Account is **project admin of the `default` project**. Verifies PR-0 through PR-3 acceptance criteria end-to-end. diff --git a/react/src/components/MainLayout/WebUISider.tsx b/react/src/components/MainLayout/WebUISider.tsx index dc7e333cca..109ac1eb4a 100644 --- a/react/src/components/MainLayout/WebUISider.tsx +++ b/react/src/components/MainLayout/WebUISider.tsx @@ -3,7 +3,7 @@ Copyright (c) 2015-2026 Lablup Inc. All rights reserved. */ import { useSuspendedBackendaiClient, useWebUINavigate } from '../../hooks'; -import { useCurrentUserRole } from '../../hooks/backendai'; +import { useEffectiveAdminRole } from '../../hooks/useCurrentUserProjectRoles'; import { useCustomThemeConfig } from '../../hooks/useCustomThemeConfig'; import usePrimaryColors from '../../hooks/usePrimaryColors'; import AboutBackendAIModal from '../AboutBackendAIModal'; @@ -52,9 +52,10 @@ const WebUISider: React.FC = (props) => { const currentSiderTheme = config.theme?.algorithm === theme.darkAlgorithm ? 'dark' : 'light'; - const currentUserRole = useCurrentUserRole(); - const hasAdminCategoryRole = - currentUserRole === 'superadmin' || currentUserRole === 'admin'; + // 3-tier admin category visibility: super, domain, or project admin all show + // the Admin Settings entry. Project admins see a reduced set of pages + // (gated inside `useWebUIMenuItems`). + const hasAdminCategoryRole = useEffectiveAdminRole() !== 'none'; const webuiNavigate = useWebUINavigate(); const location = useLocation(); const baiClient = useSuspendedBackendaiClient(); diff --git a/react/src/components/ProjectSelect.tsx b/react/src/components/ProjectSelect.tsx index 7f0b8122e1..a27e4e5e84 100644 --- a/react/src/components/ProjectSelect.tsx +++ b/react/src/components/ProjectSelect.tsx @@ -6,7 +6,12 @@ import { ProjectSelectorQuery } from '../__generated__/ProjectSelectorQuery.grap import { useSuspendedBackendaiClient } from '../hooks'; import { useCurrentUserInfo, useCurrentUserRole } from '../hooks/backendai'; import useControllableState_deprecated from '../hooks/useControllableState'; -import { BAISelect, BAISelectProps } from 'backend.ai-ui'; +import { + useCurrentUserProjectRoles, + useEffectiveAdminRole, +} from '../hooks/useCurrentUserProjectRoles'; +import { Tag, theme } from 'antd'; +import { BAIFlex, BAISelect, BAISelectProps } from 'backend.ai-ui'; import * as _ from 'lodash-es'; import React, { useEffect, useEffectEvent } from 'react'; import { useTranslation } from 'react-i18next'; @@ -35,12 +40,28 @@ const ProjectSelect: React.FC = ({ ...selectProps }) => { const { t } = useTranslation(); + const { token } = theme.useToken(); const [currentUser] = useCurrentUserInfo(); const baiClient = useSuspendedBackendaiClient(); const blockList = baiClient?._config?.blockList ?? null; const [value, setValue] = useControllableState_deprecated(selectProps); const userRole = useCurrentUserRole(); + const { projectAdminIds } = useCurrentUserProjectRoles(); + const effectiveAdminRole = useEffectiveAdminRole(); + // Only show the per-project "Project Admin" badge when the user's effective + // admin role is exactly 'projectAdmin'. Super and domain admins have broader + // authority over every project, so a per-project badge would be noise. + const shouldShowProjectAdminBadge = effectiveAdminRole === 'projectAdmin'; + const projectAdminIdSet = new Set(projectAdminIds); + // Project IDs returned by the GraphQL `groups` query are full UUIDs (with + // hyphens). `useCurrentUserProjectRoles` exposes short 8-hex IDs. Convert on + // compare. + const isProjectAdmin = (projectId: string | null | undefined): boolean => { + if (!projectId) return false; + const short = projectId.replace(/-/g, '').slice(0, 8).toLowerCase(); + return projectAdminIdSet.has(short); + }; const { groups, user } = useLazyLoadQuery( graphql` query ProjectSelectorQuery( @@ -124,8 +145,22 @@ const ProjectSelect: React.FC = ({ label: getLabel(key), title: key, options: _.map(_.sortBy(value, 'name'), (project) => { + const showBadge = + shouldShowProjectAdminBadge && isProjectAdmin(project?.id); return { - label: project?.name, + label: showBadge ? ( + + {project?.name} + + {t('projectSelect.ProjectAdminBadge')} + + + ) : ( + project?.name + ), value: project?.id, projectId: project?.id, projectResourcePolicy: project?.resource_policy, diff --git a/react/src/hooks/useWebUIMenuItems.tsx b/react/src/hooks/useWebUIMenuItems.tsx index 395e6f7936..e9eded0438 100644 --- a/react/src/hooks/useWebUIMenuItems.tsx +++ b/react/src/hooks/useWebUIMenuItems.tsx @@ -6,6 +6,7 @@ import { useSuspendedBackendaiClient } from '.'; import { useCurrentUserRole } from './backendai'; import { useDiagnosticsBadgeSeverity } from './useAutoDiagnostics'; import { useBAISettingUserState } from './useBAISetting'; +import { useEffectiveAdminRole } from './useCurrentUserProjectRoles'; import { useCustomThemeConfig } from './useCustomThemeConfig'; import { PluginPage, @@ -137,6 +138,22 @@ const ALL_ADMIN_PAGE_KEYS: ReadonlySet = new Set([ 'information', ]); +// Admin-category page keys reachable by a project admin (3-tier admin gating). +// Project admins see Sessions, Serving, Data (vfolders) and Members within the +// admin category. Other admin pages remain visible only to domain admins or +// superadmins. Kept as a plain array so it can be exported and reused (e.g. for +// per-page route gating in follow-up PRs). +export const PROJECT_ADMIN_PAGE_KEYS = [ + 'admin-session', + 'admin-serving', + 'admin-data', + 'admin-members', +] as const; + +const PROJECT_ADMIN_PAGE_KEY_SET: ReadonlySet = new Set( + PROJECT_ADMIN_PAGE_KEYS, +); + // Page keys that additionally require superadmin role const SUPERADMIN_ONLY_PAGE_KEYS: ReadonlySet = new Set([ 'admin-serving', @@ -190,6 +207,7 @@ export const useWebUIMenuItems = (props?: UseWebUIMenuItemsProps) => { const plugins = useWebUIPluginValue(); const isPluginLoaded = useWebUIPluginLoadedValue(); const currentUserRole = useCurrentUserRole(); + const effectiveAdminRole = useEffectiveAdminRole(); const location = useLocation(); const { t } = useTranslation(); @@ -319,7 +337,7 @@ export const useWebUIMenuItems = (props?: UseWebUIMenuItemsProps) => { const isSuperAdmin = currentUserRole === 'superadmin'; - const adminMenu: Array = filterOutEmpty([ + const fullAdminMenu: Array = filterOutEmpty([ // --- Operations group --- { label: {t('webui.menu.Users')}, @@ -460,6 +478,20 @@ export const useWebUIMenuItems = (props?: UseWebUIMenuItemsProps) => { }, ]); + // 3-tier admin gating: + // - 'none': no admin items + // - 'projectAdmin': only PROJECT_ADMIN_PAGE_KEYS + // - 'domainAdmin' / 'superadmin': existing behavior preserved by fullAdminMenu + // (which already applies isSuperAdmin gating per item) + const adminMenu: Array = + effectiveAdminRole === 'none' + ? [] + : effectiveAdminRole === 'projectAdmin' + ? fullAdminMenu.filter((item) => + PROJECT_ADMIN_PAGE_KEY_SET.has(item.key as string), + ) + : fullAdminMenu; + const pluginMap: Record = { 'menuitem-user': generalMenu as unknown as MenuItem[], 'menuitem-admin': adminMenu as unknown as MenuItem[], @@ -770,29 +802,43 @@ export const useWebUIMenuItems = (props?: UseWebUIMenuItemsProps) => { return true; })(); - // Check if current page requires higher permission than user has + // Check if current page requires higher permission than user has. // Uses static key sets (not role-filtered adminMenu) to ensure correct 401 responses. + // Gating is driven by the user's effective admin role (super/domain/project/none) + // rather than the legacy `currentUserRole` string, so that project admins can reach + // the subset of admin pages listed in PROJECT_ADMIN_PAGE_KEYS. const isCurrentPageUnauthorized = (() => { if (currentPathKey === '') return false; - // Regular users (not admin, not superadmin) cannot access any admin page + const isAdminPage = + ALL_ADMIN_PAGE_KEYS.has(currentMenuKey) || + PROJECT_ADMIN_PAGE_KEY_SET.has(currentMenuKey); + + if (!isAdminPage) return false; + + // superadmin and domain admin can reach every admin page. if ( - currentUserRole !== 'admin' && - currentUserRole !== 'superadmin' && - ALL_ADMIN_PAGE_KEYS.has(currentMenuKey) + effectiveAdminRole === 'superadmin' || + effectiveAdminRole === 'domainAdmin' ) { - return true; + // Domain admin still cannot reach superadmin-only pages. + if ( + effectiveAdminRole === 'domainAdmin' && + SUPERADMIN_ONLY_PAGE_KEYS.has(currentMenuKey) && + !PROJECT_ADMIN_PAGE_KEY_SET.has(currentMenuKey) + ) { + return true; + } + return false; } - // Admin users cannot access superadmin-only pages - if ( - currentUserRole === 'admin' && - SUPERADMIN_ONLY_PAGE_KEYS.has(currentMenuKey) - ) { - return true; + // Project admin: allow only pages explicitly reachable by project admins. + if (effectiveAdminRole === 'projectAdmin') { + return !PROJECT_ADMIN_PAGE_KEY_SET.has(currentMenuKey); } - return false; + // No admin role at all: all admin pages are unauthorized. + return true; })(); // Get theme config for custom logo href diff --git a/resources/i18n/en.json b/resources/i18n/en.json index cbe4a987aa..2b28b8ae54 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -1684,6 +1684,9 @@ "ProjectIDFilterRuleMessage": "Invalid UUID.", "ResourcePolicy": "Resource Policy" }, + "projectSelect": { + "ProjectAdminBadge": "Project Admin" + }, "rbac": { "Activate": "Activate", "ActivateRole": "Activate Role", diff --git a/resources/i18n/ko.json b/resources/i18n/ko.json index ba06acba9c..575d477192 100644 --- a/resources/i18n/ko.json +++ b/resources/i18n/ko.json @@ -1683,6 +1683,9 @@ "ProjectIDFilterRuleMessage": "잘못된 UUID입니다.", "ResourcePolicy": "자원 정책" }, + "projectSelect": { + "ProjectAdminBadge": "프로젝트 관리자" + }, "rbac": { "Activate": "활성화", "ActivateRole": "권한 활성화",