Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
766944a
feat(common): seed the baseline permissions and the ADMIN role in Fly…
EdgarEldy Sep 19, 2026
9d7c467
feat(common): add PageDto for paginated service results
EdgarEldy Sep 19, 2026
37aa991
feat(auth): add the Role, Permission and AuditLog entities
EdgarEldy Sep 19, 2026
2a2889e
feat(auth): add RoleDto, PermissionDto and AuditLogDto
EdgarEldy Sep 19, 2026
4b7a9c0
feat(auth): add the RowMappers of roles, permissions and audit logs
EdgarEldy Sep 19, 2026
bb9b5aa
feat(auth): add the entity mappers of roles, permissions and audit logs
EdgarEldy Sep 19, 2026
be284fd
feat(auth): add the role, permission and audit log DAOs
EdgarEldy Sep 19, 2026
6840f29
feat(auth): add user paging, role assignment and last-admin queries
EdgarEldy Sep 19, 2026
95bde19
feat(auth): add AuditLogger with joined and independent transactions
EdgarEldy Sep 19, 2026
450a87f
feat(auth): implement roles, permissions and assignments in RbacService
EdgarEldy Sep 19, 2026
b731605
feat(auth): wire the RBAC beans and the optional admin bootstrap
EdgarEldy Sep 19, 2026
b9a1169
feat(ws): resolve permissions in CustomPermissionEvaluator
EdgarEldy Sep 19, 2026
b18a707
feat(ws): enable method security backed by the permission evaluator
EdgarEldy Sep 19, 2026
f25f7a9
feat(ws): add the RBAC payloads and converters
EdgarEldy Sep 19, 2026
fea7e3b
feat(ws): expose the RBAC endpoints with declarative permissions
EdgarEldy Sep 19, 2026
3d0fcd0
fix(ws): refine 401 versus 403 and name the invalid request parameters
EdgarEldy Sep 19, 2026
0945886
test(common): assert the V2 seed exactly
EdgarEldy Sep 19, 2026
c8b8cf1
test(auth): cover the RBAC DAOs against PostgreSQL
EdgarEldy Sep 19, 2026
165033f
test(auth): test the RBAC RowMappers and entity mappers
EdgarEldy Sep 19, 2026
aa72181
test(auth): unit test every RbacService operation with mocked DAOs
EdgarEldy Sep 19, 2026
f5cf45d
test(auth): test AuditLoggerImpl and AdminBootstrap
EdgarEldy Sep 19, 2026
98aa7ca
test(ws): add RBAC fixtures for token-based and database-backed tests
EdgarEldy Sep 19, 2026
c2ab81c
test(ws): test CustomPermissionEvaluator and the actor provider
EdgarEldy Sep 19, 2026
af363b3
test(ws): test the RBAC controllers with MockMvc
EdgarEldy Sep 19, 2026
8107daa
test(ws): check the permission matrix of the 14 RBAC endpoints
EdgarEldy Sep 19, 2026
65b1ddf
test(ws): run the RBAC flows on the real stack
EdgarEldy Sep 19, 2026
e2fcf7f
test(ws): prove the last-admin rule, including under concurrency
EdgarEldy Sep 19, 2026
a2e1da5
test(ws): test the admin bootstrap on the real context
EdgarEldy Sep 19, 2026
0aa9a43
fix(ws): never print a null parameter name in validation messages
EdgarEldy Sep 19, 2026
848397c
docs(readme): tick the feature/rbac tasks and document the dev admin …
EdgarEldy Sep 19, 2026
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
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,18 +369,18 @@ Full CRUD for users, roles, and permissions, still inside `core/auth`. Assignmen

### Tasks

- [ ] `Role`, `Permission`, `AuditLog` entity classes (`entity/`, `@Table("roles")`/`@Table("permissions")`/`@Table("audit_logs")`) and `RoleDto`/`PermissionDto`/`AuditLogDto` (`dto/`), `RoleRowMapper`/`PermissionRowMapper`/`AuditLogRowMapper` (entity, from `ResultSet`) plus `RoleMapper`/`PermissionMapper`/`AuditLogMapper` (entity ↔ dto), `RoleDao`/`PermissionDao`/`AuditLogDao` + impl (working in entity/ types)
- [ ] `RbacService` (interface) + `RbacServiceImpl`:
- [x] `Role`, `Permission`, `AuditLog` entity classes (`entity/`, `@Table("roles")`/`@Table("permissions")`/`@Table("audit_logs")`) and `RoleDto`/`PermissionDto`/`AuditLogDto` (`dto/`), `RoleRowMapper`/`PermissionRowMapper`/`AuditLogRowMapper` (entity, from `ResultSet`) plus `RoleMapper`/`PermissionMapper`/`AuditLogMapper` (entity ↔ dto), `RoleDao`/`PermissionDao`/`AuditLogDao` + impl (working in entity/ types)
- [x] `RbacService` (interface) + `RbacServiceImpl`:
- `createRole`/`updateRole`/`deleteRole` - `deleteRole` rejects if any user is still assigned this role
- `createPermission`/`updatePermission`/`deletePermission` - rejects if any role still has this permission
- `assignPermissionToRole`/`removePermissionFromRole` - rejects removing `ROLE:WRITE` from a role if it would leave zero users anywhere holding a role that grants it
- `assignRoleToUser`/`removeRoleFromUser` - the same last-admin check applied at the point of removal from a specific user
- [ ] `AuditLogger`/`AuditLoggerImpl`: a single `log(String action, String entityType, Long entityId, String details)` method, called from every `RbacServiceImpl` mutation
- [ ] `CustomPermissionEvaluator` (`ws`, implements Spring Security's `PermissionEvaluator`): resolves the authenticated user's permissions (loaded once at login and embedded in the JWT, read from the `Authentication` object - no database call per request) and answers `hasPermission(target, permission)` calls from `@PreAuthorize`
- [ ] `SecurityConfig` updated: `@EnableMethodSecurity`, `CustomPermissionEvaluator` registered on the `MethodSecurityExpressionHandler`
- [ ] A seeding step (a Flyway data-migration): baseline permissions covering every resource/action this project defines, assigned to a seeded `ADMIN` role - without this, nobody could ever be granted `ROLE:WRITE`/`PERMISSION:WRITE` to create the first assignment
- [ ] `RoleController`, `PermissionController`, `UserController` role-assignment endpoints, `RoleConverter`/`PermissionConverter`
- [ ] Tests at all five layers, including the "still referenced" rejection on `deleteRole`/`deletePermission`, the last-admin rejection triggered both ways, `CustomPermissionEvaluator` allowing/denying correctly, and an assertion that every `RbacServiceImpl` mutation produces a matching `AuditLog` row
- [x] `AuditLogger`/`AuditLoggerImpl`: a single `log(String action, String entityType, Long entityId, String details)` method, called from every `RbacServiceImpl` mutation
- [x] `CustomPermissionEvaluator` (`ws`, implements Spring Security's `PermissionEvaluator`): resolves the authenticated user's permissions (loaded once at login and embedded in the JWT, read from the `Authentication` object - no database call per request) and answers `hasPermission(target, permission)` calls from `@PreAuthorize`
- [x] `SecurityConfig` updated: `@EnableMethodSecurity`, `CustomPermissionEvaluator` registered on the `MethodSecurityExpressionHandler`
- [x] A seeding step (a Flyway data-migration): baseline permissions covering every resource/action this project defines, assigned to a seeded `ADMIN` role - without this, nobody could ever be granted `ROLE:WRITE`/`PERMISSION:WRITE` to create the first assignment
- [x] `RoleController`, `PermissionController`, `UserController` role-assignment endpoints, `RoleConverter`/`PermissionConverter`
- [x] Tests at all five layers, including the "still referenced" rejection on `deleteRole`/`deletePermission`, the last-admin rejection triggered both ways, `CustomPermissionEvaluator` allowing/denying correctly, and an assertion that every `RbacServiceImpl` mutation produces a matching `AuditLog` row

## feature/catalog

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@

import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao;
import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao;
import com.edgareldy.springjdbctutorial.core.auth.dao.AuditLogDao;
import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao;
import com.edgareldy.springjdbctutorial.core.auth.dao.PermissionDao;
import com.edgareldy.springjdbctutorial.core.auth.dao.RoleDao;
import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao;
import com.edgareldy.springjdbctutorial.core.auth.dao.impl.ActivationTokenDaoImpl;
import com.edgareldy.springjdbctutorial.core.auth.dao.impl.BlacklistedTokenDaoImpl;
import com.edgareldy.springjdbctutorial.core.auth.dao.impl.AuditLogDaoImpl;
import com.edgareldy.springjdbctutorial.core.auth.dao.impl.PasswordResetTokenDaoImpl;
import com.edgareldy.springjdbctutorial.core.auth.dao.impl.PermissionDaoImpl;
import com.edgareldy.springjdbctutorial.core.auth.dao.impl.RoleDaoImpl;
import com.edgareldy.springjdbctutorial.core.auth.dao.impl.UserDaoImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand Down Expand Up @@ -42,4 +48,19 @@ public PasswordResetTokenDao passwordResetTokenDao(JdbcTemplate jdbcTemplate) {
public BlacklistedTokenDao blacklistedTokenDao(JdbcTemplate jdbcTemplate) {
return new BlacklistedTokenDaoImpl(jdbcTemplate);
}

@Bean
public RoleDao roleDao(JdbcTemplate jdbcTemplate) {
return new RoleDaoImpl(jdbcTemplate);
}

@Bean
public PermissionDao permissionDao(JdbcTemplate jdbcTemplate) {
return new PermissionDaoImpl(jdbcTemplate);
}

@Bean
public AuditLogDao auditLogDao(JdbcTemplate jdbcTemplate) {
return new AuditLogDaoImpl(jdbcTemplate);
}
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,37 @@
package com.edgareldy.springjdbctutorial.core.auth.config;

import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao;
import com.edgareldy.springjdbctutorial.core.auth.dao.AuditLogDao;
import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao;
import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao;
import com.edgareldy.springjdbctutorial.core.auth.dao.PermissionDao;
import com.edgareldy.springjdbctutorial.core.auth.dao.RoleDao;
import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao;
import com.edgareldy.springjdbctutorial.core.auth.service.ActorProvider;
import com.edgareldy.springjdbctutorial.core.auth.service.AuditLogger;
import com.edgareldy.springjdbctutorial.core.auth.service.AuthService;
import com.edgareldy.springjdbctutorial.core.auth.service.RbacService;
import com.edgareldy.springjdbctutorial.core.auth.service.impl.AdminBootstrap;
import com.edgareldy.springjdbctutorial.core.auth.service.impl.AuditLoggerImpl;
import com.edgareldy.springjdbctutorial.core.auth.service.impl.AuthServiceImpl;
import com.edgareldy.springjdbctutorial.core.auth.service.impl.ExpiredTokenCleanupJob;
import com.edgareldy.springjdbctutorial.core.auth.service.impl.RbacServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;

import java.time.Clock;

/**
* Declares the service beans of the auth module. Imports its DaoConfig so importing this class alone
* is enough. The Clock bean comes from CommonConfig, the JdbcTemplate from DataSourceConfig.
* is enough. The Clock bean comes from CommonConfig, the JdbcTemplate from DataSourceConfig. Any context
* importing this class must also provide an ActorProvider bean (declared by the web layer).
* <p>
* Created edgar.muhamyangabo on 9/19/26
* Author : edgar.muhamyangabo
Expand Down Expand Up @@ -54,4 +67,33 @@ public AuthService authService(UserDao userDao, ActivationTokenDao activationTok
public ExpiredTokenCleanupJob expiredTokenCleanupJob(AuthService authService) {
return new ExpiredTokenCleanupJob(authService);
}

// AuditLogger and RbacService are returned as their interfaces so the @Transactional advice (including
// the REQUIRES_NEW of logRejected) is applied by the proxy. RbacServiceImpl receives the AuditLogger
// PROXY here, which is why its refusal rows really commit in an independent transaction.
// The ActorProvider parameter is a bean the web layer declares: core only knows the interface.
@Bean
public AuditLogger auditLogger(AuditLogDao auditLogDao, ActorProvider actorProvider, Clock clock) {
return new AuditLoggerImpl(auditLogDao, actorProvider, clock);
}

@Bean
public RbacService rbacService(UserDao userDao, RoleDao roleDao, PermissionDao permissionDao,
AuditLogger auditLogger) {
return new RbacServiceImpl(userDao, roleDao, permissionDao, auditLogger);
}

// initMethod "run" makes Spring call AdminBootstrap.run() once the bean is wired, after Flyway (the DAOs
// depend on it). The account is created only when BOTH app.bootstrap.* values are configured (development
// only: never set them in test or production); absent values make the bean do nothing. The Environment
// is read leniently (getProperty, no default), unlike the required datasource settings.
@Bean(initMethod = "run")
public AdminBootstrap adminBootstrap(UserDao userDao, RoleDao roleDao, AuditLogger auditLogger,
PasswordEncoder passwordEncoder, PlatformTransactionManager transactionManager,
Environment environment) {
return new AdminBootstrap(userDao, roleDao, auditLogger, passwordEncoder,
new TransactionTemplate(transactionManager),
environment.getProperty("app.bootstrap.admin-email"),
environment.getProperty("app.bootstrap.admin-password"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.edgareldy.springjdbctutorial.core.auth.dao;

import com.edgareldy.springjdbctutorial.core.auth.entity.AuditLog;

import java.util.List;

/**
* Data access for the audit trail. Entity types only.
* <p>
* Created edgar.muhamyangabo on 9/19/26
* Author : edgar.muhamyangabo
* Date : 9/19/26
* Project : spring-jdbc-tutorial
*/
public interface AuditLogDao {

/** Inserts the entry and returns it with its generated id. */
AuditLog insert(AuditLog auditLog);

/** Every entry ordered by id, meant for tests and diagnostics. */
List<AuditLog> findAll();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.edgareldy.springjdbctutorial.core.auth.dao;

import com.edgareldy.springjdbctutorial.core.auth.entity.Permission;

import java.util.List;
import java.util.Optional;

/**
* Data access for permissions. Entity types only.
* <p>
* Created edgar.muhamyangabo on 9/19/26
* Author : edgar.muhamyangabo
* Date : 9/19/26
* Project : spring-jdbc-tutorial
*/
public interface PermissionDao {

/** All permissions ordered by resource then action. */
List<Permission> findAll();

Optional<Permission> findById(Long id);

boolean existsByResourceAndAction(String resource, String action);

/** Inserts the permission and returns it with its generated id. */
Permission insert(Permission permission);

void update(Permission permission);

void delete(Long id);

/** Number of roles currently holding this permission. */
long countRolesWithPermission(Long permissionId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.edgareldy.springjdbctutorial.core.auth.dao;

import com.edgareldy.springjdbctutorial.core.auth.entity.Permission;
import com.edgareldy.springjdbctutorial.core.auth.entity.Role;

import java.util.List;
import java.util.Optional;

/**
* Data access for roles and their role_permission assignments. Entity types only.
* <p>
* Created edgar.muhamyangabo on 9/19/26
* Author : edgar.muhamyangabo
* Date : 9/19/26
* Project : spring-jdbc-tutorial
*/
public interface RoleDao {

/** All roles ordered by name. */
List<Role> findAll();

Optional<Role> findById(Long id);

Optional<Role> findByRoleName(String roleName);

boolean existsByRoleName(String roleName);

/** Inserts the role and returns it with its generated id. */
Role insert(Role role);

void updateName(Long id, String roleName);

/** Deletes the role (its role_permission rows go with it by cascade). */
void delete(Long id);

/** Number of users currently assigned this role. */
long countUsersWithRole(Long roleId);

/** Permissions of the role ordered by resource then action. */
List<Permission> findPermissionsByRoleId(Long roleId);

void addPermission(Long roleId, Long permissionId);

/** Returns the number of rows removed (0 when the permission was not assigned). */
int removePermission(Long roleId, Long permissionId);

boolean hasPermission(Long roleId, Long permissionId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,28 @@ public interface UserDao {

/** Distinct "RESOURCE:ACTION" codes granted through the user's roles, sorted. */
List<String> findPermissionCodesByUserId(Long userId);

/** One page of users ordered by id (LIMIT/OFFSET). The page is zero-based. */
List<User> findPage(int page, int size);

long countAll();

void addRole(Long userId, Long roleId);

/** Returns the number of rows removed (0 when the role was not assigned). */
int removeRole(Long userId, Long roleId);

boolean hasRole(Long userId, Long roleId);

/**
* Number of distinct users that are enabled AND not locked and hold the ROLE:WRITE permission through
* one of their roles. Reads database state, not live tokens.
*/
long countLastAdminCandidates();

/**
* Takes a transaction-scoped advisory lock: must be the first statement of any transaction that could
* remove the last ROLE:WRITE holder. Released automatically at commit or rollback.
*/
void acquireLastAdminLock();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.edgareldy.springjdbctutorial.core.auth.dao.impl;

import com.edgareldy.springjdbctutorial.core.auth.dao.AuditLogDao;
import com.edgareldy.springjdbctutorial.core.auth.entity.AuditLog;
import com.edgareldy.springjdbctutorial.core.auth.mapper.AuditLogRowMapper;
import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao;
import org.springframework.jdbc.core.JdbcTemplate;

import java.sql.Timestamp;
import java.util.List;

/**
* JdbcTemplate implementation of AuditLogDao. Declared by @Bean in DaoConfig, no stereotype.
* <p>
* Created edgar.muhamyangabo on 9/19/26
* Author : edgar.muhamyangabo
* Date : 9/19/26
* Project : spring-jdbc-tutorial
*/
public class AuditLogDaoImpl extends AbstractDao implements AuditLogDao {

private static final String COLUMNS = "id, actor_user_id, action, entity_type, entity_id, details, created_at";

private final AuditLogRowMapper rowMapper = new AuditLogRowMapper();

public AuditLogDaoImpl(JdbcTemplate jdbcTemplate) {
super(jdbcTemplate);
}

@Override
public AuditLog insert(AuditLog auditLog) {
long id = insertReturningId(
"INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, details, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?) RETURNING id",
auditLog.getActorUserId(), auditLog.getAction(), auditLog.getEntityType(), auditLog.getEntityId(),
auditLog.getDetails(),
auditLog.getCreatedAt() == null ? null : Timestamp.from(auditLog.getCreatedAt()));
auditLog.setId(id);
return auditLog;
}

@Override
public List<AuditLog> findAll() {
return getJdbcTemplate().query("SELECT " + COLUMNS + " FROM audit_logs ORDER BY id", rowMapper);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.edgareldy.springjdbctutorial.core.auth.dao.impl;

import com.edgareldy.springjdbctutorial.core.auth.dao.PermissionDao;
import com.edgareldy.springjdbctutorial.core.auth.entity.Permission;
import com.edgareldy.springjdbctutorial.core.auth.mapper.PermissionRowMapper;
import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao;
import org.springframework.jdbc.core.JdbcTemplate;

import java.util.List;
import java.util.Optional;

/**
* JdbcTemplate implementation of PermissionDao. Declared by @Bean in DaoConfig, no stereotype.
* <p>
* Created edgar.muhamyangabo on 9/19/26
* Author : edgar.muhamyangabo
* Date : 9/19/26
* Project : spring-jdbc-tutorial
*/
public class PermissionDaoImpl extends AbstractDao implements PermissionDao {

private final PermissionRowMapper rowMapper = new PermissionRowMapper();

public PermissionDaoImpl(JdbcTemplate jdbcTemplate) {
super(jdbcTemplate);
}

@Override
public List<Permission> findAll() {
return getJdbcTemplate().query(
"SELECT id, resource, action FROM permissions ORDER BY resource, action", rowMapper);
}

@Override
public Optional<Permission> findById(Long id) {
return findOne("SELECT id, resource, action FROM permissions WHERE id = ?", rowMapper, id);
}

@Override
public boolean existsByResourceAndAction(String resource, String action) {
return count("SELECT COUNT(*) FROM permissions WHERE resource = ? AND action = ?", resource, action) > 0;
}

@Override
public Permission insert(Permission permission) {
long id = insertReturningId(
"INSERT INTO permissions (resource, action) VALUES (?, ?) RETURNING id",
permission.getResource(), permission.getAction());
permission.setId(id);
return permission;
}

@Override
public void update(Permission permission) {
getJdbcTemplate().update("UPDATE permissions SET resource = ?, action = ? WHERE id = ?",
permission.getResource(), permission.getAction(), permission.getId());
}

@Override
public void delete(Long id) {
getJdbcTemplate().update("DELETE FROM permissions WHERE id = ?", id);
}

@Override
public long countRolesWithPermission(Long permissionId) {
return count("SELECT COUNT(*) FROM role_permission WHERE permission_id = ?", permissionId);
}
}
Loading
Loading