A complete Role-Based Access Control (RBAC) system implementation using Keycloak for authentication/authorization, Spring Boot for the REST API, and PostgreSQL for data persistence.
- Architecture Overview
- RBAC Model
- Technology Stack
- Prerequisites
- Quick Start
- API Documentation
- Testing the API
- RBAC Rules Implementation
- Security Configuration
- Project Structure
- Environment Variables
- Troubleshooting
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β β β β β β
β Keycloak βββββββββββ€ Spring Boot βββββββββββ€ PostgreSQL β
β (Auth Server) β JWT β (Resource β JPA β (Database) β
β β Token β Server) β β β
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
The system implements:
- Authentication: Keycloak issues JWT tokens after user login
- Authorization: Spring Boot validates JWT tokens and enforces RBAC rules
- Data Persistence: PostgreSQL stores business resources with organization isolation
| Role | Description | Permissions |
|---|---|---|
ROLE_ADMIN |
Administrator | Full access to all resources in their organization |
ROLE_EDITOR |
Content Editor | Create resources, modify their own resources, read all |
ROLE_VIEWER |
Read-only User | Read access only |
- Organization Isolation: Users can only access resources within their organization
- Role-Based Permissions:
VIEWER: GET operations onlyEDITOR: GET + POST + PUT (own resources only)ADMIN: All operations (GET, POST, PUT, DELETE)
- Ownership: Editors can only modify resources they created
- Hierarchical: Admin rights supersede all other roles
Keycloak Realm Roles β JWT Token β Spring Security GrantedAuthority
ROLE_ADMIN (Keycloak) β realm_access.roles β ROLE_ADMIN (Spring)
ROLE_EDITOR (Keycloak) β realm_access.roles β ROLE_EDITOR (Spring)
ROLE_VIEWER (Keycloak) β realm_access.roles β ROLE_VIEWER (Spring)The mapping is handled by KeycloakRoleConverter in SecurityConfig.java.
- Java: 17
- Spring Boot: 3.2.2
- Spring Web
- Spring Security
- Spring Data JPA
- OAuth2 Resource Server
- Keycloak: 23.0.7
- PostgreSQL: 15
- Maven: 3.9+
- Docker & Docker Compose
- Swagger/OpenAPI: 3.0
- Docker & Docker Compose installed
- Java 17+ (for local development)
- Maven 3.9+ (for local development)
- cURL or Postman (for API testing)
git clone <repository-url>
cd keycloak-rbac-projectdocker-compose up --buildThis will start:
- PostgreSQL (port 5432) - Application database
- Keycloak PostgreSQL (internal) - Keycloak database
- Keycloak (port 8080) - Authentication server
- Spring Boot API (port 8081) - REST API
The startup takes approximately 1-2 minutes. You'll know it's ready when you see:
rbac-api | Started RbacApiApplication in X.XXX seconds
rbac-keycloak | Keycloak X.X.X started
- Keycloak Admin Console: http://localhost:8080
- Username:
admin - Password:
admin
- Username:
- API Swagger UI: http://localhost:8081/swagger-ui.html
- API Base URL: http://localhost:8081/api
| Username | Password | Role | Organization |
|---|---|---|---|
| admin@viasay.io | admin123 | ROLE_ADMIN | org-001 |
| editor@viasay.io | editor123 | ROLE_EDITOR | org-001 |
| viewer@viasay.io | viewer123 | ROLE_VIEWER | org-001 |
http://localhost:8081/api
| Method | Endpoint | Description | Required Role |
|---|---|---|---|
| GET | /public |
Public endpoint (no auth) | None |
| GET | /me |
Get current user info | Authenticated |
| GET | /resources |
List all resources | VIEWER/EDITOR/ADMIN |
| GET | /resources/{id} |
Get specific resource | VIEWER/EDITOR/ADMIN |
| POST | /resources |
Create resource | EDITOR/ADMIN |
| PUT | /resources/{id} |
Update resource | EDITOR/ADMIN* |
| DELETE | /resources/{id} |
Delete resource | ADMIN |
*EDITOR can only update their own resources
Visit Swagger UI: http://localhost:8081/swagger-ui.html
# Replace {USERNAME} and {PASSWORD} with one of the test users
curl -X POST "http://localhost:8080/realms/rbac-realm/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=rbac-client" \
-d "client_secret=rbac-client-secret-12345" \
-d "grant_type=password" \
-d "username=admin@viasay.io" \
-d "password=admin123"Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 3600,
"refresh_expires_in": 1800,
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer"
}Save the access_token for subsequent requests.
curl -X GET "http://localhost:8081/api/public"Expected Response:
{
"message": "This is a public endpoint accessible without authentication",
"timestamp": "1707123456789"
}# Replace {TOKEN} with your access_token
curl -X GET "http://localhost:8081/api/me" \
-H "Authorization: Bearer {TOKEN}"Expected Response:
{
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"username": "admin@viasay.io",
"email": "admin@viasay.io",
"firstName": "Admin",
"lastName": "User",
"organizationId": "org-001",
"roles": ["ROLE_ADMIN"]
}curl -X POST "http://localhost:8081/api/resources" \
-H "Authorization: Bearer {TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"name": "My First Project",
"content": "This is a test project content"
}'Expected Response:
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "My First Project",
"content": "This is a test project content",
"ownerId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"organizationId": "org-001",
"createdAt": "2024-02-05T10:30:00",
"updatedAt": "2024-02-05T10:30:00"
}curl -X GET "http://localhost:8081/api/resources" \
-H "Authorization: Bearer {TOKEN}"curl -X PUT "http://localhost:8081/api/resources/{RESOURCE_ID}" \
-H "Authorization: Bearer {TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"name": "Updated Project Name",
"content": "Updated content"
}'curl -X DELETE "http://localhost:8081/api/resources/{RESOURCE_ID}" \
-H "Authorization: Bearer {TOKEN}"Expected Response: 204 No Content
# Get token for viewer
TOKEN=$(curl -s -X POST "http://localhost:8080/realms/rbac-realm/protocol/openid-connect/token" \
-d "client_id=rbac-client" \
-d "client_secret=rbac-client-secret-12345" \
-d "grant_type=password" \
-d "username=viewer@viasay.io" \
-d "password=viewer123" | jq -r '.access_token')
# Try to create (should return 403 Forbidden)
curl -X POST "http://localhost:8081/api/resources" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Test", "content": "Test"}'# 1. Admin creates a resource
# 2. Editor tries to update it (should return 403 Forbidden)# Get token for editor
TOKEN=$(curl -s -X POST "http://localhost:8080/realms/rbac-realm/protocol/openid-connect/token" \
-d "client_id=rbac-client" \
-d "client_secret=rbac-client-secret-12345" \
-d "grant_type=password" \
-d "username=editor@viasay.io" \
-d "password=editor123" | jq -r '.access_token')
# Try to delete (should return 403 Forbidden)
curl -X DELETE "http://localhost:8081/api/resources/{RESOURCE_ID}" \
-H "Authorization: Bearer $TOKEN"Used for coarse-grained access control:
@PreAuthorize("hasRole('ROLE_ADMIN')")
public void deleteResource(UUID id) { ... }Used for fine-grained business logic:
// EDITOR can only update their own resources
if (securityUtils.isEditor() && !resource.getOwnerId().equals(userId)) {
throw new ForbiddenException("You can only update your own resources");
}Used to enforce organization isolation:
List<Resource> findByOrganizationId(String organizationId);
Optional<Resource> findByIdAndOrganizationId(UUID id, String organizationId);1. Request arrives with JWT token
β
2. Spring Security validates JWT (signature, expiration, issuer)
β
3. KeycloakRoleConverter extracts roles from JWT
β
4. @PreAuthorize checks role permissions
β
5. SecurityUtils provides user context (organizationId, userId)
β
6. Service layer enforces business rules
β
7. Repository layer filters data by organization
β
8. Response sent to client
The API validates:
- β Signature: Using Keycloak's public key
- β Expiration: Token must not be expired
- β Issuer: Must match configured issuer URI
- β Claims: Extracts roles and organizationId
| Status Code | Description |
|---|---|
| 401 Unauthorized | Missing or invalid JWT token |
| 403 Forbidden | Valid token but insufficient permissions |
| 404 Not Found | Resource doesn't exist or wrong organization |
keycloak-rbac-project/
βββ src/
β βββ main/
β β βββ java/com/viasay/rbac/
β β β βββ RbacApiApplication.java
β β β βββ config/
β β β β βββ SecurityConfig.java
β β β β βββ OpenApiConfig.java
β β β βββ controller/
β β β β βββ PublicController.java
β β β β βββ ResourceController.java
β β β βββ service/
β β β β βββ ResourceService.java
β β β β βββ ResourceServiceImpl.java
β β β βββ repository/
β β β β βββ ResourceRepository.java
β β β βββ entity/
β β β β βββ Resource.java
β β β βββ dto/
β β β β βββ ResourceDto.java
β β β β βββ UserInfoDto.java
β β β βββ exception/
β β β β βββ GlobalExceptionHandler.java
β β β β βββ ResourceNotFoundException.java
β β β β βββ ForbiddenException.java
β β β β βββ ErrorResponse.java
β β β βββ util/
β β β βββ SecurityUtils.java
β β βββ resources/
β β βββ application.yml
β βββ test/
β βββ java/com/viasay/rbac/
β βββ (test files)
βββ keycloak/
β βββ realm-export.json
βββ docker-compose.yml
βββ Dockerfile
βββ pom.xml
βββ README.md
| Variable | Default | Description |
|---|---|---|
SPRING_DATASOURCE_URL |
jdbc:postgresql://localhost:5432/rbac_db |
PostgreSQL connection URL |
SPRING_DATASOURCE_USERNAME |
rbac_user |
Database username |
SPRING_DATASOURCE_PASSWORD |
rbac_password |
Database password |
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI |
http://localhost:8080/realms/rbac-realm |
Keycloak issuer URI |
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI |
http://localhost:8080/realms/rbac-realm/protocol/openid-connect/certs |
JWK Set URI |
| Variable | Default | Description |
|---|---|---|
KEYCLOAK_ADMIN |
admin |
Keycloak admin username |
KEYCLOAK_ADMIN_PASSWORD |
admin |
Keycloak admin password |
KC_DB |
postgres |
Database type |
KC_DB_URL |
jdbc:postgresql://keycloak-postgres:5432/keycloak |
Keycloak database URL |
Solution: Ensure Docker has sufficient resources (4GB RAM minimum)
docker-compose down -v
docker-compose up --buildSolution: Check that keycloak/realm-export.json exists and restart Keycloak
docker-compose restart keycloakCauses:
- Token expired (tokens expire after 1 hour)
- Invalid token format
- Keycloak not accessible from Spring Boot
Solution:
- Obtain a fresh token
- Check token format:
Authorization: Bearer {token} - Verify Keycloak is running:
curl http://localhost:8080/health
Causes:
- User doesn't have required role
- Trying to access another organization's resources
- EDITOR trying to modify someone else's resource
Solution: Verify user roles and resource ownership
Solution: Wait for PostgreSQL to be fully ready
docker-compose logs postgresStop all services:
docker-compose downRemove all data (volumes):
docker-compose down -vCREATE TABLE resources (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
content TEXT,
owner_id VARCHAR(255) NOT NULL,
organization_id VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP
);
CREATE INDEX idx_resources_organization_id ON resources(organization_id);
CREATE INDEX idx_resources_owner_id ON resources(owner_id);- @PreAuthorize vs Custom Filters: Used
@PreAuthorizefor clarity and declarative security - Service Layer RBAC: Business rules enforced in service layer for testability
- Organization Isolation: Repository layer ensures data isolation
- SecurityUtils: Centralized helper for extracting JWT claims
- Global Exception Handler: Consistent error responses across the API
- Tokens expire after 900 seconds (15 minutes)
- Refresh tokens can be used to obtain new access tokens
- All timestamps are in UTC
- UUIDs are used for all entity IDs
- Soft delete is not implemented (hard delete)
- Audit logging can be added via JPA auditing