Skip to content
Open
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
96 changes: 95 additions & 1 deletion api/src/main/java/io/kafbat/ui/model/rbac/AccessContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -81,6 +82,37 @@ public boolean isAccessible(List<Permission> 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<PermissibleAction> requestedActions() {
return primary.requestedActions();
}

@Override
public boolean isAccessible(List<Permission> userPermissions) {
return primary.isAccessible(userPermissions)
|| fallback.isAccessible(userPermissions);
}
}

public static AccessContextBuilder builder() {
return new AccessContextBuilder();
}
Expand Down Expand Up @@ -176,7 +208,69 @@ public AccessContextBuilder operationParams(Map<String, Object> paramsMap) {
}

public AccessContext build() {
return new AccessContext(cluster, accessedResources, operationName, operationParams);
List<ResourceAccess> 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<ResourceAccess> applyConnectorFallback(List<ResourceAccess> resources) {
String connectorName = extractConnectorName();
if (connectorName == null) {
return resources;
}

List<ResourceAccess> 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<PermissibleAction> 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;
};
}
}
}
3 changes: 3 additions & 0 deletions api/src/main/java/io/kafbat/ui/model/rbac/Resource.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,6 +37,8 @@ public enum Resource {

CONNECT(ConnectAction.values(), ConnectAction.ALIASES),

CONNECTOR(ConnectorAction.values()),

KSQL(KsqlAction.values()),

ACL(AclAction.values()),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ConnectorAction> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
public sealed interface PermissibleAction permits
AclAction, ApplicationConfigAction,
ConsumerGroupAction, SchemaAction,
ConnectAction, ClusterConfigAction,
ConnectAction, ConnectorAction, ClusterConfigAction,
KsqlAction, TopicAction, AuditAction, ClientQuotaAction {

String name();
Expand Down
120 changes: 120 additions & 0 deletions api/src/test/java/io/kafbat/ui/model/rbac/AccessContextTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand All @@ -130,4 +199,55 @@ private Permission permission(Resource res, @Nullable String namePattern, List<S
}
}

@Nested
class FallbackResourceAccessTest {

@Test
void returnsTrueIfPrimaryIsAccessible() {
ResourceAccess primary = mock(ResourceAccess.class);
when(primary.isAccessible(any())).thenReturn(true);

ResourceAccess fallback = mock(ResourceAccess.class);
when(fallback.isAccessible(any())).thenReturn(false);

var fra = new AccessContext.FallbackResourceAccess(primary, fallback);
assertThat(fra.isAccessible(List.of())).isTrue();
}

@Test
void returnsTrueIfFallbackIsAccessible() {
ResourceAccess primary = mock(ResourceAccess.class);
when(primary.isAccessible(any())).thenReturn(false);

ResourceAccess fallback = mock(ResourceAccess.class);
when(fallback.isAccessible(any())).thenReturn(true);

var fra = new AccessContext.FallbackResourceAccess(primary, fallback);
assertThat(fra.isAccessible(List.of())).isTrue();
}

@Test
void returnsFalseIfBothAreNotAccessible() {
ResourceAccess primary = mock(ResourceAccess.class);
when(primary.isAccessible(any())).thenReturn(false);

ResourceAccess fallback = mock(ResourceAccess.class);
when(fallback.isAccessible(any())).thenReturn(false);

var fra = new AccessContext.FallbackResourceAccess(primary, fallback);
assertThat(fra.isAccessible(List.of())).isFalse();
}

@Test
void delegatesResourceIdToPrimary() {
ResourceAccess primary = mock(ResourceAccess.class);
when(primary.resourceId()).thenReturn("primary-id");

ResourceAccess fallback = mock(ResourceAccess.class);

var fra = new AccessContext.FallbackResourceAccess(primary, fallback);
assertThat(fra.resourceId()).isEqualTo("primary-id");
}
}

}
Loading
Loading