Security & Compliance
Status: Final Version: 1.2
Purpose
Expand security requirements beyond RBAC to cover encryption, PII handling, threat modeling, access logging, and regulatory compliance (GDPR, SOC 2).
Encryption
At Rest
Database Encryption: - PostgreSQL: Enable Transparent Data Encryption (TDE) or use encrypted EBS volumes - Quarkus: Encrypt PII fields using JPA AttributeConverter
@Entity
@Table(name = "metric_submissions")
class MetricSubmission(
@Id
@GeneratedValue(strategy = GenerationType.UUID)
val id: UUID? = null,
@Convert(converter = EncryptedJsonConverter::class)
@Column(name = "pii_data", columnDefinition = "TEXT")
val piiData: Map<String, Any>? = null
)
@ApplicationScoped
class EncryptedJsonConverter(
@Inject
private val encryptor: CustomEncryptor
) : AttributeConverter<Map<String, Any>?, String?> {
private val objectMapper = ObjectMapper()
override fun convertToDatabaseColumn(attribute: Map<String, Any>?): String? {
return attribute?.let {
val json = objectMapper.writeValueAsString(it)
encryptor.encrypt(json)
}
}
override fun convertToEntityAttribute(dbData: String?): Map<String, Any>? {
return dbData?.let {
val decrypted = encryptor.decrypt(it)
objectMapper.readValue(decrypted, object : TypeReference<Map<String, Any>>() {})
}
}
}
// Custom encryptor using Quarkus security vault or custom implementation
@ApplicationScoped
class CustomEncryptor {
@Inject
@ConfigProperty(name = "encryption.key")
lateinit var encryptionKey: String
fun encrypt(plaintext: String): String {
// Implement encryption logic using AES or other algorithm
// Can integrate with Quarkus security vault
// Example: using javax.crypto.Cipher
return encryptedValue
}
fun decrypt(ciphertext: String): String {
// Implement decryption logic
return decryptedValue
}
}
File Storage Encryption:
- S3: Enable Server-Side Encryption (SSE-AES256 or SSE-KMS)
- Evidence bucket: ServerSideEncryption: AES256
In Transit
- TLS 1.2+ required for all HTTPS connections
- Certificate Management: AWS Certificate Manager or Let's Encrypt
- HSTS Header:
Strict-Transport-Security: max-age=31536000; includeSubDomains
PII Handling (GDPR Compliance)
Identified PII Fields
| Field/Metric | PII Type | Location | Handling |
|---|---|---|---|
| Employee names | Direct PII | Social metrics (diversity, training) | Encrypted, access-logged |
| Email addresses | Direct PII | users table |
Encrypted |
| IP addresses | Indirect PII | audit_logs.ip_address |
Anonymized after 90 days |
| Employee demographics (gender, age group, local community status) | Indirect PII | metric_submissions.raw_data, metric_submissions.processed_data (GRI 405-1, GRI 401, Age Demographics) |
Aggregated counts only (minimum 5 employees per category), no individual identifiers, encrypted at rest, access-logged |
| Employment level breakdowns | Indirect PII | metric_submissions.raw_data (Executive, Salaried, Waged headcount) |
Aggregated counts only (minimum 5 employees per category), encrypted at rest, access-logged |
| Recruitment and turnover data | Indirect PII | metric_submissions.value (GRI 401 monthly metrics) |
Aggregated monthly totals only, no individual employee records, access-logged |
GDPR Rights Implementation
Right to Erasure (Article 17)
@ApplicationScoped
@Transactional
class GdprService(
@Inject
private val userRepository: UserRepository,
@Inject
private val submissionRepository: SubmissionRepository,
@Inject
private val auditLogRepository: AuditLogRepository,
@Inject
private val auditLogService: AuditLogService
) {
fun eraseUserData(user: User) {
// Anonymize user record
user.apply {
name = "User ${user.id} (Deleted)"
email = "deleted_${user.id}@anonymized.local"
phone = null
}
userRepository.persist(user)
// Remove PII from submissions (keep aggregated data)
submissionRepository.findByUserId(user.id).forEach { submission ->
submission.piiData = null
submission.metadata = submission.metadata?.toMutableMap()?.apply {
put("collector_notes", "[Redacted]")
}
submissionRepository.persist(submission)
}
// Keep audit trail but anonymize actor
auditLogRepository.findByActorUserId(user.id).forEach { log ->
log.actorUserId = null
auditLogRepository.persist(log)
}
auditLogService.log(
action = "gdpr.erasure_requested",
entity = user,
changes = null,
notes = "User data anonymized per GDPR Article 17"
)
}
}
Right to Data Portability (Article 20)
data class UserDataExport(
val user: UserInfo,
val submissions: List<SubmissionWithEvidence>,
val auditLog: List<AuditLog>
)
data class UserInfo(
val name: String,
val email: String,
val createdAt: Instant
)
@ApplicationScoped
class GdprService(
@Inject
private val userRepository: UserRepository,
@Inject
private val submissionRepository: SubmissionRepository,
@Inject
private val auditLogRepository: AuditLogRepository
) {
fun exportUserData(user: User): UserDataExport {
return UserDataExport(
user = UserInfo(
name = user.name,
email = user.email,
createdAt = user.createdAt
),
submissions = submissionRepository.findByUserIdWithEvidence(user.id),
auditLog = auditLogRepository.findByActorUserId(user.id)
)
}
}
HR Data Anonymization & Aggregation Guidance
Privacy Requirements for Human Capital Metrics
Employee demographics (GRI 405-1, GRI 401, age distribution) contain indirect PII because headcount breakdowns by gender, age group, employment level, or local community status can potentially identify individuals in small teams or niche roles.
Privacy Principle: The platform MUST NOT store, transmit, or report individual employee records. Only aggregated counts are permitted.
Aggregation Thresholds
Minimum Aggregation Threshold: 5 employees per category
Rationale: - Categories with 1-4 employees risk individual identification (e.g., "1 female executive" reveals specific person) - Threshold of 5 balances data utility with privacy protection - Aligns with statistical disclosure control best practices
Implementation: 1. Collection Stage: Collectors submit aggregated counts only (no individual names, IDs, or ages) 2. Validation Stage: PII Aggregation Validator flags categories with <5 employees as warnings (soft check, not hard failure) 3. Reporting Stage: Report generation suppresses or aggregates categories below threshold
Example Scenarios:
| Scenario | Action |
|---|---|
| Site has 3 female executives | Suppress: Do not report gender breakdown for Executive level at this site. Report only site-level totals or aggregate to organisation level. |
| Site has 12 waged staff: 8 male, 4 female | Flag Warning: Reviewer should consider aggregating site data to organisation level or suppressing gender breakdown. |
| Organisation has 150 employees across 5 sites with balanced demographics | Safe: All categories exceed threshold, full disaggregation permitted. |
Anonymization Techniques
1. Aggregation to Higher Level
When: Site-level data has small counts (<5 per category)
Action: Aggregate site-level data to business unit or organisation level
Example: - Site A: 2 female executives, 3 male executives → Aggregate to organisation level with other sites - Organisation total: 15 female executives, 25 male executives → Safe to report
Platform Implementation: Report generation API supports aggregationLevel parameter:
- aggregationLevel: "site" - Returns separate sheets/tables per site (only if all categories exceed threshold)
- aggregationLevel: "organisation" - Returns single aggregated output across all sites (default, safest)
2. Suppression of Small Categories
When: Cannot aggregate (single-site organisation) and category has <5 employees
Action: Replace specific count with "[Suppressed]" or aggregate into "Other" category
Example: - "From Local Community: 2 employees" → "From Local Community: [Suppressed for privacy]" - Gender breakdown: "Male: 45, Female: 3" → "Male: 45, Female/Other: 3" (if grouping feasible)
Platform Implementation: Report validation checks flag small categories. Manual reviewer decides on suppression or aggregation.
3. Broader Age Ranges
Why Age Groups (Not Specific Ages): - Specific ages or birthdates would be direct PII - Age groups (Under 30, 30-50, Over 50) are broad enough to prevent identification
Platform Rule: Collectors submit counts by age group only. Individual ages are NEVER collected, stored, or processed.
Example Data Flow: - ❌ PROHIBITED: "Employee ID 123: Age 32, Male, Executive" - ✅ PERMITTED: "Aged 30-50: 15 employees, Male: 9, Female: 6"
4. No Individual Employee Identifiers
Prohibited Fields in HR Metric Submissions: - Employee names - Employee IDs - National ID numbers - Email addresses - Specific job titles (use employment level categories instead) - Specific ages or birthdates (use age groups)
Platform Enforcement: 1. Collection templates do NOT include fields for individual identifiers 2. Validation engine rejects submissions containing unexpected PII fields 3. Mobile UI forms only accept aggregated counts (no employee lists)
Correct Collection Pattern (GRI 405-1 Example):
{
"metric_id": "GRI_405_1_EXECUTIVE_HEADCOUNT",
"reporting_period_id": "uuid-period",
"organisation_id": "uuid-org",
"site_id": null,
"activity_date": "2025-12-31",
"raw_data": {
"total_headcount": 40,
"male_count": 25,
"female_count": 15,
"local_community_count": 8
}
}
Incorrect Collection Pattern (PROHIBITED):
{
"employees": [
{"name": "John Doe", "gender": "Male", "age": 52, "local": true},
{"name": "Jane Smith", "gender": "Female", "age": 45, "local": false}
]
}
Evidence File Anonymization
Requirement: Evidence files (HR registers, payroll reports) uploaded to support HR metrics MUST NOT contain individual employee details visible in aggregated reports.
Acceptable Evidence: - Aggregated headcount summaries from HR system (totals by category) - Payroll summary reports (total employee counts, not itemized lists) - Board reports with executive headcount only - Attestation letters from HR manager certifying aggregated counts
Unacceptable Evidence (Must Be Redacted Before Upload): - Employee rosters with full names and demographic details - Itemized payroll reports listing individual salaries - Individual employment contracts - Personal employee files
Redaction Guidance: - If evidence file contains individual records, redact names, IDs, and other direct identifiers before upload - Keep aggregated totals and category counts (the data points needed for reporting) - Use PDF redaction tools or export summary-only reports from HR systems - Add redaction note in evidence description: "Individual employee identifiers redacted for privacy"
Platform Feature (vNext): Automated PII detection in evidence files with warning prompts before upload.
Access Controls for HR Metrics
Role-Based Access: - Collectors: Can submit aggregated HR data for their assigned sites/organisations (no access to other sites' data) - Reviewers: Can view HR submissions for sites in their scope (site or business unit level) - Approvers: Can view and approve HR submissions for entire organisation - Admins: Full access to all HR data (with mandatory access logging and audit justification) - Auditors: Read-only access to all HR data for assurance purposes
Access Logging: - All views of HR metrics (GRI 405-1, GRI 401, Age Demographics) are logged to audit trail - Log includes: user ID, timestamp, IP address, accessed endpoint, filters applied - Logs retained for 7 years for compliance and audit purposes - Anomalous access patterns (e.g., bulk downloads) trigger alerts
Query Example (Access Log for HR Metrics):
SELECT
al.created_at,
u.name AS user_name,
u.role,
al.action,
al.entity_type,
al.ip_address,
al.metadata->>'url' AS accessed_endpoint
FROM audit_logs al
JOIN users u ON al.actor_user_id = u.id
WHERE al.action = 'pii.accessed'
AND al.entity_type LIKE '%GRI_405%' OR al.entity_type LIKE '%GRI_401%'
ORDER BY al.created_at DESC;
Data Retention for HR Metrics
Retention Period: 10 years (aligns with GRI reporting archival requirements and labor law compliance)
After Retention Period: - Aggregated counts can be retained indefinitely (no PII risk) - Evidence files containing individual employee data must be securely deleted - Audit logs of data access retained for additional 7 years (compliance requirement)
Employee Departure: - When employee leaves organisation, individual record (if any) anonymized within 90 days - Aggregated historical counts remain unchanged (e.g., "Q4 2023: 50 employees" is accurate historical data) - GDPR "right to erasure" applies: Employee can request removal from historical aggregated data if technically feasible without distorting reports
Implementation: - Scheduled job runs quarterly to anonymize departed employees - HR system integration (vNext): Auto-sync employee status and trigger anonymization
Reporting Best Practices
When Generating HR Reports:
- Always Use Aggregation Level Appropriately:
- Multi-site organisations: Default to organisation-level aggregation unless site-level detail needed
-
Single-site organisations: Suppress small categories or use broader groupings
-
Flag Small Categories:
- Report generation includes "Data Quality Notes" section
-
Warnings: "Executive gender breakdown suppressed (n<5)" or "Site A age distribution aggregated to org level for privacy"
-
Apply Filters Carefully:
- Combining multiple filters (e.g., "Female Executives from Local Community at Site A") may create very small groups
-
Validator warns if filtered result has <5 records
-
Public vs Internal Reports:
- Public Reports (external stakeholders): Apply strictest aggregation (organisation-level, suppress all <5 categories)
- Internal Reports (management only): More granular data permitted if justified and access-logged
-
Assurance Reports (auditors only): Full granularity permitted with NDA and access controls
-
Derived Metrics (Percentages):
- Percentages calculated from aggregated counts are safe (e.g., "40% female executives" is not PII)
- Do NOT report percentages for small absolute counts (e.g., "100% of 2 executives" reveals count)
- Threshold: Only report percentages if denominator ≥ 10 employees
Example Report Configuration:
{
"report_type": "human_capital_report",
"aggregation_level": "organisation",
"apply_pii_threshold": true,
"minimum_category_size": 5,
"suppress_small_categories": true,
"public_report": true
}
Compliance Summary
| Requirement | Implementation | Status |
|---|---|---|
| GDPR Article 5 (Data Minimization) | Collect only aggregated counts, no individual records | ✅ Implemented |
| GDPR Article 5 (Purpose Limitation) | HR data used only for ESG reporting, not HR management | ✅ Documented |
| GDPR Article 9 (Special Categories) | Gender and local community status are sensitive; encrypted, access-logged | ✅ Implemented |
| GDPR Article 17 (Right to Erasure) | Anonymize individual data upon request (aggregated data unaffected) | ✅ Implemented |
| GDPR Article 32 (Security) | Encryption at rest, TLS in transit, access controls | ✅ Implemented |
| Statistical Disclosure Control | Minimum 5 employees per category, suppression/aggregation | ✅ Implemented |
| Labor Law Compliance | No individual performance data, only aggregated workforce metrics | ✅ By Design |
OHS Data Confidentiality & Access Control
Overview
Occupational Health and Safety (OHS) metrics and evidence require special confidentiality and access control measures due to the sensitive nature of incident data and potential inclusion of Personal Health Information (PHI). All OHS data is classified as CONFIDENTIAL, with additional protections for medical records.
OHS Data Sensitivity Classification
| Data Type | Sensitivity Level | Contains PII/PHI | Encryption Required | Access Logging Required |
|---|---|---|---|---|
| Incident Counts (Aggregated) | CONFIDENTIAL | No (aggregated only) | Yes (at rest and in transit) | Yes |
| LTI Days, LTIFR Metrics | CONFIDENTIAL | No (aggregated only) | Yes (at rest and in transit) | Yes |
| Incident Evidence Files | CONFIDENTIAL | Potentially (if unredacted) | Yes (at rest and in transit) | Yes |
| Medical Treatment Records | CONFIDENTIAL + PHI | Yes (diagnosis, treatment) | Yes (field-level encryption) | Yes (mandatory) |
| Fatality Reports | CONFIDENTIAL + PHI | Yes (victim information) | Yes (field-level encryption) | Yes (mandatory) |
| Accident Reports | CONFIDENTIAL | Potentially (if unredacted) | Yes (at rest and in transit) | Yes |
| Occupational Disease Registers | CONFIDENTIAL + PHI | Yes (employee medical history) | Yes (field-level encryption) | Yes (mandatory) |
Key Principles: 1. Aggregated Incident Counts Only: Platform collects only aggregated incident counts by workforce type (Mine vs Contractors). Individual employee records are NEVER stored in metric submissions. 2. PHI Redaction Required: Evidence files uploaded for OHS metrics must be redacted to remove individual employee names, ID numbers, medical diagnoses, and other Personal Health Information before upload. See Evidence Management - PHI Redaction Guidance. 3. Medical Records with PHI: Evidence files containing medical diagnoses or treatment details (MEDICAL_TREATMENT_RECORD, FATALITY_REPORT, OCCUPATIONAL_DISEASE_REGISTER) require additional field-level encryption beyond standard S3 encryption. 4. Fatality Victim Information: Fatality reports may contain victim information for regulatory notification purposes, but access is restricted to Approver, Admin, Auditor, and HSE Manager roles only.
Access Control by Role (OHS-Specific)
OHS data access follows the principle of least privilege, with role-based permissions designed to minimize exposure of sensitive incident data:
| Role | OHS Submission Access | OHS Evidence Access | Can Approve OHS Submissions | Access Logging |
|---|---|---|---|---|
| Collector | Create and view own site submissions only | Upload evidence for own submissions only; cannot view medical records after upload | No | All OHS submission views and evidence uploads logged |
| Reviewer | View all OHS submissions for assigned sites (site or business unit scope) | View evidence for assigned sites (excluding medical records with PHI) | No | All OHS submission views logged |
| Approver | View and approve all OHS submissions for entire organisation | View all evidence including medical records (with audit justification) | Yes (cannot approve own submissions) | All OHS submission views, evidence views, and approvals logged with justification |
| Admin | Full access to all OHS submissions across tenant | Full access to all evidence including medical records (with mandatory audit justification) | Yes (flagged in audit log) | All OHS data access logged with mandatory justification field |
| Auditor | Read-only access to all OHS submissions for assurance purposes | Read-only access to all evidence including medical records | No | All OHS data access logged |
| HSE Manager | Read-only access to all OHS submissions across organisation | Read-only access to all evidence including medical records (for safety analysis) | No | All OHS data access logged |
Additional Controls:
- Medical Records Restriction: Medical treatment records (MEDICAL_TREATMENT_RECORD, OCCUPATIONAL_DISEASE_REGISTER) are restricted to HSE Manager, Approver, Admin, and Auditor roles. Collectors and Reviewers cannot view medical records after upload.
- Fatality Evidence Restriction: Fatality reports (FATALITY_REPORT) are restricted to Approver, Admin, Auditor, and HSE Manager roles. Additional audit justification required for access.
- Segregation of Duties: Collectors cannot approve their own OHS submissions. Approvers should not routinely be collectors (policy enforcement).
- Evidence Download Logging: All downloads of OHS evidence files are logged to audit_logs with user ID, timestamp, IP address, evidence ID, and evidence type.
OHS-Specific PII/PHI Fields
OHS data collection follows strict aggregation-only principles to protect employee privacy:
| Field/Metric | PII/PHI Type | Location | Handling |
|---|---|---|---|
| Incident Counts (Near Miss, First Aid, LTI, Fatality, etc.) | Not PII (aggregated counts only) | metric_submissions.raw_data (JSONB) |
Aggregated by workforce type (Mine, Contractors); no individual identifiers; encrypted at rest, access-logged |
| LTI Days, LTIFR | Not PII (aggregated metrics only) | metric_submissions.raw_data (JSONB) |
Aggregated by workforce type; encrypted at rest, access-logged |
| Medical Treatment Records (Evidence) | PHI (diagnosis, treatment) | evidence.filepath (S3 storage) |
Field-level encryption required; access restricted to HSE Manager, Approver, Admin, Auditor; redaction required before upload |
| Fatality Reports (Evidence) | PHI (victim name, medical details) | evidence.filepath (S3 storage) |
Field-level encryption required; access restricted to Approver, Admin, Auditor, HSE Manager; may contain victim information for regulatory notification |
| Accident Reports (Evidence) | Potentially PII (employee names, witness statements) | evidence.filepath (S3 storage) |
Redaction required before upload (remove employee names, ID numbers); standard encryption at rest |
| Occupational Disease Registers (Evidence) | PHI (employee medical history) | evidence.filepath (S3 storage) |
Field-level encryption required; access restricted to HSE Manager, Approver, Admin, Auditor; redaction required |
Privacy Protection Rules: 1. No Individual Employee Records: The platform NEVER collects individual employee incident records. Only aggregated counts are permitted (e.g., "5 LTIs in Q1" not "John Doe had an LTI on 2025-03-15"). 2. PHI Redaction Mandatory: Evidence files containing individual employee names, medical diagnoses, or treatment details must be redacted before upload. See Evidence Management - PHI Redaction Guidance. 3. Aggregation Threshold Not Applicable: Unlike HR metrics (which require minimum 5 employees per category), OHS incident counts are reported as actual numbers (including zero). Small incident counts (e.g., "1 fatality") do not risk individual identification when properly redacted. 4. GDPR Right to Erasure: Medical records with PHI are subject to GDPR Article 17 (Right to Erasure). Employee can request anonymization of individual medical records; aggregated incident counts remain unchanged (accurate historical safety data).
Encryption Requirements for OHS Data
OHS data requires encryption at rest and in transit, with additional field-level encryption for medical records:
At Rest Encryption
Database Encryption:
- All OHS metric submissions stored in metric_submissions.raw_data JSONB column with database-level encryption (PostgreSQL TDE or encrypted EBS volumes)
- Standard encryption for incident counts and performance metrics (no additional field-level encryption needed for aggregated data)
S3 Evidence Storage: - All OHS evidence files stored with S3 Server-Side Encryption (SSE-AES256) - Standard evidence types (ACCIDENT_REPORT, INCIDENT_REGISTER, HSE_REPORT): SSE-AES256 sufficient - Medical records with PHI (MEDICAL_TREATMENT_RECORD, FATALITY_REPORT, OCCUPATIONAL_DISEASE_REGISTER): Additional field-level encryption using AWS KMS (SSE-KMS with customer-managed keys)
Configuration Example:
@ApplicationScoped
class OHSEvidenceStorageService(
private val s3Client: S3Client,
@ConfigProperty(name = "storage.evidence.bucket")
private val bucketName: String,
@ConfigProperty(name = "storage.evidence.kms.key.arn")
private val kmsKeyArn: String
) {
fun storeOHSEvidence(path: String, content: ByteArray, evidenceType: String) {
val encryptionType = if (evidenceType in listOf(
"MEDICAL_TREATMENT_RECORD",
"FATALITY_REPORT",
"OCCUPATIONAL_DISEASE_REGISTER"
)) {
// Medical records with PHI: Use KMS encryption with customer-managed key
ServerSideEncryption.AWS_KMS
} else {
// Standard incident evidence: Use S3-managed encryption
ServerSideEncryption.AES256
}
val putRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(path)
.serverSideEncryption(encryptionType)
.apply {
if (encryptionType == ServerSideEncryption.AWS_KMS) {
ssekmsKeyId(kmsKeyArn)
}
}
.build()
s3Client.putObject(putRequest, RequestBody.fromBytes(content))
}
}
In Transit Encryption
- TLS 1.2+ required for all OHS evidence uploads and downloads
- Certificate Pinning (Mobile App): Mobile collector app pins TLS certificate to prevent man-in-the-middle attacks during evidence upload
- Signed URLs with Short Expiry: Evidence download URLs expire after 1 hour (reduce window for URL interception)
Access Logging for OHS Data
All access to OHS metrics and evidence is logged to audit_logs table with the following mandatory fields:
| Log Field | Purpose | Example Value |
|---|---|---|
action |
Type of access | "ohs.submission.viewed", "ohs.evidence.downloaded", "ohs.submission.approved" |
entity_type |
OHS entity accessed | "OHS_QUARTERLY_INCIDENT_STATS", "OHS_LTI_PERFORMANCE", "FATALITY_REPORT" |
entity_id |
Submission or evidence UUID | "uuid-submission-123" |
actor_user_id |
User who accessed the data | UUID of user |
ip_address |
Source IP address | "192.168.1.100" |
timestamp |
Access timestamp (UTC) | "2025-03-15T14:30:00Z" |
metadata |
Additional context | {"evidence_type": "FATALITY_REPORT", "justification": "Quarterly safety audit"} |
OHS-Specific Access Actions Logged:
- ohs.submission.created - Collector creates OHS submission
- ohs.submission.viewed - User views OHS submission detail
- ohs.evidence.uploaded - Collector uploads OHS evidence
- ohs.evidence.viewed - User views OHS evidence file (list view)
- ohs.evidence.downloaded - User downloads OHS evidence file (generates signed URL)
- ohs.submission.approved - Approver approves OHS submission
- ohs.submission.rejected - Reviewer rejects OHS submission
- ohs.medical_record.accessed - User views or downloads medical record with PHI (flagged for audit review)
- ohs.fatality_evidence.accessed - User views or downloads fatality report (flagged for audit review)
Audit Justification Requirement:
- Admin and Approver: Must provide audit justification when accessing medical records or fatality evidence (stored in metadata.justification field)
- HSE Manager: Justification recommended but not mandatory (use case: safety analysis)
- Auditor: Justification required for assurance context (e.g., "GRI 403 assurance audit Q1 2025")
Anomaly Detection for OHS Access: - Bulk downloads of OHS evidence (>10 files in 1 hour) trigger alert to Admin - Access to fatality evidence outside business hours triggers alert - Repeated access to same medical record by same user (>3 times in 24 hours) flagged for review
Query Example (Audit Log for OHS Medical Record Access):
SELECT
al.created_at,
u.name AS user_name,
u.role,
al.action,
al.entity_type,
al.metadata->>'evidence_type' AS evidence_type,
al.metadata->>'justification' AS justification,
al.ip_address
FROM audit_logs al
JOIN users u ON al.actor_user_id = u.id
WHERE al.action IN ('ohs.medical_record.accessed', 'ohs.fatality_evidence.accessed')
ORDER BY al.created_at DESC
LIMIT 100;
Data Retention for OHS Data
| Data Type | Retention Period | Legal Hold Support | Deletion Policy |
|---|---|---|---|
| OHS Incident Counts (Aggregated) | 10 years minimum (GRI archival + labor law compliance) | Yes | Can be retained indefinitely (no PII risk); manual deletion only |
| LTI Days, LTIFR Metrics | 10 years minimum | Yes | Can be retained indefinitely (aggregated performance data); manual deletion only |
| Standard OHS Evidence | 7 years minimum (regulatory compliance) | Yes | Manual deletion only after retention period expires and no legal hold |
| Fatality Evidence | 10 years minimum (extended for legal/regulatory compliance) | Yes | Manual deletion only after retention period expires and no legal hold; may be subject to regulatory retention requirements |
| Medical Records with PHI | 7 years minimum; subject to GDPR Right to Erasure | Yes | Anonymize on employee request (GDPR Article 17); keep aggregated counts unchanged |
GDPR Right to Erasure (OHS-Specific): - Aggregated Incident Counts: Not affected by GDPR erasure request (no individual identifiers, accurate historical safety data) - Medical Records with PHI: Employee can request anonymization of individual medical records; platform removes employee name, ID, diagnosis from evidence metadata and file (if stored in database); aggregated incident counts remain unchanged - Fatality Evidence: Subject to legal hold and regulatory notification requirements; GDPR erasure may be restricted by legitimate interest (legal compliance)
Implementation:
@ApplicationScoped
class OHSDataRetentionService(
private val evidenceRepository: EvidenceRepository,
private val auditLogService: AuditLogService
) {
@Transactional
fun anonymizeEmployeeMedicalRecords(employeeId: UUID) {
// Find all medical records linked to employee (if employee ID stored in evidence metadata)
val medicalRecords = evidenceRepository.findByEmployeeId(employeeId)
.filter { it.evidenceType in listOf(
"MEDICAL_TREATMENT_RECORD",
"OCCUPATIONAL_DISEASE_REGISTER"
)}
medicalRecords.forEach { evidence ->
// Anonymize evidence metadata (remove employee identifiers)
evidence.metadata = evidence.metadata?.toMutableMap()?.apply {
put("employee_name", "[Redacted per GDPR Article 17]")
put("employee_id", null)
put("anonymization_date", Instant.now().toString())
}
evidenceRepository.persist(evidence)
auditLogService.log(
action = "ohs.gdpr_erasure.medical_record_anonymized",
entity = evidence,
changes = null,
notes = "Medical record anonymized per GDPR Article 17 (employee ID: ${employeeId})"
)
}
// Note: Aggregated incident counts in metric_submissions remain unchanged
// (e.g., "5 medical treatment incidents in Q1 2025" is accurate historical data)
}
}
OHS Data Export for Regulatory Notification
Fatality Regulatory Notification: - Fatality incidents may require notification to regulatory authorities (e.g., labor inspectorate, mining authority) - Platform supports export of fatality evidence with controlled PHI disclosure: - Internal Use: Full fatality report with victim information (restricted to Approver, Admin, HSE Manager) - Regulatory Notification: Redacted fatality report with only essential details (incident date, time, location, root cause, corrective actions); remove non-essential PHI (next of kin contact, detailed medical information) - Public Disclosure: Aggregated fatality count only (no individual details)
Export Workflow:
1. HSE Manager or Admin exports fatality evidence via Admin API
2. System generates audit log entry with action = "ohs.fatality_evidence.exported" and metadata.export_purpose (Internal/Regulatory/Public)
3. For regulatory export, system prompts user to confirm PHI redaction before download
4. Exported file includes watermark: "CONFIDENTIAL - For Regulatory Notification Only"
Configuration:
@POST
@Path("/api/v1/admin/ohs/evidence/{evidenceId}/export")
@RolesAllowed("HSE_MANAGER", "ADMIN")
fun exportFatalityEvidence(
@PathParam("evidenceId") evidenceId: UUID,
@QueryParam("export_purpose") exportPurpose: String, // Internal, Regulatory, Public
@Context securityIdentity: SecurityIdentity
): Response {
val evidence = evidenceRepository.findByIdOptional(evidenceId)
.orElseThrow { NotFoundException("Evidence not found") }
if (evidence.evidenceType != "FATALITY_REPORT") {
throw BadRequestException("Only fatality reports can be exported via this endpoint")
}
// Require audit justification for regulatory export
if (exportPurpose == "Regulatory") {
// Prompt user to confirm PHI redaction
// Log export action with justification
}
auditLogService.log(
action = "ohs.fatality_evidence.exported",
entity = evidence,
metadata = mapOf(
"export_purpose" to exportPurpose,
"exported_by" to securityIdentity.principal.name
),
notes = "Fatality evidence exported for $exportPurpose purpose"
)
val signedUrl = storageService.generatePresignedUrl(evidence.filepath, Duration.ofHours(1))
return Response.ok(signedUrl).build()
}
Cross-References
- OHS Evidence Management - OHS evidence types, PHI redaction guidance
- OHS Metric Catalog - OHS metric definitions and sensitivity classification
- OHS Collection Templates - OHS data collection templates
- OHS Validation Rules - OHS evidence validation rules
- OHS Report Outputs - F.1 and F.2 OHS report sections
- RBAC Implementation - Role-based access control matrix
Secrets Management
Never Store in Code/Env Files: - Database passwords - API keys - Encryption keys - AWS credentials
Use AWS Secrets Manager or HashiCorp Vault:
// application.properties
quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/esg_db
quarkus.datasource.username=${DB_USERNAME:app_user}
quarkus.datasource.password=${sm://db_password} // AWS Secrets Manager integration via Quarkus Amazon Services
// Or programmatically with SecretsManagerClient
@ApplicationScoped
class DatabaseConfig(
@Inject
private val secretsManager: SecretsManagerClient
) {
@Produces
@ApplicationScoped
fun dataSource(): DataSource {
val dbPassword = secretsManager.getSecretValue(
GetSecretValueRequest.builder()
.secretId("db_password")
.build()
).secretString()
return PGSimpleDataSource().apply {
setURL("jdbc:postgresql://localhost:5432/esg_db")
user = "app_user"
password = dbPassword
}
}
}
// For Quarkus Amazon Services extension:
// Add dependency: io.quarkiverse.amazonservices:quarkus-amazon-secretsmanager
// Configuration in application.properties:
quarkus.secretsmanager.sync-client.type=url
quarkus.secretsmanager.aws.region=us-east-1
quarkus.secretsmanager.aws.credentials.type=default
Access Logging
Requirement: Log all access to PII-containing metrics and evidence files.
@ApplicationScoped
class AuditFilter(
@Inject
private val auditLogRepository: AuditLogRepository,
@Inject
private val securityIdentity: SecurityIdentity
) : ContainerRequestFilter {
override fun filter(requestContext: ContainerRequestContext) {
// Log if accessing PII
if (containsPII(requestContext)) {
val userId = securityIdentity.principal?.name?.toLongOrNull()
auditLogRepository.persist(
AuditLog(
action = "pii.accessed",
entityType = getEndpointName(requestContext),
actorUserId = userId,
ipAddress = getClientIpAddress(requestContext),
metadata = mapOf(
"url" to requestContext.uriInfo.requestUri.toString(),
"method" to requestContext.method
)
)
)
}
}
private fun containsPII(requestContext: ContainerRequestContext): Boolean {
// Check if request path or parameters contain PII-related endpoints
val path = requestContext.uriInfo.path
return path.contains("/metrics/social") ||
path.contains("/users/") ||
requestContext.uriInfo.queryParameters.getFirst("include_pii") == "true"
}
private fun getEndpointName(requestContext: ContainerRequestContext): String {
return requestContext.uriInfo.path
}
private fun getClientIpAddress(requestContext: ContainerRequestContext): String {
// Extract IP from X-Forwarded-For header or remote address
return requestContext.getHeaderString("X-Forwarded-For")?.split(",")?.first()?.trim()
?: requestContext.getHeaderString("X-Real-IP")
?: "unknown"
}
}
// Register the filter with @Provider annotation for JAX-RS auto-discovery
@Provider
@ApplicationScoped
class AuditProvider : AuditFilter()
Threat Model
Top 5 Abuse Cases & Mitigations
| Threat | Attack Vector | Mitigation |
|---|---|---|
| Data Tampering | Attacker modifies approved submissions | Immutable submissions, content hashing, audit logs |
| Fraudulent Evidence | Collector uploads fake invoices | Virus scanning, manual reviewer checks, cross-reference with external data |
| Replay Attacks | Attacker re-submits old data | Idempotency keys (UUID), timestamp validation |
| Insider Approval Abuse | Approver signs off own submissions | Segregation of duties (policy enforcement), audit log alerts |
| Credential Stuffing | Brute force login attempts | Rate limiting (5 attempts/min), CAPTCHA after 3 failures, account lockout |
Additional Threats (vNext)
- SQL Injection: JPA/Hibernate prevents (parameterized queries)
- XSS: Qute templates auto-escape output by default
- CSRF: Quarkus CSRF prevention enabled on all state-changing requests
- Mass Assignment: Use DTOs with explicit field mapping, avoid binding directly to entities
Least Privilege
Database User Permissions
| User | Permissions | Use Case |
|---|---|---|
app_user |
SELECT, INSERT, UPDATE (no DELETE) | Application runtime |
migration_user |
ALL (DDL + DML) | Flyway/Liquibase migrations only |
readonly_user |
SELECT only | Reporting, analytics |
API Service Accounts
- Collector API: Can only create submissions, not approve
- Admin API: Full CRUD, restricted by RBAC policies
Compliance Standards
| Standard | Requirement | Implementation |
|---|---|---|
| GDPR | Right to erasure, portability, consent | PII anonymization, data export API |
| SOC 2 Type II | Access controls, audit logging, encryption | RBAC, append-only audit logs, TLS/encryption at rest |
| ISO 27001 | Information security management | Documented security policies, risk assessments |
Acceptance Criteria
- Database encryption enabled (TDE or encrypted volumes)
- Evidence files stored with S3 SSE encryption
- All HTTPS connections use TLS 1.2+
- PII fields encrypted using JPA AttributeConverter with custom Quarkus encryptor
- GDPR erasure workflow implemented (anonymization)
- All PII access logged to audit trail
- Secrets stored in AWS Secrets Manager (not application.properties)
- SoD enforced: users cannot approve own submissions
- Rate limiting prevents brute force (5 req/min on auth endpoints)
Cross-References
Change Log
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.2 | 2026-01-18 | Ralph Agent | Added OHS Data Confidentiality & Access Control section covering: OHS data sensitivity classification (7 data types with encryption and logging requirements), access control by role for OHS submissions and evidence (6 roles: Collector, Reviewer, Approver, Admin, Auditor, HSE Manager), OHS-specific PII/PHI fields table (incident counts, medical records, fatality reports), encryption requirements (database, S3 with KMS for medical records, TLS in transit), access logging for OHS data (10 OHS-specific audit actions), data retention for OHS data (10 years for incident counts, 7-10 years for evidence, GDPR erasure for medical records), OHS data export for regulatory notification (fatality report export workflow with PHI redaction controls), and cross-references to OHS documentation sections. |
| 1.1 | 2026-01-18 | Ralph Agent | Added HR Data Anonymization & Aggregation Guidance section covering: privacy requirements for human capital metrics, minimum aggregation threshold (5 employees), anonymization techniques (aggregation to higher level, suppression, age groups, no individual identifiers), evidence file anonymization, access controls, data retention, reporting best practices, and compliance summary. Extended Identified PII Fields table with employee demographics, employment level breakdowns, and recruitment/turnover data. |
| 1.0 | 2026-01-03 | Senior Product Architect | Initial security & compliance specification |