Skip to content

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:

  1. Always Use Aggregation Level Appropriately:
  2. Multi-site organisations: Default to organisation-level aggregation unless site-level detail needed
  3. Single-site organisations: Suppress small categories or use broader groupings

  4. Flag Small Categories:

  5. Report generation includes "Data Quality Notes" section
  6. Warnings: "Executive gender breakdown suppressed (n<5)" or "Site A age distribution aggregated to org level for privacy"

  7. Apply Filters Carefully:

  8. Combining multiple filters (e.g., "Female Executives from Local Community at Site A") may create very small groups
  9. Validator warns if filtered result has <5 records

  10. Public vs Internal Reports:

  11. Public Reports (external stakeholders): Apply strictest aggregation (organisation-level, suppress all <5 categories)
  12. Internal Reports (management only): More granular data permitted if justified and access-logged
  13. Assurance Reports (auditors only): Full granularity permitted with NDA and access controls

  14. Derived Metrics (Percentages):

  15. Percentages calculated from aggregated counts are safe (e.g., "40% female executives" is not PII)
  16. Do NOT report percentages for small absolute counts (e.g., "100% of 2 executives" reveals count)
  17. 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


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