October 2, 2026
From Architecture to Code: Implementing MedNet’s 3-Layer Authorization System in Java
In our previous discussion, we explored why rigid, vanilla Role-Based Access Control (RBAC) fractures under the weight of enterprise…
By Youaleu Frank Loic
5 min read
In our previous discussion, we explored why rigid, vanilla Role-Based Access Control (RBAC) fractures under the weight of enterprise requirements — especially in a system like MedNet, our Electronic Health Record (EHR) platform, where patient-centric consent must overrule administrative hierarchies.
We outlined our solution: a 3-Layer Defense-in-Depth Authorization Model.
Today, we are going to look directly at the production-grade blueprints. We will write the concrete Java, Spring Boot, and Jakarta EE patterns that power this architecture, showing how to balance lightning-fast stateless JWT token validation with heavy, dynamic contextual evaluation.
Recap: The 3 Layers We Are Building
- Scoped RBAC & The
JdbcPermissionResolver: Roles are never global. They carry geographical and structural constraints (e.g., Doctor at Laquintinie Hospital, Douala). These roles resolve into flat permission codes. - Flat Permissions (The "Fast Pass"): These codes live inside the stateless JWT claims, allowing us to use
@PreAuthorize("hasAuthority('...')")for instant, zero-database-hit rejections. - ABAC & Dynamic Consent Engine (The Gatekeeper): A secondary programmatic interceptor layer (
ConsentAccessDecisionService) that evaluates real-time data ownership, guardianship, and emergency break-glass scenarios.
Let's dive into the implementation.
Layer 1 & 2: Resolving Scoped Roles to Stateless JWT Claims
When a user logs in, we need to gather permissions across multiple assignments (e.g., a doctor might have a regular membership at Hospital A, but an emergency admin assignment covering an entire District).
The JdbcPermissionResolver merges these sources into a flat set of permission string tokens that travel inside the user's JWT.
The Spring Boot / Java Implementation
java
package com.mednet.security.core;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Service
public class JdbcPermissionResolver {
private final NamedParameterJdbcTemplate jdbcTemplate;
public JdbcPermissionResolver(NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public Set<String> resolveFlatPermissions(Long accountId) {
Set<String> flatPermissions = new HashSet<>();
String sql = """
-- 1. Grab permissions from active hospital memberships
SELECT p.code FROM permission p
JOIN role_permission rp ON p.id = rp.permission_id
JOIN hospital_membership hm ON rp.role_id = hm.role_id
WHERE hm.account_id = :accountId AND hm.status = 'ACTIVE'
UNION
-- 2. Grab direct, explicit membership ALLOW overrides
SELECT p.code FROM permission p
JOIN direct_grant dg ON p.id = dg.permission_id
WHERE dg.account_id = :accountId AND dg.type = 'ALLOW'
UNION
-- 3. Grab permissions from administrative assignments (District/Region/Country)
SELECT p.code FROM permission p
JOIN role_permission rp ON p.id = rp.permission_id
JOIN admin_assignment aa ON rp.role_id = aa.role_id
WHERE aa.account_id = :accountId AND aa.status = 'ACTIVE'
""";
MapSqlParameterSource params = new MapSqlParameterSource("accountId", accountId);
List<String> codes = jdbcTemplate.queryForList(sql, params, String.class);
flatPermissions.addAll(codes);
return flatPermissions;
}
}package com.mednet.security.core;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Service
public class JdbcPermissionResolver {
private final NamedParameterJdbcTemplate jdbcTemplate;
public JdbcPermissionResolver(NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public Set<String> resolveFlatPermissions(Long accountId) {
Set<String> flatPermissions = new HashSet<>();
String sql = """
-- 1. Grab permissions from active hospital memberships
SELECT p.code FROM permission p
JOIN role_permission rp ON p.id = rp.permission_id
JOIN hospital_membership hm ON rp.role_id = hm.role_id
WHERE hm.account_id = :accountId AND hm.status = 'ACTIVE'
UNION
-- 2. Grab direct, explicit membership ALLOW overrides
SELECT p.code FROM permission p
JOIN direct_grant dg ON p.id = dg.permission_id
WHERE dg.account_id = :accountId AND dg.type = 'ALLOW'
UNION
-- 3. Grab permissions from administrative assignments (District/Region/Country)
SELECT p.code FROM permission p
JOIN role_permission rp ON p.id = rp.permission_id
JOIN admin_assignment aa ON rp.role_id = aa.role_id
WHERE aa.account_id = :accountId AND aa.status = 'ACTIVE'
""";
MapSqlParameterSource params = new MapSqlParameterSource("accountId", accountId);
List<String> codes = jdbcTemplate.queryForList(sql, params, String.class);
flatPermissions.addAll(codes);
return flatPermissions;
}
}Use code with caution.
Once these are loaded into your security context at login, your Spring Security pipeline bakes them straight into the token payload claims.
The Fast Pass Filter
On 108 of our 122 REST endpoints, we now get a stateless, ultra-performant gateway check. If an incoming request lacks the core capability string, it fails immediately before wasting compute cycles or triggering deep database transactions.
java
@RestController
@RequestMapping("/api/v1/clinical/patients")
public class PatientClinicalController {
@GetMapping("/{patientId}/vitals")
@PreAuthorize("hasAuthority('vitals.read')")
public ResponseEntity<VitalsRecord> getPatientVitals(@PathVariable Long patientId) {
// Layer 1 and 2 passed. We know they are authorized to read vitals *somewhere*.
// Now, we move to Layer 3 to check if they can read *this specific patient's* vitals.
return ResponseEntity.ok(consentGatekeeper.evaluateAndFetch(patientId));
}
}@RestController
@RequestMapping("/api/v1/clinical/patients")
public class PatientClinicalController {
@GetMapping("/{patientId}/vitals")
@PreAuthorize("hasAuthority('vitals.read')")
public ResponseEntity<VitalsRecord> getPatientVitals(@PathVariable Long patientId) {
// Layer 1 and 2 passed. We know they are authorized to read vitals *somewhere*.
// Now, we move to Layer 3 to check if they can read *this specific patient's* vitals.
return ResponseEntity.ok(consentGatekeeper.evaluateAndFetch(patientId));
}
}Use code with caution.
Layer 3: The Contextual Gatekeeper (ConsentAccessDecisionService)
Passing the @PreAuthorize token check is necessary, but never sufficient.
To handle complex legal constraints — such as a patient explicitly revoking a doctor's visibility, or an ER team activating a tracking override during a medical crisis — we hand execution over to the ConsentAccessDecisionService.
The Core Request Context Domain
java
public record AccessRequest(
Long patientId,
Long requesterAccountId,
String requesterRole,
Long activeHospitalId,
String dataCategory,
boolean isEmergencyOverride
) {}public record AccessRequest(
Long patientId,
Long requesterAccountId,
String requesterRole,
Long activeHospitalId,
String dataCategory,
boolean isEmergencyOverride
) {}Use code with caution.
The Engine Implementation
java
package com.mednet.security.abac;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service
public class ConsentAccessDecisionService {
private static final Logger auditLogger = LoggerFactory.getLogger("MEDNET_AUDIT_LOG");
private final ConsentRepository consentRepo;
private final RelationshipRepository relationshipRepo;
public ConsentAccessDecisionService(ConsentRepository consentRepo, RelationshipRepository relationshipRepo) {
this.consentRepo = consentRepo;
this.relationshipRepo = relationshipRepo;
}
public boolean evaluate(AccessRequest request) {
// Rule A: Dynamic Break-Glass Provision
if (request.isEmergencyOverride() && "ER_Doctor".equals(request.requesterRole())) {
logAudit(request, "ALLOWED", "Emergency Break-Glass provision activated.");
return true;
}
// Rule B: Verify Explicit Patient-Centric Consent
boolean hasExplicitConsent = consentRepo.hasValidGrant(
request.patientId(),
request.requesterAccountId(),
request.dataCategory()
);
if (hasExplicitConsent) {
logAudit(request, "ALLOWED", "Explicit user-granted patient consent verified.");
return true;
}
// Rule C: Guardianship Verification with Age Threshold
int patientAge = consentRepo.getPatientAge(request.patientId());
if (patientAge < 18) {
boolean isRegisteredGuardian = relationshipRepo.isGuardian(request.requesterAccountId(), request.patientId());
if (isRegisteredGuardian) {
logAudit(request, "ALLOWED", "Access granted via active minority guardianship.");
return true;
}
}
// Rule D: Fallback to Active Clinical Relationship
boolean hasActiveTreatmentRelation = relationshipRepo.hasActiveCareRelationship(
request.requesterAccountId(),
request.patientId(),
request.activeHospitalId()
);
// Ensure no explicit privacy restriction blocks this clinician
boolean isExplicitlyRestricted = consentRepo.isRestricted(request.patientId(), request.requesterAccountId());
if (hasActiveTreatmentRelation && !isExplicitlyRestricted) {
logAudit(request, "ALLOWED", "Access granted via active care team relationship context.");
return true;
}
// Default: Strict Denial
logAudit(request, "DENIED", "No matching consent, relationship, or emergency rules met.");
return false;
}
private void logAudit(AccessRequest req, String result, String reason) {
// Crucial Property: Every execution point writes exactly one secure log line.
auditLogger.info("AUDIT_RECORD | Actor: {} | Target Patient: {} | Action: {} | Decision: {} | Justification: {}",
req.requesterAccountId(), req.patientId(), req.dataCategory(), result, reason);
}
}package com.mednet.security.abac;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service
public class ConsentAccessDecisionService {
private static final Logger auditLogger = LoggerFactory.getLogger("MEDNET_AUDIT_LOG");
private final ConsentRepository consentRepo;
private final RelationshipRepository relationshipRepo;
public ConsentAccessDecisionService(ConsentRepository consentRepo, RelationshipRepository relationshipRepo) {
this.consentRepo = consentRepo;
this.relationshipRepo = relationshipRepo;
}
public boolean evaluate(AccessRequest request) {
// Rule A: Dynamic Break-Glass Provision
if (request.isEmergencyOverride() && "ER_Doctor".equals(request.requesterRole())) {
logAudit(request, "ALLOWED", "Emergency Break-Glass provision activated.");
return true;
}
// Rule B: Verify Explicit Patient-Centric Consent
boolean hasExplicitConsent = consentRepo.hasValidGrant(
request.patientId(),
request.requesterAccountId(),
request.dataCategory()
);
if (hasExplicitConsent) {
logAudit(request, "ALLOWED", "Explicit user-granted patient consent verified.");
return true;
}
// Rule C: Guardianship Verification with Age Threshold
int patientAge = consentRepo.getPatientAge(request.patientId());
if (patientAge < 18) {
boolean isRegisteredGuardian = relationshipRepo.isGuardian(request.requesterAccountId(), request.patientId());
if (isRegisteredGuardian) {
logAudit(request, "ALLOWED", "Access granted via active minority guardianship.");
return true;
}
}
// Rule D: Fallback to Active Clinical Relationship
boolean hasActiveTreatmentRelation = relationshipRepo.hasActiveCareRelationship(
request.requesterAccountId(),
request.patientId(),
request.activeHospitalId()
);
// Ensure no explicit privacy restriction blocks this clinician
boolean isExplicitlyRestricted = consentRepo.isRestricted(request.patientId(), request.requesterAccountId());
if (hasActiveTreatmentRelation && !isExplicitlyRestricted) {
logAudit(request, "ALLOWED", "Access granted via active care team relationship context.");
return true;
}
// Default: Strict Denial
logAudit(request, "DENIED", "No matching consent, relationship, or emergency rules met.");
return false;
}
private void logAudit(AccessRequest req, String result, String reason) {
// Crucial Property: Every execution point writes exactly one secure log line.
auditLogger.info("AUDIT_RECORD | Actor: {} | Target Patient: {} | Action: {} | Decision: {} | Justification: {}",
req.requesterAccountId(), req.patientId(), req.dataCategory(), result, reason);
}
}Use code with caution.
Portability: Dropping this Pattern into Jakarta EE / EJB Monoliths
If you are running this architecture inside a traditional enterprise monolith using Jakarta EE (EJB 4), the paradigm maps over elegantly using Jakarta Interceptors (@AroundInvoke) and standard JAAS contexts instead of Spring Aspects.
java
package com.mednet.security.ee;
import jakarta.annotation.Resource;
import jakarta.ejb.SessionContext;
import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.InvocationContext;
import java.util.Map;
public class ConsentInterceptor {
@Resource
private SessionContext sessionContext;
@Inject
private EJBContextPermissionResolver permissionResolver;
@AroundInvoke
public Object interceptLifecycle(InvocationContext context) throws Throwable {
// Layer 1 & 2 Check: Simulating the 'Fast Pass'
String principalName = sessionContext.getCallerPrincipal().getName();
boolean isAuthorizedCapability = sessionContext.isCallerInRole("vitals.read");
if (!isAuthorizedCapability) {
throw new SecurityException("EJB Early Reject: Missing capability token.");
}
// Layer 3 Check: Parse arguments dynamically for context extraction
Long targetPatientId = null;
for (Object param : context.getParameters()) {
if (param instanceof ClinicalPayload payload) {
targetPatientId = payload.getPatientId();
break;
}
}
// If target resource is found, evaluate runtime attributes against engine
if (targetPatientId != null) {
boolean isAllowed = permissionResolver.evaluateEngine(principalName, targetPatientId);
if (!isAllowed) {
throw new SecurityException("EJB Fine-Grained Access Blocked: Consent violation.");
}
}
return context.proceed();
}
}package com.mednet.security.ee;
import jakarta.annotation.Resource;
import jakarta.ejb.SessionContext;
import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.InvocationContext;
import java.util.Map;
public class ConsentInterceptor {
@Resource
private SessionContext sessionContext;
@Inject
private EJBContextPermissionResolver permissionResolver;
@AroundInvoke
public Object interceptLifecycle(InvocationContext context) throws Throwable {
// Layer 1 & 2 Check: Simulating the 'Fast Pass'
String principalName = sessionContext.getCallerPrincipal().getName();
boolean isAuthorizedCapability = sessionContext.isCallerInRole("vitals.read");
if (!isAuthorizedCapability) {
throw new SecurityException("EJB Early Reject: Missing capability token.");
}
// Layer 3 Check: Parse arguments dynamically for context extraction
Long targetPatientId = null;
for (Object param : context.getParameters()) {
if (param instanceof ClinicalPayload payload) {
targetPatientId = payload.getPatientId();
break;
}
}
// If target resource is found, evaluate runtime attributes against engine
if (targetPatientId != null) {
boolean isAllowed = permissionResolver.evaluateEngine(principalName, targetPatientId);
if (!isAllowed) {
throw new SecurityException("EJB Fine-Grained Access Blocked: Consent violation.");
}
}
return context.proceed();
}
}Architectural Wins of the Split
The practical benefit of this separation is not simply that we have more authorization checks. It is that each layer has a clearly defined responsibility.
The first layer answers:
Does this user have the capability to perform this type of operation?
The second answers:
Does that capability apply within the user's assigned scope?
And the contextual layer answers:
Can this user access this particular patient's data under the current circumstances?
That separation gives us two important benefits.
1. Fast rejection before expensive evaluation
A request that does not contain the required permission should not reach the more expensive authorization logic.
For example:
@PreAuthorize("hasAuthority('vitals.read')")@PreAuthorize("hasAuthority('vitals.read')")can reject a request before we evaluate patient consent, clinical relationships, guardianship, or other contextual rules.
The contextual authorization layer is therefore reserved for requests that have already passed the basic capability check.
2. Authorization decisions remain explicit and auditable
JWT claims tell us what permissions were granted to the caller when the token was issued. They do not tell us why a particular patient-level request was ultimately allowed or denied.
That decision happens inside the ConsentAccessDecisionService, where we can evaluate the current context and record the reason for the decision.
For example:
ALLOW → Active clinical relationship
DENY → Patient restriction
ALLOW → Active break-glass session
DENY → No applicable authorization ruleALLOW → Active clinical relationship
DENY → Patient restriction
ALLOW → Active break-glass session
DENY → No applicable authorization ruleThis makes the authorization decision explainable instead of hiding all of the logic inside roles or tokens.
The main lesson
Authorization becomes difficult when we expect one mechanism to answer every question.
Roles are useful for defining broad responsibilities.
Permissions are useful for expressing capabilities.
Scopes define where those capabilities apply.
Contextual policies determine whether access to a specific resource is appropriate at that moment.
In MedNet, we deliberately keep those concerns separate.
The result is not a more complicated authorization system for the sake of complexity. It is a system where each mechanism is responsible for the question it is actually designed to answer.
Don't make one authorization mechanism answer three different questions.