diff --git a/api/src/main/java/io/kafbat/ui/model/rbac/AccessContext.java b/api/src/main/java/io/kafbat/ui/model/rbac/AccessContext.java index dbf5c456b..cd53aae12 100644 --- a/api/src/main/java/io/kafbat/ui/model/rbac/AccessContext.java +++ b/api/src/main/java/io/kafbat/ui/model/rbac/AccessContext.java @@ -9,6 +9,7 @@ import io.kafbat.ui.model.rbac.permission.ClientQuotaAction; import io.kafbat.ui.model.rbac.permission.ClusterConfigAction; import io.kafbat.ui.model.rbac.permission.ConnectAction; +import io.kafbat.ui.model.rbac.permission.ConnectorAction; import io.kafbat.ui.model.rbac.permission.ConsumerGroupAction; import io.kafbat.ui.model.rbac.permission.KsqlAction; import io.kafbat.ui.model.rbac.permission.PermissibleAction; @@ -81,6 +82,37 @@ public boolean isAccessible(List userPermissions) throws AccessDenie } } + /** + * A ResourceAccess that checks primary first, then falls back to fallback if primary fails. + * This enables OR semantics: access is granted if EITHER primary OR fallback is accessible. + */ + record FallbackResourceAccess( + ResourceAccess primary, + ResourceAccess fallback + ) implements ResourceAccess { + + @Override + public Object resourceId() { + return primary.resourceId(); + } + + @Override + public Resource resourceType() { + return primary.resourceType(); + } + + @Override + public Collection requestedActions() { + return primary.requestedActions(); + } + + @Override + public boolean isAccessible(List userPermissions) { + return primary.isAccessible(userPermissions) + || fallback.isAccessible(userPermissions); + } + } + public static AccessContextBuilder builder() { return new AccessContextBuilder(); } @@ -176,7 +208,69 @@ public AccessContextBuilder operationParams(Map paramsMap) { } public AccessContext build() { - return new AccessContext(cluster, accessedResources, operationName, operationParams); + List finalResources = shouldApplyConnectorFallback() + ? applyConnectorFallback(accessedResources) + : accessedResources; + return new AccessContext(cluster, finalResources, operationName, operationParams); + } + + private boolean shouldApplyConnectorFallback() { + return extractConnectorName() != null + && accessedResources.stream().anyMatch(r -> r.resourceType() == Resource.CONNECT); + } + + private List applyConnectorFallback(List resources) { + String connectorName = extractConnectorName(); + if (connectorName == null) { + return resources; + } + + List result = new ArrayList<>(); + for (ResourceAccess resource : resources) { + if (resource.resourceType() == Resource.CONNECT && resource instanceof SingleResourceAccess sra) { + String connectName = sra.name(); + String connectorPath = ConnectorAction.buildResourcePath(connectName, connectorName); + ConnectorAction[] connectorActions = mapConnectToConnectorActions(sra.requestedActions()); + + ResourceAccess connectorAccess = new SingleResourceAccess( + connectorPath, Resource.CONNECTOR, List.of(connectorActions)); + + result.add(new FallbackResourceAccess(connectorAccess, resource)); + } else { + result.add(resource); + } + } + return result; + } + + @Nullable + private String extractConnectorName() { + if (operationParams instanceof Map map) { + Object value = map.get("connectorName"); + if (value instanceof String s) { + return s; + } + } + return null; + } + + private ConnectorAction[] mapConnectToConnectorActions(Collection actions) { + return actions.stream() + .filter(a -> a instanceof ConnectAction) + .map(a -> mapSingleAction((ConnectAction) a)) + .distinct() + .toArray(ConnectorAction[]::new); + } + + private ConnectorAction mapSingleAction(ConnectAction action) { + return switch (action) { + case VIEW -> ConnectorAction.VIEW; + case EDIT -> ConnectorAction.EDIT; + case CREATE -> ConnectorAction.CREATE; + case DELETE -> ConnectorAction.DELETE; + case OPERATE -> ConnectorAction.OPERATE; + case RESET_OFFSETS -> ConnectorAction.RESET_OFFSETS; + }; } } } diff --git a/api/src/main/java/io/kafbat/ui/model/rbac/Resource.java b/api/src/main/java/io/kafbat/ui/model/rbac/Resource.java index 691bc9e94..6ba6a2629 100644 --- a/api/src/main/java/io/kafbat/ui/model/rbac/Resource.java +++ b/api/src/main/java/io/kafbat/ui/model/rbac/Resource.java @@ -6,6 +6,7 @@ import io.kafbat.ui.model.rbac.permission.ClientQuotaAction; import io.kafbat.ui.model.rbac.permission.ClusterConfigAction; import io.kafbat.ui.model.rbac.permission.ConnectAction; +import io.kafbat.ui.model.rbac.permission.ConnectorAction; import io.kafbat.ui.model.rbac.permission.ConsumerGroupAction; import io.kafbat.ui.model.rbac.permission.KsqlAction; import io.kafbat.ui.model.rbac.permission.PermissibleAction; @@ -36,6 +37,8 @@ public enum Resource { CONNECT(ConnectAction.values(), ConnectAction.ALIASES), + CONNECTOR(ConnectorAction.values()), + KSQL(KsqlAction.values()), ACL(AclAction.values()), diff --git a/api/src/main/java/io/kafbat/ui/model/rbac/permission/ConnectorAction.java b/api/src/main/java/io/kafbat/ui/model/rbac/permission/ConnectorAction.java new file mode 100644 index 000000000..5a01fb26f --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/model/rbac/permission/ConnectorAction.java @@ -0,0 +1,45 @@ +package io.kafbat.ui.model.rbac.permission; + +import java.util.Set; +import org.apache.commons.lang3.EnumUtils; +import org.jetbrains.annotations.Nullable; + +public enum ConnectorAction implements PermissibleAction { + + VIEW, + EDIT(VIEW), + CREATE(VIEW), + OPERATE(VIEW), + DELETE(VIEW), + RESET_OFFSETS(VIEW), + ; + + public static final String CONNECTOR_RESOURCE_DELIMITER = "/"; + + private final ConnectorAction[] dependantActions; + + ConnectorAction(ConnectorAction... dependantActions) { + this.dependantActions = dependantActions; + } + + public static final Set ALTER_ACTIONS = Set.of(CREATE, EDIT, DELETE, OPERATE, RESET_OFFSETS); + + @Nullable + public static ConnectorAction fromString(String name) { + return EnumUtils.getEnum(ConnectorAction.class, name); + } + + @Override + public boolean isAlter() { + return ALTER_ACTIONS.contains(this); + } + + @Override + public PermissibleAction[] dependantActions() { + return dependantActions; + } + + public static String buildResourcePath(String connectName, String connectorName) { + return connectName + CONNECTOR_RESOURCE_DELIMITER + connectorName; + } +} \ No newline at end of file diff --git a/api/src/main/java/io/kafbat/ui/model/rbac/permission/PermissibleAction.java b/api/src/main/java/io/kafbat/ui/model/rbac/permission/PermissibleAction.java index a1394ac7e..67c0a64c7 100644 --- a/api/src/main/java/io/kafbat/ui/model/rbac/permission/PermissibleAction.java +++ b/api/src/main/java/io/kafbat/ui/model/rbac/permission/PermissibleAction.java @@ -5,7 +5,7 @@ public sealed interface PermissibleAction permits AclAction, ApplicationConfigAction, ConsumerGroupAction, SchemaAction, - ConnectAction, ClusterConfigAction, + ConnectAction, ConnectorAction, ClusterConfigAction, KsqlAction, TopicAction, AuditAction, ClientQuotaAction { String name(); diff --git a/api/src/test/java/io/kafbat/ui/model/rbac/AccessContextTest.java b/api/src/test/java/io/kafbat/ui/model/rbac/AccessContextTest.java index b1417f2c7..49f94d6b8 100644 --- a/api/src/test/java/io/kafbat/ui/model/rbac/AccessContextTest.java +++ b/api/src/test/java/io/kafbat/ui/model/rbac/AccessContextTest.java @@ -10,6 +10,7 @@ import io.kafbat.ui.model.rbac.AccessContext.SingleResourceAccess; import io.kafbat.ui.model.rbac.permission.ClusterConfigAction; import io.kafbat.ui.model.rbac.permission.ConnectAction; +import io.kafbat.ui.model.rbac.permission.ConnectorAction; import io.kafbat.ui.model.rbac.permission.PermissibleAction; import io.kafbat.ui.model.rbac.permission.TopicAction; import jakarta.annotation.Nullable; @@ -113,6 +114,74 @@ void shouldMapActionAliases() { assertThat(allowed).isTrue(); } + @Test + void allowsAccessForConnectorWithSpecificNameIfUserHasPermission() { + SingleResourceAccess sra = + new SingleResourceAccess("my-connect/my-connector", Resource.CONNECTOR, + List.of(ConnectorAction.VIEW, ConnectorAction.OPERATE)); + + var allowed = sra.isAccessible( + List.of( + permission(Resource.CONNECTOR, "my-connect/my-connector", + ConnectorAction.VIEW, ConnectorAction.OPERATE))); + + assertThat(allowed).isTrue(); + } + + @Test + void allowsAccessForConnectorWithWildcardPatternIfUserHasPermission() { + SingleResourceAccess sra = + new SingleResourceAccess("prod-connect/customer-connector", Resource.CONNECTOR, + List.of(ConnectorAction.VIEW)); + + var allowed = sra.isAccessible( + List.of( + permission(Resource.CONNECTOR, "prod-connect/.*", ConnectorAction.VIEW, ConnectorAction.EDIT))); + + assertThat(allowed).isTrue(); + } + + @Test + void deniesAccessForConnectorIfUserLacksRequiredPermission() { + SingleResourceAccess sra = + new SingleResourceAccess("my-connect/my-connector", Resource.CONNECTOR, + List.of(ConnectorAction.DELETE)); + + var allowed = sra.isAccessible( + List.of( + permission(Resource.CONNECTOR, "my-connect/my-connector", ConnectorAction.VIEW, ConnectorAction.EDIT))); + + assertThat(allowed).isFalse(); + } + + @Test + void allowsAccessForConnectorWithMultipleWildcardPatterns() { + SingleResourceAccess sra = + new SingleResourceAccess("staging-connect/debezium-mysql-connector", Resource.CONNECTOR, + List.of(ConnectorAction.RESET_OFFSETS)); + + var allowed = sra.isAccessible( + List.of( + permission(Resource.CONNECTOR, ".*/debezium-.*", ConnectorAction.RESET_OFFSETS), + permission(Resource.CONNECTOR, "staging-.*/.*", ConnectorAction.VIEW))); + + assertThat(allowed).isTrue(); + } + + @Test + void testConnectorActionHierarchy() { + // Test that EDIT includes VIEW permission + SingleResourceAccess sra = + new SingleResourceAccess("test-connect/test-connector", Resource.CONNECTOR, + List.of(ConnectorAction.VIEW)); + + var allowed = sra.isAccessible( + List.of( + permission(Resource.CONNECTOR, "test-connect/.*", ConnectorAction.EDIT))); + + assertThat(allowed).isTrue(); + } + private Permission permission(Resource res, @Nullable String namePattern, PermissibleAction... actions) { return permission( res, namePattern, Stream.of(actions).map(PermissibleAction::name).toList() @@ -130,4 +199,55 @@ private Permission permission(Resource res, @Nullable String namePattern, List roles = List.of( + getDevRole(), + getAdminRole() + ); + RoleBasedAccessControlProperties properties = mock(); + when(properties.getRoles()).thenReturn(roles); + + accessControlService = new AccessControlService(null, properties, environment); + accessControlService.init(); + + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(user); + } + + public void withSecurityContext(Runnable runnable) { + try (MockedStatic ctxHolder = Mockito.mockStatic( + ReactiveSecurityContextHolder.class)) { + // Mock static method to get security context + ctxHolder.when(ReactiveSecurityContextHolder::getContext) + .thenReturn(Mono.just(securityContext)); + runnable.run(); + } + } + + /** + * Test that a user with specific connector-level permission can view the connector. + */ + @Test + void validateAccess_withConnectorLevelPermission_allowed() { + withSecurityContext(() -> { + when(user.groups()).thenReturn(List.of(DEV_ROLE_NAME)); + AccessContext context = AccessContext.builder() + .cluster(CLUSTER_NAME) + .connectActions(CONNECT_NAME, ConnectAction.VIEW) + .operationParams(Map.of("connectorName", CONNECTOR_NAME)) + .build(); + Mono validateAccessMono = accessControlService.validateAccess(context); + StepVerifier.create(validateAccessMono) + .expectComplete() + .verify(); + }); + } + + /** + * Test that a user without specific connector-level permission is denied access. + */ + @Test + void validateAccess_withoutConnectorLevelPermission_denied() { + withSecurityContext(() -> { + when(user.groups()).thenReturn(List.of(DEV_ROLE_NAME)); + AccessContext context = AccessContext.builder() + .cluster(CLUSTER_NAME) + .connectActions(CONNECT_NAME, ConnectAction.VIEW) + .operationParams(Map.of("connectorName", ANOTHER_CONNECTOR_NAME)) + .build(); + Mono validateAccessMono = accessControlService.validateAccess(context); + StepVerifier.create(validateAccessMono) + .expectErrorMatches(e -> e instanceof AccessDeniedException) + .verify(); + }); + } + + /** + * Test that a user with wildcard connector permission can access any connector. + */ + @Test + void validateAccess_withWildcardConnectorPermission_allowed() { + withSecurityContext(() -> { + when(user.groups()).thenReturn(List.of(ADMIN_ROLE_NAME)); + AccessContext context = AccessContext.builder() + .cluster(CLUSTER_NAME) + .connectActions(CONNECT_NAME, ConnectAction.VIEW) + .operationParams(Map.of("connectorName", "any-connector-name")) + .build(); + Mono validateAccessMono = accessControlService.validateAccess(context); + StepVerifier.create(validateAccessMono) + .expectComplete() + .verify(); + }); + } + + /** + * Test that connector-level DELETE permission works. + */ + @Test + void validateAccess_withConnectorLevelDeletePermission_allowed() { + withSecurityContext(() -> { + when(user.groups()).thenReturn(List.of(DEV_ROLE_NAME)); + AccessContext context = AccessContext.builder() + .cluster(CLUSTER_NAME) + .connectActions(CONNECT_NAME, ConnectAction.DELETE) + .operationParams(Map.of("connectorName", CONNECTOR_NAME)) + .build(); + Mono validateAccessMono = accessControlService.validateAccess(context); + StepVerifier.create(validateAccessMono) + .expectComplete() + .verify(); + }); + } + + /** + * Test that fallback to connect-level permission works. + * Admin has CONNECT.OPERATE permission, which should allow access. + */ + @Test + void validateAccess_fallsBackToConnectPermission() { + withSecurityContext(() -> { + when(user.groups()).thenReturn(List.of(ADMIN_ROLE_NAME)); + // Admin has CONNECT.OPERATE but checking a connector not in wildcard + // The fallback to connect-level should allow access + AccessContext context = AccessContext.builder() + .cluster(CLUSTER_NAME) + .connectActions(CONNECT_NAME, ConnectAction.OPERATE) + .operationParams(Map.of("connectorName", "any-connector")) + .build(); + Mono validateAccessMono = accessControlService.validateAccess(context); + StepVerifier.create(validateAccessMono) + .expectComplete() + .verify(); + }); + } + + /** + * Dev role with specific connector-level permissions. + */ + public static Role getDevRole() { + Role role = new Role(); + role.setName(DEV_ROLE_NAME); + role.setClusters(List.of(CLUSTER_NAME)); + + Subject sub = new Subject(); + sub.setType("group"); + sub.setProvider(Provider.LDAP); + sub.setValue("dev.group"); + role.setSubjects(List.of(sub)); + + // Specific connector-level permission for "my-connector" + Permission specificConnectorPermission = new Permission(); + specificConnectorPermission.setResource(Resource.CONNECTOR.name()); + specificConnectorPermission.setActions(List.of( + ConnectorAction.VIEW.name(), + ConnectorAction.EDIT.name(), + ConnectorAction.DELETE.name() + )); + specificConnectorPermission.setValue( + ConnectorAction.buildResourcePath(CONNECT_NAME, CONNECTOR_NAME)); + + List permissions = List.of(specificConnectorPermission); + role.setPermissions(permissions); + role.validate(); + return role; + } + + /** + * Admin role with wildcard permissions on all connectors. + */ + public static Role getAdminRole() { + Role role = new Role(); + role.setName(ADMIN_ROLE_NAME); + role.setClusters(List.of(CLUSTER_NAME)); + + Subject sub = new Subject(); + sub.setType("group"); + sub.setProvider(Provider.LDAP); + sub.setValue("admin.group"); + role.setSubjects(List.of(sub)); + + // Wildcard connector-level permission + Permission wildcardConnectorPermission = new Permission(); + wildcardConnectorPermission.setResource(Resource.CONNECTOR.name()); + wildcardConnectorPermission.setActions(List.of( + ConnectorAction.VIEW.name(), + ConnectorAction.EDIT.name(), + ConnectorAction.OPERATE.name(), + ConnectorAction.DELETE.name(), + ConnectorAction.RESET_OFFSETS.name() + )); + wildcardConnectorPermission.setValue( + CONNECT_NAME + ConnectorAction.CONNECTOR_RESOURCE_DELIMITER + ".*"); + + // Also have connect-level permissions for backwards compatibility + Permission connectPermission = new Permission(); + connectPermission.setResource(Resource.CONNECT.name()); + connectPermission.setActions(List.of( + ConnectAction.VIEW.name(), + ConnectAction.EDIT.name(), + ConnectAction.OPERATE.name() + )); + connectPermission.setValue(CONNECT_NAME); + + List permissions = List.of(wildcardConnectorPermission, connectPermission); + role.setPermissions(permissions); + role.validate(); + return role; + } +} diff --git a/contract-typespec/api/config.tsp b/contract-typespec/api/config.tsp index 3490f2dcc..7875b2762 100644 --- a/contract-typespec/api/config.tsp +++ b/contract-typespec/api/config.tsp @@ -294,6 +294,7 @@ enum ResourceType { CONSUMER, SCHEMA, CONNECT, + CONNECTOR, KSQL, ACL, AUDIT, diff --git a/contract/src/main/resources/swagger/kafbat-ui-api.yaml b/contract/src/main/resources/swagger/kafbat-ui-api.yaml index dd0dce0d8..94f9559ca 100644 --- a/contract/src/main/resources/swagger/kafbat-ui-api.yaml +++ b/contract/src/main/resources/swagger/kafbat-ui-api.yaml @@ -4156,6 +4156,7 @@ components: - CONSUMER - SCHEMA - CONNECT + - CONNECTOR - KSQL - ACL - AUDIT diff --git a/frontend/src/components/Connect/Details/Actions/Actions.tsx b/frontend/src/components/Connect/Details/Actions/Actions.tsx index f1f2ff13d..1ed0164f4 100644 --- a/frontend/src/components/Connect/Details/Actions/Actions.tsx +++ b/frontend/src/components/Connect/Details/Actions/Actions.tsx @@ -20,7 +20,7 @@ import { } from 'lib/paths'; import { useConfirm } from 'lib/hooks/useConfirm'; import { Dropdown } from 'components/common/Dropdown'; -import { ActionDropdownItem } from 'components/common/ActionComponent'; +import { ActionDropdownItemWithFallback } from 'components/common/ActionComponent'; import ChevronDownIcon from 'components/common/Icons/ChevronDownIcon'; import * as S from './Action.styled'; @@ -75,6 +75,8 @@ const Actions: React.FC = () => { () => resetConnectorOffsetsMutation.mutateAsync() ); + const connectorPath = `${routerProps.connectName}/${routerProps.connectorName}`; + return ( { } > {connector?.status.state === ConnectorState.RUNNING && ( - Pause - + )} {connector?.status.state === ConnectorState.RUNNING && ( - Stop - + )} {(connector?.status.state === ConnectorState.PAUSED || connector?.status.state === ConnectorState.STOPPED) && ( - Resume - + )} - Restart Connector - - + Restart All Tasks - - + Restart Failed Tasks - + - Reset Offsets - - + Delete - + ); diff --git a/frontend/src/components/Connect/Details/Tasks/ActionsCellTasks.tsx b/frontend/src/components/Connect/Details/Tasks/ActionsCellTasks.tsx index 8055456a4..a87495cf3 100644 --- a/frontend/src/components/Connect/Details/Tasks/ActionsCellTasks.tsx +++ b/frontend/src/components/Connect/Details/Tasks/ActionsCellTasks.tsx @@ -4,7 +4,7 @@ import { CellContext } from '@tanstack/react-table'; import useAppParams from 'lib/hooks/useAppParams'; import { useRestartConnectorTask } from 'lib/hooks/api/kafkaConnect'; import { Dropdown } from 'components/common/Dropdown'; -import { ActionDropdownItem } from 'components/common/ActionComponent'; +import { ActionDropdownItemWithFallback } from 'components/common/ActionComponent'; import { RouterParamsClusterConnectConnector } from 'lib/paths'; const ActionsCellTasks: React.FC> = ({ row }) => { @@ -17,20 +17,29 @@ const ActionsCellTasks: React.FC> = ({ row }) => { restartMutation.mutateAsync(taskId); }; + const connectorPath = `${routerProps.connectName}/${routerProps.connectorName}`; + return ( - restartTaskHandler(id?.task)} danger confirm="Are you sure you want to restart the task?" - permission={{ - resource: ResourceType.CONNECT, - action: Action.OPERATE, - value: routerProps.connectName, - }} + permission={[ + { + resource: ResourceType.CONNECTOR, + action: Action.OPERATE, + value: connectorPath, + }, + { + resource: ResourceType.CONNECT, + action: Action.OPERATE, + value: routerProps.connectName, + }, + ]} > Restart task - + ); }; diff --git a/frontend/src/components/Connect/List/ConnectorsTable/connectorsColumns/cells/ActionsCell.tsx b/frontend/src/components/Connect/List/ConnectorsTable/connectorsColumns/cells/ActionsCell.tsx index 8529b58f9..1e90ee9cc 100644 --- a/frontend/src/components/Connect/List/ConnectorsTable/connectorsColumns/cells/ActionsCell.tsx +++ b/frontend/src/components/Connect/List/ConnectorsTable/connectorsColumns/cells/ActionsCell.tsx @@ -17,7 +17,7 @@ import { } from 'lib/hooks/api/kafkaConnect'; import { useConfirm } from 'lib/hooks/useConfirm'; import { useIsMutating } from '@tanstack/react-query'; -import { ActionDropdownItem } from 'components/common/ActionComponent'; +import ActionDropdownItemWithFallback from 'components/common/ActionComponent/ActionDropDownItem/ActionDropdownItemWithFallback'; import ClusterContext from 'components/contexts/ClusterContext'; const ActionsCell: React.FC> = ({ @@ -88,100 +88,156 @@ const ActionsCell: React.FC> = ({ {(status.state === ConnectorState.PAUSED || status.state === ConnectorState.STOPPED) && ( - Resume - + )} {status.state === ConnectorState.RUNNING && ( - Pause - + )} {status.state === ConnectorState.RUNNING && ( - Stop - + )} - Restart Connector - - + Restart All Tasks - - + Restart Failed Tasks - - + Reset Offsets - - + Delete - + ); }; diff --git a/frontend/src/components/common/ActionComponent/ActionDropDownItem/ActionDropdownItemWithFallback.tsx b/frontend/src/components/common/ActionComponent/ActionDropDownItem/ActionDropdownItemWithFallback.tsx new file mode 100644 index 000000000..e13816ff0 --- /dev/null +++ b/frontend/src/components/common/ActionComponent/ActionDropDownItem/ActionDropdownItemWithFallback.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { usePermission } from 'lib/hooks/usePermission'; +import { DropdownItemProps } from 'components/common/Dropdown/DropdownItem'; +import { + ActionComponentProps, + getDefaultActionMessage, +} from 'components/common/ActionComponent/ActionComponent'; + +import ActionDropdownItem from './ActionDropdownItem'; + +interface Props + extends Omit, + DropdownItemProps { + permission: + | ActionComponentProps['permission'] + | ActionComponentProps['permission'][]; +} + +/** + * ActionDropdownItem that supports multiple permission checks. + * If an array of permissions is provided, it will check them in order + * and use the first one that grants access. + */ +const ActionDropdownItemWithFallback: React.FC = ({ + permission, + message = getDefaultActionMessage(), + placement = 'left', + children, + disabled, + ...props +}) => { + const permissions = Array.isArray(permission) ? permission : [permission]; + + // Check all permissions upfront to avoid conditional hook calls + const permissionResults = permissions.map((perm) => + // eslint-disable-next-line react-hooks/rules-of-hooks + usePermission(perm.resource, perm.action, perm.value) + ); + + // Find the first permission that grants access + let effectivePermission = permissions[0]; + const hasAnyPermission = permissionResults.some((result, index) => { + if (result) { + effectivePermission = permissions[index]; + return true; + } + return false; + }); + + // If no permissions granted, the ActionDropdownItem will handle hiding + return ( + + {children} + + ); +}; + +export default ActionDropdownItemWithFallback; diff --git a/frontend/src/components/common/ActionComponent/index.ts b/frontend/src/components/common/ActionComponent/index.ts index 707083b6e..442e72f6c 100644 --- a/frontend/src/components/common/ActionComponent/index.ts +++ b/frontend/src/components/common/ActionComponent/index.ts @@ -3,6 +3,7 @@ import ActionButton from './ActionButton/ActionButton'; import ActionCanButton from './ActionButton/ActionCanButton/ActionCanButton'; import ActionNavLink from './ActionNavLink/ActionNavLink'; import ActionDropdownItem from './ActionDropDownItem/ActionDropdownItem'; +import ActionDropdownItemWithFallback from './ActionDropDownItem/ActionDropdownItemWithFallback'; import ActionPermissionWrapper from './ActionPermissionWrapper/ActionPermissionWrapper'; export { @@ -11,5 +12,6 @@ export { ActionCanButton, ActionButton, ActionDropdownItem, + ActionDropdownItemWithFallback, ActionPermissionWrapper, }; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index df563b834..17e3ec56c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ cel = '0.3.0' junit = '5.12.2' mockito = '5.20.0' okhttp3 = '4.12.0' -testcontainers = '1.20.6' +testcontainers = '2.0.2' swagger-integration-jakarta = '2.2.28' jakarta-annotation-api = '2.1.1' jackson-databind-nullable = '0.2.6' @@ -97,8 +97,8 @@ cel = { module = 'dev.cel:cel', version.ref = 'cel' } caffeine = { module = 'com.github.ben-manes.caffeine:caffeine', version = '3.2.2'} testcontainers = { module = 'org.testcontainers:testcontainers', version.ref = 'testcontainers' } -testcontainers-kafka = { module = 'org.testcontainers:kafka', version.ref = 'testcontainers' } -testcontainers-jupiter = { module = 'org.testcontainers:junit-jupiter', version.ref = 'testcontainers' } +testcontainers-kafka = { module = 'org.testcontainers:testcontainers-kafka', version.ref = 'testcontainers' } +testcontainers-jupiter = { module = 'org.testcontainers:testcontainers-junit-jupiter', version.ref = 'testcontainers' } junit-jupiter-engine = { module = 'org.junit.jupiter:junit-jupiter-engine', version.ref = 'junit' }