Skip to content

Commit

Permalink
Improve JWT format with namespaces grouping (#500)
Browse files Browse the repository at this point in the history
  • Loading branch information
ThomasCAI-mlv authored Jan 14, 2025
1 parent 83a6e68 commit 319cf6b
Show file tree
Hide file tree
Showing 11 changed files with 328 additions and 50 deletions.
147 changes: 147 additions & 0 deletions .docker/resources/admin/another-namespace2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: anotherNamespace
cluster: local
labels:
contacts: [email protected]
spec:
kafkaUser: user2
connectClusters:
- local
topicValidator:
validationConstraints:
partitions:
validation-type: Range
min: 1
max: 6
replication.factor:
validation-type: Range
min: 1
max: 1
min.insync.replicas:
validation-type: Range
min: 1
max: 1
retention.ms:
optional: true
validation-type: Range
min: 60000
max: 604800000
cleanup.policy:
validation-type: ValidList
validStrings:
- delete
- compact
connectValidator:
validationConstraints:
key.converter:
validation-type: NonEmptyString
value.converter:
validation-type: NonEmptyString
connector.class:
validation-type: ValidString
validStrings:
- io.confluent.connect.jdbc.JdbcSinkConnector
- io.confluent.connect.jdbc.JdbcSourceConnector
- io.confluent.kafka.connect.datagen.DatagenConnector
classValidationConstraints:
io.confluent.kafka.connect.datagen.DatagenConnector:
schema.string:
validation-type: NonEmptyString
schema.keyfield:
validation-type: NonEmptyString
---
apiVersion: v1
kind: RoleBinding
metadata:
name: anotherRoleBinding1
namespace: anotherNamespace
spec:
role:
resourceTypes:
- schemas
- schemas/config
- topics
- topics/import
- topics/delete-records
- connectors
- connectors/import
- connectors/change-state
- connect-clusters
- connect-clusters/vaults
- acls
- consumer-groups/reset
- streams
verbs:
- GET
- POST
- PUT
- DELETE
subject:
subjectType: GROUP
subjectName: DEV
---
apiVersion: v1
kind: RoleBinding
metadata:
name: anotherRoleBinding2
namespace: anotherNamespace
spec:
role:
resourceTypes:
- quota
verbs:
- GET
subject:
subjectType: GROUP
subjectName: DEV
---
apiVersion: v1
kind: AccessControlEntry
metadata:
name: anotherTopicAcl
namespace: anotherNamespace
spec:
resourceType: TOPIC
resource: def.
resourcePatternType: PREFIXED
permission: OWNER
grantedTo: anotherNamespace
---
apiVersion: v1
kind: AccessControlEntry
metadata:
name: anotherGroupAcl
namespace: anotherNamespace
spec:
resourceType: GROUP
resource: def.
resourcePatternType: PREFIXED
permission: OWNER
grantedTo: anotherNamespace
---
apiVersion: v1
kind: AccessControlEntry
metadata:
name: anotherConnectAcl
namespace: anotherNamespace
spec:
resourceType: CONNECT
resource: def.
resourcePatternType: PREFIXED
permission: OWNER
grantedTo: anotherNamespace
---
apiVersion: v1
kind: AccessControlEntry
metadata:
name: anotherConnectClusterAcl
namespace: anotherNamespace
spec:
resourceType: CONNECT_CLUSTER
resource: def.
resourcePatternType: PREFIXED
permission: OWNER
grantedTo: anotherNamespace
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ The delivered JWT token will have the following format:
{
"roleBindings": [
{
"namespace": "myNamespace",
"namespaces": ["myNamespace"],
"verbs": [
"GET",
"POST",
Expand All @@ -165,16 +165,14 @@ The delivered JWT token will have the following format:
"schemas",
"schemas/config",
"topics",
"topics/import",
"topics/delete-records",
"connectors",
"connectors/import",
"connectors/change-state",
"connect-clusters",
"connect-clusters/vaults",
"acls",
"consumer-groups/reset",
"streams"
"streams",
"connect-clusters",
"connect-clusters/vaults"
]
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import com.michelin.ns4kafka.model.RoleBinding;
import com.michelin.ns4kafka.property.SecurityProperties;
import com.michelin.ns4kafka.repository.NamespaceRepository;
import com.michelin.ns4kafka.repository.RoleBindingRepository;
import com.michelin.ns4kafka.security.auth.AuthenticationInfo;
import com.michelin.ns4kafka.security.auth.AuthenticationRoleBinding;
import com.michelin.ns4kafka.util.exception.ForbiddenNamespaceException;
Expand Down Expand Up @@ -44,9 +43,6 @@ public class ResourceBasedSecurityRule implements SecurityRule<HttpRequest<?>> {
@Inject
SecurityProperties securityProperties;

@Inject
RoleBindingRepository roleBindingRepository;

@Inject
NamespaceRepository namespaceRepository;

Expand Down Expand Up @@ -107,10 +103,12 @@ public SecurityRuleResult checkSecurity(HttpRequest<?> request, @Nullable Authen

AuthenticationInfo authenticationInfo = AuthenticationInfo.of(authentication);

// No role binding for the target namespace. User is targeting a namespace that he is not allowed to access
// No role binding for the target namespace: the user is not allowed to access the target namespace
List<AuthenticationRoleBinding> namespaceRoleBindings = authenticationInfo.getRoleBindings()
.stream()
.filter(roleBinding -> roleBinding.getNamespace().equals(namespace))
.filter(roleBinding -> roleBinding.getNamespaces()
.stream()
.anyMatch(ns -> ns.equals(namespace)))
.toList();

if (namespaceRoleBindings.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
@NoArgsConstructor
@AllArgsConstructor
public class AuthenticationRoleBinding {
private String namespace;
private List<String> namespaces;
private List<RoleBinding.Verb> verbs;
private List<String> resourceTypes;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;

/**
Expand All @@ -23,7 +24,6 @@
@Singleton
public class AuthenticationService {


@Inject
ResourceBasedSecurityRule resourceBasedSecurityRule;

Expand All @@ -47,14 +47,25 @@ public AuthenticationResponse buildAuthJwtGroups(String username, List<String> g
throw new AuthenticationException(new AuthenticationFailed("No namespace matches your groups"));
}

return AuthenticationResponse.success(username, resourceBasedSecurityRule.computeRolesFromGroups(groups),
return AuthenticationResponse.success(
username,
resourceBasedSecurityRule.computeRolesFromGroups(groups),
Map.of(ROLE_BINDINGS, roleBindings
.stream()
.map(roleBinding -> AuthenticationRoleBinding.builder()
.namespace(roleBinding.getMetadata().getNamespace())
.verbs(new ArrayList<>(roleBinding.getSpec().getRole().getVerbs()))
.resourceTypes(new ArrayList<>(roleBinding.getSpec().getRole().getResourceTypes()))
// group the namespaces by roles in a mapping
.collect(Collectors.groupingBy(
roleBinding -> roleBinding.getSpec().getRole(),
Collectors.mapping(roleBinding -> roleBinding.getMetadata().getNamespace(), Collectors.toList())
))
// build JWT with a list of namespaces for each different role
.entrySet()
.stream()
.map(entry -> AuthenticationRoleBinding.builder()
.namespaces(entry.getValue())
.verbs(new ArrayList<>(entry.getKey().getVerbs()))
.resourceTypes(new ArrayList<>(entry.getKey().getResourceTypes()))
.build())
.toList()));
.toList())
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

@ExtendWith(MockitoExtension.class)
class ResourceBasedSecurityRuleTest {
private static final String NAMESPACE = "namespace";
private static final String NAMESPACES = "namespaces";
private static final String VERBS = "verbs";
private static final String RESOURCE_TYPES = "resourceTypes";

Expand Down Expand Up @@ -133,7 +133,7 @@ void checkReturnsAllowedNamespaceAsAdmin() {
"topics,/api/namespaces/test/topics/topic.with.dots"})
void shouldReturnAllowedWhenHyphenAndDotResourcesAndHandleRoleBindingsType(String resourceType, String path) {
List<Map<String, ?>> jwtRoleBindings = List.of(
Map.of(NAMESPACE, "test",
Map.of(NAMESPACES, List.of("test"),
VERBS, List.of(GET),
RESOURCE_TYPES, List.of(resourceType)));

Expand All @@ -148,7 +148,7 @@ void shouldReturnAllowedWhenHyphenAndDotResourcesAndHandleRoleBindingsType(Strin

List<AuthenticationRoleBinding> basicAuthRoleBindings = List.of(
AuthenticationRoleBinding.builder()
.namespace("test")
.namespaces(List.of("test"))
.verbs(List.of(GET))
.resourceTypes(List.of(resourceType))
.build());
Expand All @@ -167,7 +167,7 @@ void shouldReturnAllowedWhenHyphenAndDotResourcesAndHandleRoleBindingsType(Strin
@Test
void shouldReturnAllowedWhenSubResource() {
List<Map<String, ?>> jwtRoleBindings = List.of(
Map.of(NAMESPACE, "test",
Map.of(NAMESPACES, List.of("test"),
VERBS, List.of(GET),
RESOURCE_TYPES, List.of("connectors/restart", "topics/delete-records")));

Expand All @@ -192,7 +192,7 @@ void shouldReturnAllowedWhenSubResource() {
@CsvSource({"namespace", "name-space", "name.space", "_name_space_", "namespace123"})
void shouldReturnAllowedWhenSpecialNamespaceName(String namespace) {
List<Map<String, ?>> roleBindings = List.of(
Map.of(NAMESPACE, namespace,
Map.of(NAMESPACES, List.of(namespace),
VERBS, List.of(GET),
RESOURCE_TYPES, List.of("topics")));

Expand All @@ -207,6 +207,45 @@ void shouldReturnAllowedWhenSpecialNamespaceName(String namespace) {
assertEquals(SecurityRuleResult.ALLOWED, actual);
}

@Test
void shouldReturnAllowedWhenMultipleNamespaces() {
List<Map<String, ?>> roleBindings = List.of(
Map.of(NAMESPACES, List.of("ns1", "ns2", "ns3"),
VERBS, List.of(GET),
RESOURCE_TYPES, List.of("topics")));

Map<String, Object> claims = Map.of(SUBJECT, "user", ROLES, List.of(), ROLE_BINDINGS, roleBindings);
Authentication auth = Authentication.build("user", claims);

when(namespaceRepository.findByName("ns3"))
.thenReturn(Optional.of(Namespace.builder().build()));

SecurityRuleResult actual =
resourceBasedSecurityRule.checkSecurity(HttpRequest.GET("/api/namespaces/ns3/topics"), auth);
assertEquals(SecurityRuleResult.ALLOWED, actual);
}

@Test
void shouldReturnAllowedWhenMultipleVerbsResourceTypesCombinations() {
List<Map<String, ?>> roleBindings = List.of(
Map.of(NAMESPACES, List.of("ns1"),
VERBS, List.of(GET),
RESOURCE_TYPES, List.of("topics")),
Map.of(NAMESPACES, List.of("ns2"),
VERBS, List.of(GET),
RESOURCE_TYPES, List.of("connectors")));

Map<String, Object> claims = Map.of(SUBJECT, "user", ROLES, List.of(), ROLE_BINDINGS, roleBindings);
Authentication auth = Authentication.build("user", claims);

when(namespaceRepository.findByName("ns2"))
.thenReturn(Optional.of(Namespace.builder().build()));

SecurityRuleResult actual =
resourceBasedSecurityRule.checkSecurity(HttpRequest.GET("/api/namespaces/ns2/connectors"), auth);
assertEquals(SecurityRuleResult.ALLOWED, actual);
}

@Test
void shouldReturnForbiddenNamespaceWhenNoRoleBinding() {
Map<String, Object> claims = Map.of(SUBJECT, "user", ROLES, List.of(), ROLE_BINDINGS, List.of());
Expand All @@ -226,7 +265,7 @@ void shouldReturnForbiddenNamespaceWhenNoRoleBinding() {
@Test
void shouldReturnForbiddenNamespaceWhenNoRoleBindingMatchingRequestedNamespace() {
List<Map<String, ?>> roleBindings = List.of(
Map.of(NAMESPACE, "namespace",
Map.of(NAMESPACES, List.of("namespace"),
VERBS, List.of(GET),
RESOURCE_TYPES, List.of("connectors")));

Expand All @@ -247,7 +286,7 @@ void shouldReturnForbiddenNamespaceWhenNoRoleBindingMatchingRequestedNamespace()
@Test
void checkReturnsUnknownSubResource() {
List<Map<String, ?>> roleBindings = List.of(
Map.of(NAMESPACE, "test",
Map.of(NAMESPACES, List.of("test"),
VERBS, List.of(GET),
RESOURCE_TYPES, List.of("connectors")));

Expand All @@ -266,7 +305,7 @@ void checkReturnsUnknownSubResource() {
@Test
void checkReturnsUnknownSubResourceWithDot() {
List<Map<String, ?>> roleBindings = List.of(
Map.of(NAMESPACE, "test",
Map.of(NAMESPACES, List.of("test"),
VERBS, List.of(GET),
RESOURCE_TYPES, List.of("connectors")));

Expand Down
Loading

0 comments on commit 319cf6b

Please sign in to comment.