blogs
Building RBAC Into a Java CRM: What I Learned at Sonata Software
What building Role-Based Access Control into a Java CRM at Sonata Software taught me about the mechanics and philosophy of authorization.
Building RBAC Into a Java CRM: What I Learned at Sonata Software
Role-Based Access Control is one of those concepts that sounds simple until you implement it in a real enterprise application with complex authorization requirements. My internship at Sonata Software — where I built a Java-based CRM system — taught me both the mechanics and the philosophy of getting access control right.
What Is RBAC?
Role-Based Access Control assigns permissions to roles, and roles to users — rather than assigning permissions directly to individual users. This indirection is what makes RBAC scalable.
In a CRM context:
- Sales Rep: Can view and create customer records, log interactions, view their own pipeline
- Sales Manager: Everything a Sales Rep can do, plus view all reps' pipelines, run reports
- Admin: Full access including user management, system configuration, data exports
- Read-Only: Can view but not modify any records
When a new Sales Rep joins, you assign the Sales Rep role — they automatically get all appropriate permissions. When permissions need to change for all Sales Reps, you update the role — not hundreds of individual user records.
The Data Model
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE roles (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) UNIQUE NOT NULL,
description VARCHAR(200)
);
CREATE TABLE permissions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) UNIQUE NOT NULL, -- e.g., "customer:read", "customer:write"
description VARCHAR(200)
);
CREATE TABLE role_permissions (
role_id BIGINT REFERENCES roles(id),
permission_id BIGINT REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id BIGINT REFERENCES users(id),
role_id BIGINT REFERENCES roles(id),
PRIMARY KEY (user_id, role_id)
);
This structure supports users having multiple roles — a sales manager might also have an admin role in a small company.
Spring Security Integration
In our Java CRM, I integrated RBAC using Spring Security.
Security configuration:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/customers/**").hasAnyRole("SALES_REP", "SALES_MANAGER", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/customers/**").hasAnyRole("SALES_REP", "SALES_MANAGER", "ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/customers/**").hasRole("ADMIN")
.requestMatchers("/api/reports/**").hasAnyRole("SALES_MANAGER", "ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
}
Method-level security for fine-grained control:
@Service
public class CustomerService {
// Only admins can delete
@PreAuthorize("hasRole('ADMIN')")
public void deleteCustomer(Long customerId) {
customerRepository.deleteById(customerId);
}
// Sales reps can only see their own customers
@PreAuthorize("hasRole('ADMIN') or hasRole('SALES_MANAGER') or @customerSecurity.isOwner(#customerId, authentication)")
public Customer getCustomer(Long customerId) {
return customerRepository.findById(customerId).orElseThrow();
}
// Report access limited to managers and admins
@PreAuthorize("hasAnyRole('SALES_MANAGER', 'ADMIN')")
public SalesReport generateReport(ReportParams params) {
return reportGenerator.generate(params);
}
}
Custom security expression for ownership checks:
@Component("customerSecurity")
public class CustomerSecurityExpression {
@Autowired
private CustomerRepository customerRepository;
public boolean isOwner(Long customerId, Authentication auth) {
UserDetails user = (UserDetails) auth.getPrincipal();
return customerRepository.findById(customerId)
.map(customer -> customer.getAssignedRepUsername().equals(user.getUsername()))
.orElse(false);
}
}
Input Validation: The Other Half of Security
RBAC controls who can access what. Input validation controls what data enters the system. Both are necessary.
In the CRM, I implemented Bean Validation throughout:
public class CustomerDTO {
@NotBlank(message = "Company name is required")
@Size(max = 200, message = "Company name must not exceed 200 characters")
private String companyName;
@NotBlank
@Email(message = "Must be a valid email address")
private String contactEmail;
@Pattern(regexp = "^\\+?[1-9]\\d{1,14}$", message = "Must be a valid international phone number")
private String phone;
@DecimalMin(value = "0.0", message = "Deal value cannot be negative")
@DecimalMax(value = "10000000.00", message = "Deal value exceeds maximum")
private BigDecimal dealValue;
}
Custom validators for business rules:
@Constraint(validatedBy = UniqueEmailValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueEmail {
String message() default "Email address is already in use";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
@Autowired
private CustomerRepository customerRepository;
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
return !customerRepository.existsByContactEmail(email);
}
}
Auditing: Knowing Who Did What
In a CRM, auditing isn't optional — you need to know who modified a customer record, who exported data, who changed access controls. I implemented audit logging using Spring Data JPA Auditing and a custom audit trail:
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Customer {
@CreatedBy
@Column(updatable = false)
private String createdBy;
@LastModifiedBy
private String lastModifiedBy;
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
// Audit log for sensitive operations
@Entity
public class AuditLog {
@Id @GeneratedValue
private Long id;
private String username;
private String action; // "DELETE_CUSTOMER", "EXPORT_DATA", etc.
private String resourceType;
private String resourceId;
private String details;
private LocalDateTime timestamp;
private String ipAddress;
}
Every delete, export, and admin action writes a record to the audit log. This creates an immutable trail that supports both security investigations and compliance requirements.
Results and Lessons Learned
After implementing RBAC and input validation, we ran automated security tests using OWASP ZAP and manual review. Key results:
- Zero SQL injection vulnerabilities in data input paths
- Zero authorization bypass vulnerabilities (BOLA/BFLA)
- Audit log coverage on all sensitive operations
What I'd do differently: implement RBAC from day one, not as a retrofit. Adding access control to an existing codebase means auditing every endpoint and every service method. Starting with it means building the authorization checks naturally as you build the features.
The 25% reduction in manual errors we achieved through automated validation workflows came from a simple insight: if you make incorrect input impossible, you eliminate a whole class of downstream errors. Access control and input validation aren't just security features — they're quality features.
