abase\Eloquent\Model;
class Permission extends Model
{
protected $fillable = ['name', 'resource', 'action', 'conditions'];
protected $casts = ['conditions' => 'array'];
}
class Role extends Model
{
protected $fillable = ['name', 'description', 'level'];
public function permissions()
{
return $this->belongsToMany(Permission::class);
}
}
```php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
class AccessControlService
{
public function canAccess(User $user, string $resource, string $action, array $context = []): bool
{
$permissions = $this->getUserPermissions($user);
foreach ($permissions as $permission) {
if ($permission->resource !== $resource) continue;
if ($permission->action !== $action && $permission->action !== '*') continue;
if ($permission->conditions) {
if (!$this->evaluateConditions($permission->conditions, $context, $user)) {
continue;
}
}
$this->logAccess($user, $resource, $action, $context, true);
return true;
}
$this->logAccess($user, $resource, $action, $context, false);
return false;
}
private function evaluateConditions(array $conditions, array $context, User $user): bool
{
foreach ($conditions as $condition) {
switch ($condition['type']) {
case 'own_patients_only':
if (($context['patient_id'] ?? null) &&
!$user->patients()->where('id', $context['patient_id'])->exists()) {
return false;
}
break;
case 'department_match':
if (($context['department_id'] ?? null) !== $user->department_id) {
return false;
}
break;
case 'time_restricted':
$now = now();
if ($now->hour < $condition['start_hour'] || $now->hour > $condition['end_hour']) {
return false;
}
break;
}
}
return true;
}
private function getUserPermissions(User $user)
{
return Cache::remember(
"user_permissions:{$user->id}",
now()->addMinutes(15),
fn () => $user->roles()->with('permissions')->get()->pluck('permissions')->flatten()
);
}
private function logAccess(User $user, string $resource, string $action, array $context, bool $granted): void
{
\App\Models\AccessLog::create([
'user_id' => $user->id,
'resource' => $resource,
'action' => $action,
'context' => $context,
'granted' => $granted,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'accessed_at' => now(),
]);
}
}
Node.js: Middleware-Based Access Control
import { Request, Response, NextFunction } from 'express';
interface AccessRule {
resource: string;
action: string;
conditions?: Array<{
type: string;
[key: string]: unknown;
}>;
}
export function requireAccess(resource: string, action: string) {
return async (req: Request, res: Response, next: NextFunction) => {
const user = (req as any).user;
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const context = {
patient_id: req.params.patientId,
department_id: req.params.departmentId,
ip_address: req.ip,
};
const granted = await checkAccess(user, resource, action, context);
await logAccess({
userId: user.id,
resource,
action,
context,
granted,
ipAddress: req.ip!,
userAgent: req.headers['user-agent'] || '',
accessedAt: new Date(),
});
if (!granted) {
return res.status(403).json({
error: 'Access denied',
resource,
action,
});
}
next();
};
}
async function checkAccess(
user: any,
resource: string,
action: string,
context: Record<string, unknown>
): Promise<boolean> {
const permissions = await getUserPermissions(user.id);
return permissions.some((perm: AccessRule) => {
if (perm.resource !== resource) return false;
if (perm.action !== action && perm.action !== '*') return false;
if (perm.conditions) {
return perm.conditions.every((condition) =>
evaluateCondition(condition, context, user)
);
}
return true;
});
}
function evaluateCondition(
condition: { type: string; [key: string]: unknown },
context: Record<string, unknown>,
user: any
): boolean {
switch (condition.type) {
case 'own_patients_only':
return user.patientIds?.includes(context.patient_id) ?? false;
case 'department_match':
return user.departmentId === context.department_id;
default:
return false;
}
}
Apply to routes:
router.get(
'/patients/:patientId/records',
requireAccess('patient_records', 'read'),
patientRecordController.list
);
router.post(
'/patients/:patientId/records',
requireAccess('patient_records', 'write'),
patientRecordController.create
);
router.get(
'/patients/:patientId/records/:recordId',
requireAccess('patient_records', 'read'),
patientRecordController.show
);
Field-Level Encryption
Not all data in a patient record is equally sensitive. Name and date of birth require protection, but appointment times may not. Field-level encryption encrypts specific fields within a record rather than encrypting the entire database:
Laravel: Encrypted Attributes
namespace App\Models\Traits;
use Illuminate\Support\Facades\Crypt;
trait EncryptsHealthData
{
protected static array $encryptedFields = [];
public function getAttribute($key)
{
$value = parent::getAttribute($key);
if (in_array($key, static::$encryptedFields) && $value !== null) {
try {
return Crypt::decryptString($value);
} catch (\Exception) {
return $value;
}
}
return $value;
}
public function setAttribute($key, $value)
{
if (in_array($key, static::$encryptedFields) && $value !== null) {
$value = Crypt::encryptString($value);
}
return parent::setAttribute($key, $value);
}
public static function searchEncrypted(string $field, string $value): ?static
{
if (!in_array($field, static::$encryptedFields)) {
return static::where($field, $value)->first();
}
$blindIndex = hash('sha256', strtolower(trim($value)) . config('app.encryption_salt'));
return static::where("{$field}_index", $blindIndex)->first();
}
}
class PatientRecord extends Model
{
use EncryptsHealthData;
protected static array $encryptedFields = [
'diagnosis',
'treatment_notes',
'medication_details',
'lab_results',
'genetic_data',
];
protected $fillable = [
'patient_id', 'record_type', 'diagnosis', 'diagnosis_index',
'treatment_notes', 'medication_details', 'lab_results',
'genetic_data', 'provider_id', 'facility_id', 'recorded_at',
];
}
Node.js: Encryption Service
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');
export function encryptField(plaintext: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
}
export function decryptField(ciphertext: string): string {
const [ivHex, authTagHex, encrypted] = ciphertext.split(':');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
export function createBlindIndex(value: string): string {
return crypto.createHash('sha256').update(value.toLowerCase().trim() + process.env.ENCRYPTION_SALT!).digest('hex');
}
Pitfall Guide
- Permission Cache Staleness: Caching RBAC permissions improves performance but creates a window where revoked roles remain active. Always implement cache invalidation hooks on role/permission updates, or use short TTLs with background refresh.
- Blind Index Collisions & Case Sensitivity: Encrypted field searches rely on deterministic hashing. Failing to normalize case, trim whitespace, or use a consistent salt causes search failures. Always apply
toLowerCase().trim() and a server-side salt before hashing.
- Over-Encryption of Low-Sensitivity Data: Encrypting entire tables or non-PII fields introduces unnecessary CPU overhead and complicates database indexing. Reserve field-level encryption strictly for PHI/PII columns defined in your data classification matrix.
- Missing Denial Audit Trails: Compliance frameworks require proof of attempted unauthorized access. Logging only successful requests leaves critical gaps. Always persist
granted: false events with full context (IP, user agent, resource, timestamp).
- Hardcoded or Unrotated Encryption Keys: Storing keys in environment variables or code violates key management best practices. Integrate with a KMS (AWS KMS, HashiCorp Vault, Azure Key Vault) and enforce automated key rotation with versioned ciphertext prefixes.
- Ignoring Contextual Access Constraints: Static role assignments cannot enforce healthcare-specific rules like "only view own patients" or "access restricted to clinic hours." Always layer contextual conditions (
own_patients_only, department_match, time_restricted) on top of base RBAC.
- Unvalidated Decryption Fallbacks: Catching decryption exceptions and returning raw ciphertext silently exposes data. Implement strict fail-closed behavior: log the error, alert security ops, and return a sanitized placeholder or HTTP 500.
Deliverables
- Healthcare Security Architecture Blueprint: Multi-layer defense diagram with client, application, data, and infrastructure control mappings aligned to HIPAA/GDPR/NDPR requirements.
- RBAC & Field-Level Encryption Implementation Checklist: Step-by-step validation matrix covering key management, blind indexing, cache invalidation, audit schema, and compliance sign-off criteria.
- Configuration Templates: Production-ready
config/app.php (Laravel encryption salt/KMS bindings), docker-compose.yml (network segmentation, vault sidecar), and Express middleware setup scripts.
- Audit Logging Schema: PostgreSQL/MySQL DDL for
access_logs with immutable append-only constraints, JSONB context storage, and automated retention/purging policies aligned with regulatory data lifecycle requirements.