Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve JWT format with namespaces grouping #500

Merged
merged 5 commits into from
Jan 14, 2025
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
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
Loading