Skip to content

Commit 333770f

Browse files
authored
Feature/attribute security read filter (#13)
* Changed bean creation to static instantiation. * Added unique constraints. Implemented equals and hashCode functions. * Created service to check attribute permissions. Added result filtering to attribute-value service. * Updated README.md.
1 parent f7366f3 commit 333770f

19 files changed

+268
-49
lines changed

.scripts/001-init-db-schema.sql

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
CREATE SCHEMA IF NOT EXISTS "public";
2+
CREATE SCHEMA IF NOT EXISTS "security";

.scripts/002-test-data.sql

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
INSERT INTO "security"."role" (id, "name") VALUES (nextval('security.role_id_seq'), 'READER');
2+
INSERT INTO "security"."role" (id, "name") VALUES (nextval('security.role_id_seq'), 'ADMINISTRATOR');
3+
INSERT INTO "security"."role" (id, "name") VALUES (nextval('security.role_id_seq'), 'TEST_ROLE_1');
4+
INSERT INTO "security"."role" (id, "name") VALUES (nextval('security.role_id_seq'), 'TEST_ROLE_2');
5+
6+
INSERT INTO "security"."user" (id, email, email_confirmed, enabled, expire_at, "password", username)
7+
VALUES(nextval('security.user_id_seq'), 'admin@eav.eav', true, true, '2030-12-31 00:00:00.000', '$2a$10$iz4lSRkDXgzjBCZupy9IS.D0RfI5HJK9eoCES.YWHwbtj/mGVA0M6', 'admin@eav.eav');
8+
INSERT INTO "security"."user" (id, email, email_confirmed, enabled, expire_at, "password", username)
9+
VALUES(nextval('security.user_id_seq'), 'reader@eav.eav', true, true, '2030-12-31 00:00:00.000', '$2a$10$iz4lSRkDXgzjBCZupy9IS.D0RfI5HJK9eoCES.YWHwbtj/mGVA0M6', 'reader@eav.eav');
10+
11+
INSERT INTO "security"."user_role" (user_id, role_id)
12+
VALUES ((SELECT id FROM "security"."user" WHERE username = 'admin@eav.eav'), (SELECT id from "security"."role" WHERE "name" = 'ADMINISTRATOR'));
13+
INSERT INTO "security"."user_role" (user_id, role_id)
14+
VALUES ((SELECT id FROM "security"."user" WHERE username = 'reader@eav.eav'), (SELECT id from "security"."role" WHERE "name" = 'READER'));
15+
16+
INSERT INTO "public"."entity_type" (id, "name", "description") VALUES (nextval('public.entity_type_id_seq'), 'car', NULL);
17+
INSERT INTO "public"."entity_type" (id, "name", "description") VALUES (nextval('public.entity_type_id_seq'), 'book', NULL);
18+
19+
INSERT INTO "public"."attribute" (id, description, "name", entity_type_id) VALUES(nextval('public.attribute_id_seq'), NULL, 'make', (SELECT id FROM "public"."entity_type" WHERE "name" = 'car'));
20+
INSERT INTO "public"."attribute" (id, description, "name", entity_type_id) VALUES(nextval('public.attribute_id_seq'), NULL, 'model', (SELECT id FROM "public"."entity_type" WHERE "name" = 'car'));
21+
INSERT INTO "public"."attribute" (id, description, "name", entity_type_id) VALUES(nextval('public.attribute_id_seq'), NULL, 'color', (SELECT id FROM "public"."entity_type" WHERE "name" = 'car'));
22+
23+
INSERT INTO "public"."attribute" (id, description, "name", entity_type_id) VALUES(nextval('public.attribute_id_seq'), NULL, 'isbn', (SELECT id FROM "public"."entity_type" WHERE "name" = 'book'));
24+
INSERT INTO "public"."attribute" (id, description, "name", entity_type_id) VALUES(nextval('public.attribute_id_seq'), NULL, 'author', (SELECT id FROM "public"."entity_type" WHERE "name" = 'book'));
25+
INSERT INTO "public"."attribute" (id, description, "name", entity_type_id) VALUES(nextval('public.attribute_id_seq'), NULL, 'title', (SELECT id FROM "public"."entity_type" WHERE "name" = 'book'));
26+
INSERT INTO "public"."attribute" (id, description, "name", entity_type_id) VALUES(nextval('public.attribute_id_seq'), NULL, 'publish_year', (SELECT id FROM "public"."entity_type" WHERE "name" = 'book'));
27+
28+
INSERT INTO public.metadata (id, data_type, max_length, max_value, min_length, min_value, "repeatable", required, sub_attribute_ids)
29+
VALUES((SELECT id FROM "public"."attribute" WHERE "name" = 'make' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'car')), 'STRING', NULL, NULL, NULL, NULL, false, true, '[]'::jsonb);
30+
INSERT INTO public.metadata (id, data_type, max_length, max_value, min_length, min_value, "repeatable", required, sub_attribute_ids)
31+
VALUES((SELECT id FROM "public"."attribute" WHERE "name" = 'model' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'car')), 'STRING', NULL, NULL, NULL, NULL, false, true, '[]'::jsonb);
32+
INSERT INTO public.metadata (id, data_type, max_length, max_value, min_length, min_value, "repeatable", required, sub_attribute_ids)
33+
VALUES((SELECT id FROM "public"."attribute" WHERE "name" = 'color' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'car')), 'STRING', NULL, NULL, NULL, NULL, false, true, '[]'::jsonb);
34+
35+
INSERT INTO public.metadata (id, data_type, max_length, max_value, min_length, min_value, "repeatable", required, sub_attribute_ids)
36+
VALUES((SELECT id FROM "public"."attribute" WHERE "name" = 'isbn' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'book')), 'STRING', NULL, NULL, NULL, NULL, false, true, '[]'::jsonb);
37+
INSERT INTO public.metadata (id, data_type, max_length, max_value, min_length, min_value, "repeatable", required, sub_attribute_ids)
38+
VALUES((SELECT id FROM "public"."attribute" WHERE "name" = 'author' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'book')), 'STRING', NULL, NULL, NULL, NULL, false, true, '[]'::jsonb);
39+
INSERT INTO public.metadata (id, data_type, max_length, max_value, min_length, min_value, "repeatable", required, sub_attribute_ids)
40+
VALUES((SELECT id FROM "public"."attribute" WHERE "name" = 'title' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'book')), 'STRING', NULL, NULL, NULL, NULL, false, true, '[]'::jsonb);
41+
INSERT INTO public.metadata (id, data_type, max_length, max_value, min_length, min_value, "repeatable", required, sub_attribute_ids)
42+
VALUES((SELECT id FROM "public"."attribute" WHERE "name" = 'publish_year' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'book')), 'INTEGER', NULL, NULL, NULL, NULL, false, false, '[]'::jsonb);
43+
44+
INSERT INTO "security"."attribute_permission" (actions, attribute_id, role_id)
45+
VALUES('["READ"]'::jsonb, (SELECT id FROM "public"."attribute" WHERE "name" = 'make' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'car')), (SELECT id FROM "security"."role" WHERE "name" = 'READER'));
46+
INSERT INTO "security"."attribute_permission" (actions, attribute_id, role_id)
47+
VALUES('["READ"]'::jsonb, (SELECT id FROM "public"."attribute" WHERE "name" = 'model' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'car')), (SELECT id FROM "security"."role" WHERE "name" = 'READER'));
48+
INSERT INTO "security"."attribute_permission" (actions, attribute_id, role_id)
49+
VALUES('["READ"]'::jsonb, (SELECT id FROM "public"."attribute" WHERE "name" = 'isbn' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'book')), (SELECT id FROM "security"."role" WHERE "name" = 'READER'));
50+
INSERT INTO "security"."attribute_permission" (actions, attribute_id, role_id)
51+
VALUES('["READ"]'::jsonb, (SELECT id FROM "public"."attribute" WHERE "name" = 'author' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'book')), (SELECT id FROM "security"."role" WHERE "name" = 'READER'));
52+
INSERT INTO "security"."attribute_permission" (actions, attribute_id, role_id)
53+
VALUES('["READ"]'::jsonb, (SELECT id FROM "public"."attribute" WHERE "name" = 'title' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'book')), (SELECT id FROM "security"."role" WHERE "name" = 'READER'));
54+
INSERT INTO "security"."attribute_permission" (actions, attribute_id, role_id)
55+
VALUES('["READ"]'::jsonb, (SELECT id FROM "public"."attribute" WHERE "name" = 'publish_year' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'book')), (SELECT id FROM "security"."role" WHERE "name" = 'READER'));
56+
INSERT INTO "security"."attribute_permission" (actions, attribute_id, role_id)
57+
VALUES('["READ", "CREATE"]'::jsonb, (SELECT id FROM "public"."attribute" WHERE "name" = 'make' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'car')), (SELECT id FROM "security"."role" WHERE "name" = 'ADMINISTRATOR'));
58+
INSERT INTO "security"."attribute_permission" (actions, attribute_id, role_id)
59+
VALUES('["READ", "CREATE", "UPDATE", "DELETE"]'::jsonb, (SELECT id FROM "public"."attribute" WHERE "name" = 'model' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'car')), (SELECT id FROM "security"."role" WHERE "name" = 'ADMINISTRATOR'));
60+
INSERT INTO "security"."attribute_permission" (actions, attribute_id, role_id)
61+
VALUES('["READ", "CREATE", "DELETE"]'::jsonb, (SELECT id FROM "public"."attribute" WHERE "name" = 'color' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'car')), (SELECT id FROM "security"."role" WHERE "name" = 'ADMINISTRATOR'));
62+
63+
INSERT INTO "public"."entity" (id, entity_type_id) VALUES (nextval('public.entity_id_seq'), (SELECT id FROM "public"."entity_type" WHERE "name" = 'car'));
64+
65+
INSERT INTO "public"."attribute_value" (value, "position", attribute_id, entity_id)
66+
VALUES('Ford', NULL, (SELECT id FROM "public"."attribute" WHERE "name" = 'make' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'car')), (SELECT id from "public"."entity" LIMIT 1));
67+
INSERT INTO "public"."attribute_value" (value, "position", attribute_id, entity_id)
68+
VALUES('Puma', NULL, (SELECT id FROM "public"."attribute" WHERE "name" = 'model' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'car')), (SELECT id from "public"."entity" LIMIT 1));
69+
INSERT INTO "public"."attribute_value" (value, "position", attribute_id, entity_id)
70+
VALUES('red', NULL, (SELECT id FROM "public"."attribute" WHERE "name" = 'color' AND entity_type_id = (SELECT id FROM "public"."entity_type" WHERE "name" = 'car')), (SELECT id from "public"."entity" LIMIT 1));

README.md

+24-3
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010

1111
## Features (details)
1212

13-
### JWT Authentication
13+
### Security: Authentication & Authorization
1414
- Uses JWT tokens for user authentication and authorization.
1515
- Implements refresh tokens to extend JWT validity without requiring reauthentication.
1616
- Provides enhanced security by reducing token exposure and improving session management.
17+
- Role-Based Access Control (RBAC)
18+
- Row-Level data protection
1719
#### Description
1820
- **_User Registration:_**
1921
- Users can register using their email and password.
@@ -24,12 +26,31 @@
2426
- **_User Login:_**
2527
- Users log in using their email (username) and password.
2628
- Upon successful authentication, the user receives a JWT access token and a refresh token.
27-
29+
- **_Role-Based Access Control (RBAC) with attribute level permissions_**: TODO
2830
### Data Management with Spring Data JPA
2931
- Data model is implemented using Spring JPA (Java Persistence API).
3032
- Repository interfaces extend `JpaRepository`, providing methods for common database operations.
3133
- For enhanced performance and optimized data retrieval, `Entity graphs` is leveraged in certain scenarios.
3234

3335
## Requirements
3436
- **Java Development Kit (JDK):** While the project may work with JDK versions 21 or higher, it is recommended to use the latest stable version of JDK for optimal compatibility and performance.
35-
- **Gradle Build Tool:** is used for project management and dependency resolution.
37+
- **Gradle Build Tool** is used for project management and dependency resolution.
38+
39+
## Build and Run
40+
1. Clone this repository:
41+
42+
`git clone https://github.com/nenadjakic/eav-platform.git`
43+
44+
`cd eav-platform`
45+
2. Create a new database for the project. `eav-platform` uses ORM (`Hibernate`) so you can choose any relational database engine
46+
which is supported by `Hibernate` (e.g., MySQL, PostgreSQL, Oracle).
47+
3. Configure database connection:
48+
- Open the `application.properties` file located in src/main/resources.
49+
- Update the database connection settings (URL, username, password, dialect).
50+
4. Open a terminal and navigate to the project directory.
51+
5. Build the project using Gradle:
52+
53+
`./gradlew clean build`
54+
6. Run the project using Gradle:
55+
56+
`./gradlew bootRun`

src/main/kotlin/com/github/nenadjakic/eav/configuration/MainConfiguration.kt

+5-1
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ import io.swagger.v3.oas.models.security.SecurityRequirement
88
import io.swagger.v3.oas.models.security.SecurityScheme
99
import org.modelmapper.ModelMapper
1010
import org.springframework.beans.factory.annotation.Value
11+
import org.springframework.beans.factory.support.BeanDefinitionRegistry
12+
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor
1113
import org.springframework.cache.CacheManager
1214
import org.springframework.cache.concurrent.ConcurrentMapCacheManager
1315
import org.springframework.context.annotation.Bean
1416
import org.springframework.context.annotation.Configuration
17+
import org.springframework.context.annotation.DependsOn
1518
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
1619
import org.springframework.scheduling.annotation.EnableAsync
1720
import org.springframework.security.authentication.AuthenticationManager
@@ -74,8 +77,9 @@ open class MainConfiguration(
7477
open fun authenticationManager(config: AuthenticationConfiguration): AuthenticationManager {
7578
return config.authenticationManager
7679
}
80+
7781
@Bean
78-
open fun authenticationProvider(userDetailsService: UserDetailsService?): AuthenticationProvider {
82+
open fun authenticationProvider(): AuthenticationProvider {
7983
val provider = DaoAuthenticationProvider()
8084
provider.setUserDetailsService(userDetailsService)
8185
provider.setPasswordEncoder(passwordEncoder)

src/main/kotlin/com/github/nenadjakic/eav/configuration/SecurityConfiguration.kt

+10-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package com.github.nenadjakic.eav.configuration
22

33
import com.github.nenadjakic.eav.security.filter.JwtAuthenticationFilter
4+
import com.github.nenadjakic.eav.service.security.AttributePermissionService
5+
import org.springframework.beans.factory.config.BeanDefinition
46
import org.springframework.context.annotation.Bean
57
import org.springframework.context.annotation.Configuration
6-
import org.springframework.security.access.vote.RoleVoter
8+
import org.springframework.context.annotation.DependsOn
9+
import org.springframework.context.annotation.Role
710
import org.springframework.security.authentication.AuthenticationProvider
811
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
912
import org.springframework.security.config.annotation.web.builders.HttpSecurity
@@ -21,10 +24,12 @@ open class SecurityConfiguration(
2124
private val authenticationProvider: AuthenticationProvider,
2225
private val jwtAuthenticationFilter: JwtAuthenticationFilter
2326
) {
24-
25-
@Bean
26-
open fun grantedAuthorityDefaults() : GrantedAuthorityDefaults {
27-
return GrantedAuthorityDefaults("");
27+
companion object {
28+
@Bean
29+
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
30+
open fun grantedAuthorityDefaults(): GrantedAuthorityDefaults {
31+
return GrantedAuthorityDefaults("");
32+
}
2833
}
2934

3035
@Bean

src/main/kotlin/com/github/nenadjakic/eav/entity/Attribute.kt

+3-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import jakarta.persistence.Entity
88
* This class is mapped to the "attribute" table in the "public" schema.
99
*/
1010
@Entity
11-
@Table(schema = "public", name = "attribute",
11+
@Table(
12+
schema = "public",
13+
name = "attribute",
1214
uniqueConstraints = [
1315
UniqueConstraint(name = "uq_attribute_entity_type_id_name", columnNames = ["entity_type_id", "name"])
1416
])

src/main/kotlin/com/github/nenadjakic/eav/entity/EntityAttributeId.kt

+19
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,23 @@ class EntityAttributeId {
1515

1616
@Column(name = "attribute_id", nullable = false)
1717
var attributeId: Long? = null
18+
19+
override fun equals(other: Any?): Boolean {
20+
if (this === other) return true
21+
if (javaClass != other?.javaClass) return false
22+
23+
other as EntityAttributeId
24+
25+
if (entityId != other.entityId) return false
26+
if (attributeId != other.attributeId) return false
27+
28+
return true
29+
}
30+
31+
override fun hashCode(): Int {
32+
var result = entityId?.hashCode() ?: 0
33+
result = 31 * result + (attributeId?.hashCode() ?: 0)
34+
return result
35+
}
36+
1837
}

src/main/kotlin/com/github/nenadjakic/eav/entity/EntityType.kt

+4-2
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import jakarta.persistence.*
44
import jakarta.persistence.Entity
55

66
@Entity
7-
@Table(schema = "public", name = "entity_type",
7+
@Table(
8+
schema = "public",
9+
name = "entity_type",
810
uniqueConstraints = [
9-
UniqueConstraint(name = "uq_entity_type_name", columnNames = ["name"])
11+
UniqueConstraint(name = "uq_entity_type_name", columnNames = [ "name" ])
1012
])
1113
class EntityType : AbstractEntityId<Long>() {
1214

src/main/kotlin/com/github/nenadjakic/eav/entity/security/ConfirmationToken.kt

+8-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ import java.util.*
1111
*
1212
*/
1313
@Entity
14-
@Table(schema = "security", name = "confirmation_token")
14+
@Table(
15+
schema = "security",
16+
name = "confirmation_token",
17+
uniqueConstraints = [
18+
UniqueConstraint(name = "uq_security_confirmation_token_token", columnNames = [ "token" ])
19+
]
20+
)
1521
class ConfirmationToken() : AbstractEntityId<Long>() {
1622

1723
@Id
@@ -23,7 +29,7 @@ class ConfirmationToken() : AbstractEntityId<Long>() {
2329
@JoinColumn(name = "id", nullable = false)
2430
lateinit var user: User
2531

26-
@Column(name = "token", nullable = false)
32+
@Column(name = "token", nullable = false, length = 100, unique = true)
2733
lateinit var token: String
2834

2935
@Column(name = "expire_at", nullable = false)

src/main/kotlin/com/github/nenadjakic/eav/entity/security/Permission.kt

+9-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import jakarta.persistence.Entity
66
import jakarta.persistence.Id
77
import jakarta.persistence.ManyToMany
88
import jakarta.persistence.Table
9+
import jakarta.persistence.UniqueConstraint
910

1011
/**
1112
* Represents a permission entity within the system.
@@ -14,13 +15,19 @@ import jakarta.persistence.Table
1415
*
1516
*/
1617
@Entity
17-
@Table(schema = "security", name = "permission")
18+
@Table(
19+
schema = "security",
20+
name = "permission",
21+
uniqueConstraints = [
22+
UniqueConstraint(name = "security_permission_name", columnNames = [ "name" ])
23+
]
24+
)
1825
class Permission : AbstractEntityId<Long>() {
1926
@Id
2027
@Column(name = "id")
2128
override var id: Long? = null
2229

23-
@Column(name = "name", nullable = false, length = 100)
30+
@Column(name = "name", nullable = false, length = 100, unique = true)
2431
lateinit var name: String
2532

2633
@Column(name = "description", length = 10000)

src/main/kotlin/com/github/nenadjakic/eav/entity/security/RefreshToken.kt

+7-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import java.util.*
1212
* for managing entities with a Long type ID.
1313
*/
1414
@Entity
15-
@Table(schema = "security", name = "refresh_token")
15+
@Table(
16+
schema = "security",
17+
name = "refresh_token",
18+
uniqueConstraints = [
19+
UniqueConstraint(name = "uq_security_refresh_token_token", columnNames = [ "token" ])
20+
])
1621
class RefreshToken() : AbstractEntityId<Long>() {
1722

1823
@Id
@@ -25,7 +30,7 @@ class RefreshToken() : AbstractEntityId<Long>() {
2530
@JoinColumn(name = "user_id", nullable = false)
2631
lateinit var user: User
2732

28-
@Column(name = "token", nullable = false, length = 100)
33+
@Column(name = "token", nullable = false, length = 100, unique = true)
2934
lateinit var token: String
3035

3136
@Column(name = "expire_at", nullable = false)

src/main/kotlin/com/github/nenadjakic/eav/entity/security/Role.kt

+8-2
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,21 @@ import jakarta.persistence.*
88
* This class is mapped to the "role" table in the "security" schema.
99
*/
1010
@Entity
11-
@Table(schema = "security", name = "role")
11+
@Table(
12+
schema = "security",
13+
name = "role",
14+
uniqueConstraints = [
15+
UniqueConstraint(name = "uq_security_role_name", columnNames = [ "name" ])
16+
]
17+
)
1218
class Role : AbstractEntityId<Long>() {
1319
@Id
1420
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "security.role_id_seq")
1521
@SequenceGenerator(schema = "security", name = "role_id_seq", allocationSize = 1)
1622
@Column(name = "id")
1723
override var id: Long? = null
1824

19-
@Column(name = "name", nullable = false, length = 100)
25+
@Column(name = "name", nullable = false, length = 100, unique = true)
2026
lateinit var name: String
2127

2228
@ManyToMany

src/main/kotlin/com/github/nenadjakic/eav/entity/security/RoleAttributeId.kt

+18
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,22 @@ class RoleAttributeId {
1515

1616
@Column(name = "attribute_id", nullable = false)
1717
var attributeId: Long? = null
18+
19+
override fun equals(other: Any?): Boolean {
20+
if (this === other) return true
21+
if (javaClass != other?.javaClass) return false
22+
23+
other as RoleAttributeId
24+
25+
if (roleId != other.roleId) return false
26+
if (attributeId != other.attributeId) return false
27+
28+
return true
29+
}
30+
31+
override fun hashCode(): Int {
32+
var result = roleId?.hashCode() ?: 0
33+
result = 31 * result + (attributeId?.hashCode() ?: 0)
34+
return result
35+
}
1836
}

0 commit comments

Comments
 (0)