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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions react/src/hooks/__tests__/useCurrentUserProjectRoles.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
@license
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
*/
import {
deriveProjectAdminIds,
MyRolesAssignmentNode,
} from '../useCurrentUserProjectRoles';

/**
* These tests cover the pure admin-scope derivation logic that powers
* `useCurrentUserProjectRoles`. The hook itself is a thin Relay +
* baiClient wrapper — testing it end-to-end requires spinning up the full
* suspense/Relay environment, while the derivation rules (primary vs
* fallback, UUID normalization, priority) are where the correctness-sensitive
* behavior lives.
*
* Fixtures correspond to the five cases called out in the acceptance criteria:
* superadmin-only, domainAdmin-only, projectAdmin-only, mixed (super + project),
* and none.
*/
describe('deriveProjectAdminIds', () => {
const makePermissionEdge = (
scopeType: string,
entityType: string,
scopeId: string,
) => ({
node: { scopeType, entityType, scopeId },
});

const makeAssignment = (
roleName: string | null | undefined,
permissions: ReturnType<typeof makePermissionEdge>[] = [],
): MyRolesAssignmentNode => ({
role: {
name: roleName,
permissions: { edges: permissions },
},
});

it('returns empty array when there are no assignments (none case)', () => {
expect(deriveProjectAdminIds([])).toEqual([]);
});

it('returns empty array for superadmin-only assignments with no project-scoped permissions', () => {
// Superadmin typically has DOMAIN-scoped or unscoped permissions — no
// PROJECT + PROJECT_ADMIN_PAGE permission should surface.
const assignments = [
makeAssignment('role_superadmin', [
makePermissionEdge('DOMAIN', 'DOMAIN_ADMIN_PAGE', 'default'),
makePermissionEdge('PROJECT', 'SESSION', 'some-project'),
]),
];
expect(deriveProjectAdminIds(assignments)).toEqual([]);
});

it('returns empty array for domainAdmin-only assignments', () => {
const assignments = [
makeAssignment('role_domain_default_admin', [
makePermissionEdge('DOMAIN', 'DOMAIN_ADMIN_PAGE', 'default'),
]),
];
expect(deriveProjectAdminIds(assignments)).toEqual([]);
});

it('detects projectAdmin via PROJECT_ADMIN_PAGE permission (primary signal)', () => {
const assignments = [
makeAssignment('role_project_abcd1234_admin', [
makePermissionEdge(
'PROJECT',
'PROJECT_ADMIN_PAGE',
'abcd1234-5678-90ab-cdef-000000000000',
),
]),
];
expect(deriveProjectAdminIds(assignments)).toEqual(['abcd1234']);
});

it('strips hyphens from UUID scopeId and uses the first 8 hex chars', () => {
const assignments = [
makeAssignment('ignored-name', [
makePermissionEdge(
'PROJECT',
'PROJECT_ADMIN_PAGE',
'1234abcd-ef01-2345-6789-0abcdef01234',
),
]),
];
expect(deriveProjectAdminIds(assignments)).toEqual(['1234abcd']);
});

it('falls back to role-name regex when permission signal is missing', () => {
const assignments = [makeAssignment('role_project_deadbeef_admin', [])];
expect(deriveProjectAdminIds(assignments)).toEqual(['deadbeef']);
});

it('prefers permission signal over role-name regex when both are present', () => {
// Permission indicates project `aabbccdd`, but role name says `deadbeef`.
// The primary signal must win.
const assignments = [
makeAssignment('role_project_deadbeef_admin', [
makePermissionEdge(
'PROJECT',
'PROJECT_ADMIN_PAGE',
'aabbccdd-0000-0000-0000-000000000000',
),
]),
];
expect(deriveProjectAdminIds(assignments)).toEqual(['aabbccdd']);
});

it('deduplicates project IDs across multiple assignments', () => {
const assignments = [
makeAssignment('role_project_aabbccdd_admin', [
makePermissionEdge(
'PROJECT',
'PROJECT_ADMIN_PAGE',
'aabbccdd-0000-0000-0000-000000000000',
),
]),
makeAssignment('role_project_aabbccdd_admin', [
makePermissionEdge(
'PROJECT',
'PROJECT_ADMIN_PAGE',
'aabbccdd-0000-0000-0000-000000000000',
),
]),
];
expect(deriveProjectAdminIds(assignments)).toEqual(['aabbccdd']);
});

it('mixed case: reports project-admin IDs even when super/domain admin perms are also present', () => {
// Mixed super + project admin. The hook returns project IDs; the super-admin
// signal is sourced separately from baiClient in the real hook.
const assignments = [
makeAssignment('role_superadmin', [
makePermissionEdge('DOMAIN', 'DOMAIN_ADMIN_PAGE', 'default'),
]),
makeAssignment('role_project_abcd1234_admin', [
makePermissionEdge(
'PROJECT',
'PROJECT_ADMIN_PAGE',
'abcd1234-0000-0000-0000-000000000000',
),
]),
];
expect(deriveProjectAdminIds(assignments)).toEqual(['abcd1234']);
});

it('ignores PROJECT scope entries for non-PROJECT_ADMIN_PAGE entity types', () => {
const assignments = [
makeAssignment('role_project_member', [
makePermissionEdge(
'PROJECT',
'SESSION',
'abcd1234-0000-0000-0000-000000000000',
),
makePermissionEdge(
'PROJECT',
'VFOLDER',
'abcd1234-0000-0000-0000-000000000000',
),
]),
];
expect(deriveProjectAdminIds(assignments)).toEqual([]);
});

it('handles malformed role names without throwing and returns empty', () => {
const assignments = [
makeAssignment('not-a-project-role', []),
makeAssignment('role_project_XYZ_admin', []), // non-hex chars
makeAssignment(null, []),
makeAssignment(undefined, []),
];
expect(deriveProjectAdminIds(assignments)).toEqual([]);
});

it('tolerates assignments with missing role object (graceful)', () => {
const assignments: MyRolesAssignmentNode[] = [
{ role: null },
{ role: undefined },
{} as MyRolesAssignmentNode,
];
expect(deriveProjectAdminIds(assignments)).toEqual([]);
});
});
211 changes: 211 additions & 0 deletions react/src/hooks/useCurrentUserProjectRoles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
@license
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
*/
import { useSuspendedBackendaiClient } from '.';
import { useCurrentUserProjectRolesQuery } from '../__generated__/useCurrentUserProjectRolesQuery.graphql';
import { graphql, useLazyLoadQuery } from 'react-relay';

/**
* Regex matching the backend's auto-generated project-admin role name.
* Backend uses short project IDs (UUID with hyphens stripped → 32 hex chars),
* but only the first 8 hex characters are embedded in the role name.
*
* Example: `role_project_abcd1234_admin`
*/
const PROJECT_ADMIN_ROLE_NAME_RE = /^role_project_([0-9a-f]{8})_admin$/i;

/**
* Minimal shape of a single role assignment node returned by the `myRoles` query.
* Kept as an interface so tests can construct fixtures without importing Relay
* generated types. All properties are `readonly` to stay structurally compatible
* with Relay's generated readonly shapes without requiring a type assertion.
*/
export interface MyRolesAssignmentNode {
readonly role?: {
readonly name?: string | null;
readonly permissions?: {
readonly edges?: ReadonlyArray<{
readonly node?: {
readonly scopeType?: string | null;
readonly scopeId?: string | null;
readonly entityType?: string | null;
} | null;
} | null> | null;
} | null;
} | null;
}

export interface CurrentUserProjectRolesResult {
/** `true` when the authenticated user is a super-admin (derived from baiClient). */
isSuperAdmin: boolean;
/** Domain names the user has domain-admin rights over (derived from baiClient for now). */
domainAdminDomains: string[];
/**
* Short project IDs (first 8 hex chars of the UUID, hyphens stripped) the user
* has project-admin rights over.
*
* Primary signal: permissions where `scopeType === 'PROJECT'` and
* `entityType === 'PROJECT_ADMIN_PAGE'`.
* Fallback: role name regex `role_project_<8-hex>_admin` (only used when
* the primary signal returned nothing, for older cores that don't yet grant
* the `PROJECT_ADMIN_PAGE` permission).
*/
projectAdminIds: string[];
/** Raw list of role-assignment nodes, exposed for advanced consumers. */
rawAssignments: ReadonlyArray<MyRolesAssignmentNode>;
}

/**
* Strip hyphens from a UUID string and return the first 8 hex characters lowercased.
* Returns `null` if the input is not a non-empty string or doesn't yield 8+ hex chars.
*/
const toShortProjectId = (scopeId?: string | null): string | null => {
if (!scopeId) return null;
const stripped = scopeId.replace(/-/g, '').toLowerCase();
if (stripped.length < 8) return null;
const short = stripped.slice(0, 8);
return /^[0-9a-f]{8}$/.test(short) ? short : null;
};

/**
* Pure, framework-free function that derives the admin-scope data from a list
* of raw `myRoles` assignment nodes. Exposed for unit testing and for reuse
* when assignments are obtained from a non-Relay source.
*/
export const deriveProjectAdminIds = (
assignments: ReadonlyArray<MyRolesAssignmentNode>,
): string[] => {
const fromPermissions = new Set<string>();
const fromRoleNames = new Set<string>();

for (const assignment of assignments) {
const role = assignment?.role;
if (!role) continue;

// Primary: PROJECT scope + PROJECT_ADMIN_PAGE entity.
const permissionEdges = role.permissions?.edges ?? [];
for (const edge of permissionEdges) {
const node = edge?.node;
if (!node) continue;
if (
node.scopeType === 'PROJECT' &&
node.entityType === 'PROJECT_ADMIN_PAGE'
) {
const shortId = toShortProjectId(node.scopeId);
if (shortId) fromPermissions.add(shortId);
}
}

// Fallback: role name regex, collected separately so primary wins when present.
const match = role.name ? PROJECT_ADMIN_ROLE_NAME_RE.exec(role.name) : null;
if (match?.[1]) {
fromRoleNames.add(match[1].toLowerCase());
}
}

// Sort lexicographically so the returned array is deterministic regardless
// of GraphQL connection ordering — prevents unnecessary re-renders and
// flaky reference-equality comparisons across fetches.
if (fromPermissions.size > 0) {
return Array.from(fromPermissions).sort();
}
return Array.from(fromRoleNames).sort();
};

/**
* Hook that inspects the current user's RBAC role assignments and reports which
* projects they have project-admin scope over.
*
* Uses the `myRoles` query (added in core 26.3.0). On older cores that do not
* implement it, `@catch(to: RESULT)` makes the field resolve to `{ ok: false }`
* and the hook returns empty admin arrays instead of throwing — so general
* pages continue to render.
*
* Super-admin / domain-admin detection is sourced from the backendaiclient
* (the legacy signals the existing codebase already relies on), since those
* roles are outside the per-project scope this hook is concerned with.
*/
export const useCurrentUserProjectRoles = (): CurrentUserProjectRolesResult => {
const baiClient = useSuspendedBackendaiClient();

const data = useLazyLoadQuery<useCurrentUserProjectRolesQuery>(
graphql`
query useCurrentUserProjectRolesQuery {
Comment thread
yomybaby marked this conversation as resolved.
myRolesResult: myRoles(first: 100) @catch(to: RESULT) {
edges {
node {
id
role {
id
name
permissions(first: 200) {
edges {
node {
id
scopeType
scopeId
entityType
}
}
}
}
}
}
}
}
`,
{},
{
// store-or-network keeps the result cached across pages for the session.
fetchPolicy: 'store-or-network',
},
);

const assignments: ReadonlyArray<MyRolesAssignmentNode> =
data.myRolesResult?.ok === true
? (data.myRolesResult.value?.edges
?.map((edge) => edge?.node)
.filter((node) => Boolean(node)) ?? [])
: [];

const projectAdminIds = deriveProjectAdminIds(assignments);

const isSuperAdmin: boolean = !!baiClient?.is_superadmin;
// Domain-admin detection via `myRoles` is out of scope for this PR (no stable
// signal yet agreed with backend). Fall back to the existing baiClient
// heuristic: non-super admins whose legacy role === 'admin'.
const isLegacyAdmin: boolean = !!baiClient?.is_admin && !isSuperAdmin;
const domainName: string | undefined =
typeof baiClient?.current_domain === 'string'
? baiClient.current_domain
: baiClient?._config?.domainName;
const domainAdminDomains: string[] =
isLegacyAdmin && domainName ? [domainName] : [];

return {
isSuperAdmin,
domainAdminDomains,
projectAdminIds,
rawAssignments: assignments,
};
};

export type EffectiveAdminRole =
| 'superadmin'
| 'domainAdmin'
| 'projectAdmin'
| 'none';

/**
* Derived hook returning the user's effective admin role with priority:
* super > domain > project > none.
*/
export const useEffectiveAdminRole = (): EffectiveAdminRole => {
const { isSuperAdmin, domainAdminDomains, projectAdminIds } =
useCurrentUserProjectRoles();
if (isSuperAdmin) return 'superadmin';
if (domainAdminDomains.length > 0) return 'domainAdmin';
if (projectAdminIds.length > 0) return 'projectAdmin';
return 'none';
};
Loading