Skip to content

Quarkus Security RBAC Implementation Guide

Context: This document provides detailed Quarkus Security implementation patterns for the RBAC model defined in Tenancy & Role Model and the RBAC Matrix (YAML).

Note: This guide has been migrated from Laravel to Quarkus Kotlin. Code examples use Quarkus Security, Kotlin, CDI, and Panache patterns.

Table of Contents

  1. Overview
  2. Backend Architecture
  3. Core Implementation Concepts
  4. Tenant Context Resolution (Canonical)
  5. Fail-Closed Tenant Isolation
  6. Scope Semantics & Safety
  7. Reporting Period State Authority
  8. Break-Glass Access Model
  9. RBAC Matrix as Source of Truth
  10. Audit Logging Policy
  11. Evidence Security Policy
  12. Folder Structure
  13. Database Schema
  14. Code Implementations
  15. Middleware Stack
  16. Policy Patterns
  17. Usage in Controllers
  18. Testing Strategies

1. Overview

This guide demonstrates how to implement the ESG platform's RBAC system in Quarkus with Kotlin using Quarkus Security:

  • Tenant isolation: Hard boundary at tenant_id level
  • Scope constraints: Site/project-level access control for collectors
  • Reporting period state gates: Workflow-based permissions (OPEN → IN_REVIEW → APPROVED → LOCKED)
  • Segregation of duties (SoD): No self-approval enforcement
  • Break-glass actions: Admin overrides with mandatory justification and audit trails

Key Design Principles

  1. Policy-as-code: The RBAC Matrix YAML is the source of truth
  2. Layered security: Middleware → Policies → Gates → Model scopes
  3. Audit everything: Append-only logs for all privileged actions
  4. Fail secure: Default deny; explicit allow

2. Backend Architecture

2.1 Architecture Diagram

Request
Middleware Stack
  ├─ RequireTenantContext         (set tenant from header/session)
  ├─ EnforceTenantBoundary        (validate user tenant membership)
  ├─ EnforceScope                 (check site/project constraints)
  └─ EnforceReportingPeriodState  (validate period state for writes)
Controller
authorize() → Policy
  ├─ TenantContext check
  ├─ Role membership check
  ├─ Scope constraint check
  ├─ State gate check
  └─ SoD / Break-glass checks
Service / Repository
Model (with tenant scope)
Database

2.2 Security Layers

Layer Responsibility Implementation
Middleware Request-level enforcement Tenant context, boundary validation
Policies Action-level authorization Role + scope + state checks
Gates Custom permission logic Complex SoD and break-glass rules
Model Scopes Query-level isolation Automatic tenant_id filtering
Audit Events Traceability Append-only logs on every write

3. Core Implementation Concepts

3.1 Tenant Context (Mandatory)

⚠️ Simplified Summary: See Section 4: Tenant Context Resolution (Canonical) for the authoritative specification.

Every request must establish a tenant context containing: - The active tenant - User's roles in that tenant - Scoped site/project IDs

How it's set: - API: X-Tenant-Id header - Web: Session-based tenant selection - CLI: Command parameter

3.2 Scope Constraints

⚠️ Simplified Example: See Section 6: Scope Semantics & Safety for the authoritative specification.

Roles can be scoped to specific sites/projects:

# Simplified Example: User assignment
user_id: 123
tenant_id: acme-corp
role: collector
scopes:
  site_ids: [site-a, site-b]
  project_ids: []  # empty = all projects

Enforcement: Collectors can only access submissions/evidence within their assigned sites.

3.3 Reporting Period State Gates

⚠️ Simplified Summary: See Section 7: Reporting Period State Authority for the authoritative specification.

OPEN → IN_REVIEW → APPROVED → LOCKED
State Collector Actions Reviewer Actions Approver Actions
OPEN Create, update, submit Read, comment Read
IN_REVIEW Read only Review, return Approve
APPROVED Read only Read only Lock, publish
LOCKED Read only Read only Read only

Reopening: Admin only, with break-glass justification.

3.4 Segregation of Duties (SoD)

Rule: A user cannot approve a submission they created.

// Enforced in SubmissionPolicy::approveItem()
if (submission.createdBy == securityIdentity.principal.name.toLong()) {
    throw ForbiddenException("Self-approval is not permitted.")
}

3.5 Break-Glass Actions

⚠️ Simplified Summary: This section provides a high-level overview. See Section 8: Break-Glass Access Model for the authoritative specification.

High-risk admin actions require: 1. Explicit permission flag (break_glass.enabled) 2. Justification string (min 15 characters) 3. Audit event with SEVERITY=HIGH

Examples: - Delete evidence - Reopen locked period - Override SoD constraints


4. Tenant Context Resolution (Canonical)

🔒 AUTHORITATIVE SECURITY SPECIFICATION This section defines mandatory tenant context resolution behavior. Implementations deviating from this specification introduce security vulnerabilities.

4.1 Fundamental Requirement

Every authenticated request MUST establish and validate tenant context before any business logic executes.

Tenant context consists of: 1. Tenant identity (tenant_id) 2. User's role memberships within that tenant 3. User's scope constraints (site IDs, project IDs) within that tenant 4. Role expiration state for temporal access grants

4.2 Tenant Context Sources

Tenant context is derived from exactly one of the following sources, validated in order:

4.2.1 API Requests

Input: X-Tenant-Id HTTP header

Resolution Process: 1. Extract X-Tenant-Id from request headers 2. Verify tenant exists in tenants table 3. Query tenant_user_roles for authenticated user + tenant combination 4. REJECT if no active role memberships found 5. Load scope constraints from tenant_user_scopes 6. Bind validated TenantContext object to request lifecycle

Mandatory Validations: - X-Tenant-Id header is present → Reject 400 if missing - Tenant record exists → Reject 404 if not found - User has ≥1 active role in tenant → Reject 403 if no membership - Role has not expired (expires_at IS NULL OR expires_at > NOW()) → Reject 403 if expired

Security Rule: X-Tenant-Id is never trusted without cross-referencing tenant_user_roles. A valid smallrye-jwt alone does not grant tenant access.

4.2.2 Web Session Requests

Input: Session-stored tenant_id (set during explicit tenant selection)

Resolution Process: 1. Retrieve tenant_id from authenticated session 2. Verify tenant membership (same validation as API) 3. Bind TenantContext to request

Security Rule: Session-based tenant selection MUST require explicit user action (e.g., tenant switcher UI). Default tenant selection from user profile is permitted only if membership is revalidated on each request.

4.2.3 CLI / Background Jobs

Input: Command-line parameter or job payload

Resolution Process: 1. CLI commands MUST accept --tenant=<uuid> parameter 2. Background jobs MUST include tenant_id in job payload 3. System-level jobs (maintenance, aggregations) requiring cross-tenant access MUST: - Execute with explicit SYSTEM actor identity - Log elevated privilege usage - Be explicitly designed and documented as multi-tenant

Security Rule: Background jobs inherit NO tenant context by default. Absence of tenant_id in job payload MUST cause job failure, not unrestricted access.

4.3 Membership Validation Rules

All tenant context resolution MUST enforce:

Validation SQL Constraint Failure Response
Tenant exists SELECT id FROM tenants WHERE id = ? 404 Tenant Not Found
User-tenant membership exists SELECT COUNT(*) FROM tenant_user_roles WHERE tenant_id = ? AND user_id = ? 403 Not Authorized for Tenant
Role not expired WHERE expires_at IS NULL OR expires_at > NOW() 403 Role Grant Expired
Tenant not soft-deleted WHERE deleted_at IS NULL (if using soft deletes) 404 Tenant Not Found

4.4 Failure Behavior (Mandatory)

Principle: Fail closed. Ambiguity is denial.

Condition Behavior HTTP Status Log Severity
Missing X-Tenant-Id / session tenant Reject request 400 Bad Request MEDIUM
Invalid tenant UUID Reject request 404 Not Found MEDIUM
No active role membership Reject request 403 Forbidden HIGH (potential unauthorized access attempt)
Expired role grant Reject request 403 Forbidden MEDIUM
Database error during validation Reject request 503 Service Unavailable CRITICAL

Never: - Proceed with null tenant context - Default to unrestricted access - Silently ignore validation failures - Allow "guest tenant" or "system tenant" access without explicit design

4.5 Cross-Tenant Access

Default: Cross-tenant data access is prohibited.

Exceptions (require explicit design): 1. Platform admin endpoints: Operate outside tenant context (e.g., /admin/tenants) - Must use separate route group with dedicated middleware - Must not use tenant-scoped models

  1. Data export/migration: System-level operations
  2. Require SYSTEM actor role (not bound to any tenant)
  3. Must log all accessed tenant IDs
  4. Should implement rate limiting

  5. Consolidated reporting (multi-subsidiary groups):

  6. Must validate hierarchical tenant relationships (parent_organisation_id)
  7. Require explicit "group view" permission
  8. Log all child tenant access

Security Rule: Cross-tenant features require security architecture review and explicit documentation. They are not permitted by default.

4.6 Implementation Invariants

Implementations MUST guarantee:

  1. Atomicity: Tenant context is resolved once per request, immutably
  2. Visibility: Tenant context is accessible to all authorization layers (middleware, policies, gates)
  3. Traceability: All audit events include resolved tenant_id and role at time of action
  4. Isolation: Database queries automatically filter by tenant_id via global scopes (see Section 5)

4.7 Testing Requirements

Every implementation MUST include automated tests for:

  • ✅ Missing X-Tenant-Id → 400
  • ✅ Invalid tenant UUID → 404
  • ✅ Valid tenant but no user membership → 403
  • ✅ Expired role grant → 403
  • ✅ Multiple role memberships → all roles loaded
  • ✅ Scoped role grants → scope constraints loaded
  • ✅ Session-based tenant switching → revalidation on each request
  • ✅ CLI job without tenant parameter → job failure

5. Fail-Closed Tenant Isolation

🔒 AUTHORITATIVE SECURITY SPECIFICATION This section defines mandatory query-level isolation behavior. Failure to implement global scopes correctly results in catastrophic data leakage.

5.1 Isolation Principle

All database queries against tenant-scoped models MUST automatically filter by the resolved tenant_id.

Enforcement Method: Hibernate @Filter annotations applied at entity level with session-level filter enablement.

5.2 Scope Application Requirements

Every model representing tenant-owned data MUST:

  1. Implement automatic tenant_id filtering via global scope
  2. Prevent scope bypass except via explicit, documented withoutGlobalScope() usage
  3. Log all scope bypasses at MEDIUM severity minimum

Tenant-Scoped Models (mandatory scope enforcement): - Submission - Evidence - Site - Project - ReportingPeriod - Template (if tenant-customizable) - AuditEvent - Comment - MaterialTopic

5.3 Global Scope Implementation Pattern

Canonical Implementation:

// In each tenant-scoped entity (e.g., Submission, Evidence, Site)
// Use Hibernate @Filter annotation
@Entity
@Table(name = "submissions")
@FilterDef(name = "tenantFilter", parameters = [ParamDef(name = "tenantId", type = String::class)])
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
data class Submission(
    @Id
    @Column(name = "id")
    val id: UUID,

    @Column(name = "tenant_id", nullable = false)
    val tenantId: UUID,
    // ... other fields
)

// Apply filter in TenantContextFilter (JAX-RS ContainerRequestFilter)
@Provider
@ApplicationScoped
class TenantFilterInterceptor : ContainerRequestFilter {

    @Inject
    lateinit var sessionFactory: SessionFactory

    @Inject
    lateinit var tenantContextHolder: TenantContextHolder

    override fun filter(requestContext: ContainerRequestContext) {
        val tenantContext = try {
            tenantContextHolder.getContext()
        } catch (e: Exception) {
            // CRITICAL: Absence of tenant context MUST NOT silently succeed
            throw RuntimeException(
                "Tenant context not initialized. All tenant-scoped queries require valid tenant context."
            )
        }

        val session = sessionFactory.currentSession
        session.enableFilter("tenantFilter")
            .setParameter("tenantId", tenantContext.tenant.id.toString())
    }
}

Security Rules: - Scope MUST throw exception if TenantContext not bound - Never silently return empty result set when context missing - Never default to unscoped query when context missing

5.4 Fail-Closed Behavior

Scenario Behavior Rationale
Query executed without tenant context Throw RuntimeException Prevents accidental cross-tenant data exposure
Tenant context contains invalid tenant_id Throw RuntimeException Fail early rather than return misleading results
Scope bypassed via withoutGlobalScope() Log MEDIUM severity event Audit intentional scope removal
Raw query executed Developer responsibility Document in code review guidelines

5.5 Exceptional Scope Bypass

Permitted Cases (require explicit justification):

  1. System-level aggregations:
    // EXPLICIT: Aggregate across all tenants for platform metrics
    val session = sessionFactory.currentSession
    session.disableFilter("tenantFilter")
    
    val query = session.createQuery(
        "SELECT s.tenantId as tenantId, COUNT(s) as total FROM Submission s GROUP BY s.tenantId",
        Tuple::class.java
    )
    val results = query.resultList
    
  2. Must include comment explaining business justification
  3. Should execute in dedicated admin/system context

  4. Data migration scripts:

  5. Must run outside request lifecycle (Quarkus CLI runners or scheduled tasks)
  6. Must log all accessed tenant IDs

  7. Platform admin panel:

  8. Separate route group without RequireTenantContext middleware
  9. Must implement alternative authorization (e.g., platform admin role)

Security Rule: Every filter disable usage MUST be documented in code with justification and reviewed during security audit.

5.6 Relationship Constraints

Relationships between tenant-scoped models MUST enforce boundary integrity:

Foreign Key Constraints (database level):

-- Example: evidence must belong to same tenant as submission
ALTER TABLE evidence
ADD CONSTRAINT evidence_tenant_submission_fk
CHECK (
    NOT EXISTS (
        SELECT 1 FROM submissions
        WHERE submissions.id = evidence.submission_id
        AND submissions.tenant_id != evidence.tenant_id
    )
);

JPA Relationship Scoping:

// In Submission entity
@Entity
data class Submission(
    @Id val id: UUID,
    val tenantId: UUID,

    @OneToMany(
        mappedBy = "submission",
        cascade = [CascadeType.ALL],
        orphanRemoval = true
    )
    @Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
    val evidence: List<Evidence> = emptyList()
)

5.7 Audit Logging for Isolation Violations

All isolation violations MUST generate audit events:

Violation Type Severity Required Metadata
Query attempted without tenant context HIGH User ID, request path, SQL (sanitized)
Cross-tenant relationship detected CRITICAL Both tenant IDs, resource type, resource ID
Scope bypass in production MEDIUM User ID, model, justification (if provided)

5.8 Testing Requirements

Implementations MUST include automated tests for:

  • ✅ Model query without tenant context → RuntimeException
  • ✅ Query with tenant A context → returns only tenant A records
  • ✅ Query with tenant B context → returns only tenant B records
  • ✅ Relationship loading → respects tenant boundary
  • ✅ Eager loading (with()) → applies scope to relationships
  • ✅ Scope bypass logging → audit event created
  • ✅ Cross-tenant foreign key violations → database constraint error

5.9 System-Level Operations Design Pattern

For legitimate cross-tenant operations, use explicit service pattern:

// src/main/kotlin/services/SystemAggregationService.kt
@ApplicationScoped
class SystemAggregationService {

    @Inject
    lateinit var sessionFactory: SessionFactory

    @Inject
    lateinit var auditEventRepository: AuditEventRepository

    @Inject
    lateinit var securityIdentity: SecurityIdentity

    /**
     * Aggregate metrics across all tenants.
     *
     * SECURITY: This bypasses tenant isolation by design.
     * Authorization: Requires platform admin role.
     * Audit: Logs access to all tenant data.
     */
    @RolesAllowed("PLATFORM_ADMIN")
    fun aggregateMetrics(): List<TenantMetric> {
        // Log elevated access
        val actorId = securityIdentity.principal.name.toLong()
        auditEventRepository.logSystemAccess(
            actorId = actorId,
            action = "system.aggregate_metrics",
            justification = "Platform-wide analytics"
        )

        // Disable tenant filter for system-level aggregation
        val session = sessionFactory.currentSession
        session.disableFilter("tenantFilter")

        val query = session.createQuery(
            "SELECT s.tenantId as tenantId, COUNT(s) as total FROM Submission s GROUP BY s.tenantId",
            Tuple::class.java
        )

        return query.resultList.map { tuple ->
            TenantMetric(
                tenantId = tuple.get(0, UUID::class.java),
                total = tuple.get(1, Long::class.java)
            )
        }
    }
}

Documentation Requirement: All system-level services MUST include security notice in KDoc explaining scope bypass rationale and authorization requirements.


6. Scope Semantics & Safety

🔒 AUTHORITATIVE SECURITY SPECIFICATION This section defines role scope constraints and their enforcement. Misunderstanding scope semantics leads to unauthorized data access.

6.1 Scope Definition

Scope is an additional access constraint applied to role grants, limiting which sites or projects a user can access within a tenant.

Purpose: Implement least-privilege and segregation of duties at sub-tenant level.

6.2 Scope Types

Scope Type Storage Semantics
Site Scope tenant_user_scopes.site_id User can only access resources where resource.site_id IN (allowed_site_ids)
Project Scope tenant_user_scopes.project_id User can only access resources where resource.project_id IN (allowed_project_ids)
Unscoped No records in tenant_user_scopes for user+tenant User can access all sites/projects within tenant

6.3 Empty Scope Semantics

Critical Rule: Empty scope list means unrestricted access within tenant, not denial.

Scenario tenant_user_scopes Records Access Behavior
Site-scoped collector site_id IN ('site-a', 'site-b') Can access only site-a and site-b resources
Unscoped reviewer No records Can access all sites within tenant
Mixed scope site_id='site-a' AND project_id='proj-x' Can access site-a resources AND proj-x resources (union, not intersection)

Implementation Rule:

// In TenantContext
data class TenantContext(
    val tenant: Tenant,
    val roles: List<String>,
    val siteIds: List<UUID>,
    val projectIds: List<UUID>
) {
    fun canAccessSite(siteId: UUID?): Boolean {
        // Null site_id = tenant-level resource, always accessible
        if (siteId == null) {
            return true
        }

        // Empty scope list = unscoped = access all sites
        if (siteIds.isEmpty()) {
            return true
        }

        // Scoped: check membership
        return siteId in siteIds
    }
}

6.4 Roles and Default Scope Behavior

Role Default Scope Rationale
Collector Site-scoped (RECOMMENDED) Data minimization; collectors typically work at specific facilities
Reviewer Unscoped Reviewers need visibility across tenant for validation
Approver Unscoped Approvers need full tenant visibility for sign-off
Admin Unscoped (MANDATORY) Admins manage entire tenant
Auditor Unscoped or explicitly scoped Depends on audit engagement scope

Security Principle: Assign minimum necessary scope. Default to scoped for operational roles (collector), unscoped for governance roles (reviewer, approver).

6.5 Scope Enforcement Points

Scope constraints MUST be checked in:

  1. Policy authorization:

    // In SubmissionPolicy::canView()
    @ApplicationScoped
    class SubmissionPermissionEvaluator : PermissionEvaluator {
        override fun hasPermission(
            authentication: Authentication,
            targetDomainObject: Any,
            permission: Any
        ): Boolean {
            val submission = targetDomainObject as Submission
            val ctx = tenantContextHolder.getContext()
    
            if (ctx.hasRole("collector")) {
                return ctx.canAccessSite(submission.siteId)
            }
            return true
        }
    }
    

  2. Query scopes (for list operations):

    // In SubmissionController::index()
    fun findSubmissions(): List<Submission> {
        val ctx = tenantContextHolder.getContext()
        var query = submissionRepository.findAll() // Already filtered by tenant via @Filter
    
        if (ctx.hasRole("collector") && ctx.siteIds.isNotEmpty()) {
            return submissionRepository.findBySiteIdIn(ctx.siteIds)
        }
        return query
    }
    

  3. Eager loading (prevent data leakage via relationships):

    // When loading submissions with related evidence
    @EntityGraph(attributePaths = ["evidence"])
    @Query("SELECT s FROM Submission s WHERE (:siteIds IS NULL OR s.siteId IN :siteIds)")
    fun findSubmissionsWithEvidence(
        @Param("siteIds") siteIds: List<UUID>?
    ): List<Submission>
    
    // Usage
    val ctx = tenantContextHolder.getContext()
    val siteIds = if (ctx.hasRole("collector") && ctx.siteIds.isNotEmpty()) ctx.siteIds else null
    val submissions = submissionRepository.findSubmissionsWithEvidence(siteIds)
    

6.6 Database Constraints for Scope Integrity

Invariants (enforced at database level):

  1. Scope cannot reference non-existent resources:

    ALTER TABLE tenant_user_scopes
    ADD CONSTRAINT scope_site_exists_fk
    FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE;
    

  2. Scope references must belong to same tenant:

    ALTER TABLE tenant_user_scopes
    ADD CONSTRAINT scope_tenant_match_ck
    CHECK (
        site_id IS NULL OR EXISTS (
            SELECT 1 FROM sites WHERE sites.id = site_id AND sites.tenant_id = tenant_user_scopes.tenant_id
        )
    );
    

6.7 Scope Assignment Rules

Who can assign scopes: - Only admin role can create/modify tenant_user_scopes records - Admins cannot reduce their own scope (prevents self-lockout)

Validation requirements:

// When creating scope assignment
@ApplicationScoped
class ScopeAssignmentService(
    private val siteRepository: SiteRepository,
    private val tenantUserScopeRepository: TenantUserScopeRepository,
    private val auditEventRepository: AuditEventRepository
) {
    @RolesAllowed("hasPermission(#tenant, 'manage-scopes')")
    fun assignScope(user: User, tenant: Tenant, siteIds: List<UUID>) {
        // 1. Verify admin is performing assignment (handled by @RolesAllowed)

        // 2. Verify all site IDs belong to tenant
        val validSites = siteRepository.findByTenantIdAndIdIn(tenant.id, siteIds)

        if (validSites.size != siteIds.size) {
            throw ValidationException("Invalid site IDs for tenant")
        }

        // 3. Create scope records
        siteIds.forEach { siteId ->
            tenantUserScopeRepository.persist(
                TenantUserScope(
                    id = UUID.randomUUID(),
                    tenantId = tenant.id,
                    userId = user.id,
                    siteId = siteId
                )
            )
        }

        // 4. Audit scope assignment
        val actorId = securityIdentity.principal.name.toLong()
        auditEventRepository.persist(
            AuditEvent(
                tenantId = tenant.id,
                actorId = actorId,
                action = "scope.assigned",
                objectType = "User",
                objectId = user.id.toString(),
                severity = Severity.MEDIUM,
                after = mapOf("site_ids" to siteIds)
            )
        )
    }
}

6.8 Scope Changes and Audit

All scope modifications MUST be audited:

Event Severity Required Metadata
Scope assigned to user MEDIUM User ID, tenant ID, site/project IDs added
Scope revoked from user MEDIUM User ID, tenant ID, site/project IDs removed
User granted unscoped access HIGH User ID, tenant ID, previous scope
Admin modifies own scope HIGH (discouraged) Previous and new scope

6.9 Testing Requirements

Implementations MUST include automated tests for:

  • ✅ Scoped collector can view own-site submission → allowed
  • ✅ Scoped collector cannot view other-site submission → denied
  • ✅ Unscoped reviewer can view all submissions → allowed
  • ✅ Empty scope array interpreted as unscoped → allowed
  • ✅ Scope assignment validates site belongs to tenant → validation error if mismatch
  • ✅ Scope enforcement in list queries → only scoped records returned
  • ✅ Scope enforcement in eager loading → no data leakage via relationships

7. Reporting Period State Authority

🔒 AUTHORITATIVE SECURITY SPECIFICATION This section defines the authoritative source for reporting period state and its enforcement. Client-supplied state is never trusted for authorization decisions.

7.1 State Authority Principle

Reporting period state is ALWAYS determined server-side from the database. Client-supplied state values in requests are never used for authorization decisions.

Prohibited Pattern:

// ❌ INSECURE: Never authorize based on request input
val periodState = request.getParameter("period_state")
if (periodState == "OPEN") {
    // Allow edit
}

Required Pattern:

// ✅ SECURE: Always load state from database
val period = reportingPeriodRepository.findById(submission.reportingPeriodId)
    .orElseThrow { EntityNotFoundException("ReportingPeriod not found") }
if (period.state.allowsDataEntry()) {
    // Allow edit
}

7.2 Period State Lifecycle

State Machine (unidirectional except for admin break-glass):

OPEN → IN_REVIEW → APPROVED → LOCKED
  ↑         ↑          ↑
  └─────────┴──────────┴──── Admin break-glass only (with justification)
State Mutability Allowed Actors State Transition
OPEN Data entry allowed Collector (create, update, submit) → IN_REVIEW (collector submit)
IN_REVIEW Read-only for collectors Reviewer (review, return) → APPROVED (approver approve) OR → OPEN (reviewer return)
APPROVED Immutable Approver (lock) → LOCKED (approver/admin lock)
LOCKED Immutable All (read-only) → OPEN (admin break-glass only)

7.3 State vs. Submission Status

Critical Distinction: Reporting period state and individual submission status are separate concepts.

Concept Scope Purpose Authority
Reporting Period State Tenant-wide window Controls when data collection/review phases occur Server-side, from reporting_periods.state
Submission Status Individual submission Tracks workflow state of single data entry Server-side, from submissions.status

Enforcement Rule: Both MUST be validated independently.

// Example: Updating a submission
@ApplicationScoped
class SubmissionPolicy {
    fun canUpdate(user: User, submission: Submission): Boolean {
        // 1. Check reporting period state (governance layer)
        if (!submission.reportingPeriod.state.allowsDataEntry()) {
            return false  // Period closed for edits
        }

        // 2. Check submission status (workflow layer)
        if (submission.status != SubmissionStatus.DRAFT) {
            return false  // Submission already submitted
        }

        // 3. Check user authorization
        return submission.createdBy == user.id
    }
}

7.4 Server-Side State Loading Requirements

All policies MUST load period state via JPA relationship:

// ✅ Correct: Eager load when needed
@EntityGraph(attributePaths = ["reportingPeriod"])
interface SubmissionRepository : PanacheRepository<Submission> {
    fun findAllByTenantId(tenantId: UUID): List<Submission>
}

// Usage
val submissions = submissionRepository.findAllByTenantId(tenantId)

submissions.forEach { submission ->
    if (submission.reportingPeriod.state == ReportingPeriodState.OPEN) {
        // ...
    }
}

Performance Consideration: Use @EntityGraph annotation to avoid N+1 queries when checking state for multiple records.

7.5 State Transition Authorization

State transitions MUST enforce role-based controls:

Transition Required Role Additional Constraints Audit Severity
OPEN → IN_REVIEW Reviewer or Admin All submissions reviewed MEDIUM
IN_REVIEW → APPROVED Approver No pending findings HIGH
APPROVED → LOCKED Approver or Admin Final approval documented HIGH
LOCKED → OPEN Admin only Break-glass justification required CRITICAL
IN_REVIEW → OPEN Reviewer or Admin Return-for-corrections reason required MEDIUM

Implementation Example:

// In ReportingPeriodPolicy
@ApplicationScoped
class ReportingPeriodPolicy(
    private val submissionRepository: SubmissionRepository
) {
    fun canApprove(user: User, period: ReportingPeriod): Boolean {
        val ctx = tenantContextHolder.getContext()

        // 1. Verify current state allows approval
        if (period.state != ReportingPeriodState.IN_REVIEW) {
            throw ForbiddenException("Period must be in review status to approve.")
        }

        // 2. Verify user has approver role
        if (!ctx.hasRole("approver")) {
            throw ForbiddenException("Only approvers can approve periods.")
        }

        // 3. Verify all submissions are reviewed
        val unreviewed = submissionRepository.existsByReportingPeriodIdAndStatusNot(
            period.id,
            SubmissionStatus.REVIEWED
        )
        if (unreviewed) {
            throw ForbiddenException("Cannot approve period with unreviewed submissions.")
        }

        return true
    }
}

7.6 State and Data Entry Windows

Enforcement Matrix:

Period State Create Submission Update Submission Delete Draft Upload Evidence Submit for Review
OPEN ✅ Collector ✅ Owner ✅ Owner ✅ Collector ✅ Owner
IN_REVIEW
APPROVED
LOCKED

Exception: Admin break-glass can override state restrictions (see Section 8).

7.7 Audit Requirements for State Changes

All period state transitions MUST generate audit events:

// Example: Period state transition audit
@ApplicationScoped
class ReportingPeriodService(
    private val auditEventRepository: AuditEventRepository
) {
    fun transitionState(period: ReportingPeriod, newState: ReportingPeriodState, justification: String?) {
        val previousState = period.state
        period.state = newState

        val actorId = securityIdentity.principal.name.toLong()

        auditEventRepository.persist(
            AuditEvent(
                tenantId = period.tenantId,
                actorId = actorId,
                action = "reporting_period.state_transition",
                objectType = "ReportingPeriod",
                objectId = period.id.toString(),
                severity = Severity.HIGH,
                before = mapOf("state" to previousState.name),
                after = mapOf("state" to newState.name),
                justification = justification  // Required for break-glass
            )
        )
    }
}

7.8 Client-Side State Display

Permitted: Clients MAY receive period state for display purposes.

// API response
{
    "reporting_period": {
        "id": "uuid",
        "name": "FY2024",
        "state": "OPEN",  // ✅ Safe to return for UI rendering
        "can_edit": true  // ✅ Server-computed capability
    }
}

Prohibited: Clients MUST NOT send state values to influence authorization.

// ❌ Request body (ignored by server)
{
    "submission": {
        "data": {...},
        "period_state": "OPEN"  // Server ignores this value
    }
}

7.9 Testing Requirements

Implementations MUST include automated tests for:

  • ✅ Authorization loads period state from database, not request
  • ✅ OPEN period allows data entry → allowed
  • ✅ IN_REVIEW period blocks new submissions → denied
  • ✅ APPROVED period blocks all edits → denied
  • ✅ LOCKED period blocks all edits → denied
  • ✅ State transition validates current state → invalid transitions denied
  • ✅ State transition validates role permissions → unauthorized actors denied
  • ✅ Break-glass period reopening requires justification → validation error if missing
  • ✅ State transition generates audit event → audit record created

8. Break-Glass Access Model

🔒 AUTHORITATIVE SECURITY SPECIFICATION This section defines break-glass access controls for emergency administrative overrides. Break-glass actions are explicit, audited, and exceptional.

8.1 Break-Glass Definition

Break-glass access is a controlled mechanism allowing administrators to perform high-risk actions that: 1. Override normal workflow state restrictions 2. Modify locked or immutable data 3. Delete audit-critical records 4. Bypass segregation of duties constraints

Design Principle: Break-glass is not a privilege escalation mechanism. It is a formally controlled exception requiring human justification and elevated audit logging.

8.2 Break-Glass vs. Admin Role

Critical Distinction:

Concept Meaning Authorization
Admin Role Standard tenant administration Configure sites, users, roles, templates
Break-Glass Permission Emergency override capability Delete evidence, reopen locked periods, override SoD

Security Rule: Possessing the admin role does NOT automatically grant break-glass permissions. Break-glass actions require: 1. Admin role AND 2. Explicit break-glass permission flag (database or policy check) AND 3. Human justification string AND 4. Elevated audit logging

Implementation Pattern:

// Admin role grants standard administration
val ctx = tenantContextHolder.getContext()
if (ctx.hasRole("admin")) {
    // Can manage sites, users, templates
}

// Break-glass requires additional validation
@ApplicationScoped
class EvidencePolicy(
    private val breakGlassHelper: BreakGlassHelper
) {
    fun canDelete(user: User, evidence: Evidence, justification: String?): Boolean {
        val ctx = tenantContextHolder.getContext()

        // 1. Require admin role
        if (!ctx.hasRole("admin")) {
            throw ForbiddenException("Only admins can delete evidence.")
        }

        // 2. Require break-glass justification
        try {
            breakGlassHelper.requireJustification(justification)
        } catch (e: ForbiddenException) {
            throw ForbiddenException(e.message)
        }

        // 3. Log break-glass action (before execution)
        breakGlassHelper.logBreakGlassAction(
            tenantId = evidence.tenantId,
            actorId = user.id,
            action = "evidence.delete",
            objectType = "Evidence",
            objectId = evidence.id,
            justification = justification!!
        )

        return true
    }
}

8.3 Actions Requiring Break-Glass

The following actions MUST enforce break-glass controls:

Action Rationale Minimum Justification Length
Delete evidence Evidence is audit-critical; deletion violates immutability 15 characters
Reopen LOCKED reporting period Locked periods represent finalized governance records 15 characters
Reopen APPROVED reporting period Approved periods are signed off by approvers 15 characters
Override segregation of duties (approve own submission) SoD is core control for accountability 20 characters
Delete audit log entries Audit logs are compliance-critical PROHIBITED
Modify evidence files Evidence integrity must be preserved PROHIBITED
Bulk data deletion High risk of accidental data loss 30 characters

Prohibited Actions: Some actions MUST NOT be permitted even with break-glass: - Deleting audit log entries (append-only requirement) - Modifying uploaded evidence files (integrity requirement) - Changing historical created_by or submitted_at timestamps

8.4 Justification Requirements

Validation Rules:

Requirement Enforcement Rationale
Minimum length 15 characters (configurable per action type) Prevent lazy justifications like "fix" or "mistake"
Non-empty Trim whitespace; reject empty strings Enforce meaningful explanation
Human-readable Free text (not machine-generated codes) Facilitate audit review
Stored immutably Persist in audit_events.justification Provide audit trail

Implementation:

// In BreakGlassHelper
@ApplicationScoped
class BreakGlassHelper {
    companion object {
        private const val MIN_JUSTIFICATION_LENGTH = 15
    }

    fun requireJustification(
        justification: String?,
        minLength: Int = MIN_JUSTIFICATION_LENGTH
    ) {
        val j = justification?.trim() ?: ""

        if (j.length < minLength) {
            throw ForbiddenException(
                "Break-glass action requires meaningful justification (minimum $minLength characters). " +
                "Example: \"Correcting data entry error in Q3 water metrics per CFO approval.\""
            )
        }
    }
}

Example Justifications (acceptable): - ✅ "Correcting supplier data error identified in external audit, authorized by CFO email 2024-11-15." - ✅ "Removing duplicate evidence file uploaded in error during Q2 data collection." - ✅ "Reopening period to incorporate late-received regulatory clarification on Scope 3 boundaries."

Example Justifications (rejected): - ❌ "Fix" (too short, not meaningful) - ❌ "Error" (too short, lacks context) - ❌ "Per manager" (lacks specificity and approval trail)

8.5 Audit Logging for Break-Glass

All break-glass actions MUST generate HIGH or CRITICAL severity audit events.

Mandatory Audit Fields:

Field Purpose Example
tenant_id Scope action to tenant "uuid"
actor_id Identify who performed action user.id
action Specific operation "evidence.delete", "period.reopen"
object_type Resource type "Evidence", "ReportingPeriod"
object_id Resource identifier "uuid"
severity Event criticality "HIGH" or "CRITICAL"
justification Human explanation "Correcting data entry error..."
before State before action (if applicable) {"state": "LOCKED"}
after State after action (if applicable) {"state": "OPEN"}
ip_address Source IP for forensics "192.0.2.1"
user_agent Client identifier "Mozilla/5.0..."

Audit Severity Mapping:

Action Severity Rationale
Delete evidence HIGH Compromises audit trail
Reopen LOCKED period CRITICAL Violates governance finalization
Reopen APPROVED period HIGH Undermines approval workflow
Override SoD HIGH Bypasses accountability control
Bulk operations CRITICAL High risk of unintended consequences

8.6 Break-Glass Permission Model

Recommended Implementations:

Option 1: Database Flag (flexible, auditable)

// Add column to tenant_user_roles table using Flyway/Liquibase migration
// V1__add_break_glass_flag.sql
ALTER TABLE tenant_user_roles
ADD COLUMN break_glass_enabled BOOLEAN DEFAULT FALSE;

// Check in policy
@ApplicationScoped
interface TenantUserRoleRepository : PanacheRepository<TenantUserRole> {
    fun existsByTenantIdAndUserIdAndRoleAndBreakGlassEnabled(
        tenantId: UUID,
        userId: Long,
        role: String,
        breakGlassEnabled: Boolean
    ): Boolean
}

// Usage
val hasBreakGlass = tenantUserRoleRepository.existsByTenantIdAndUserIdAndRoleAndBreakGlassEnabled(
    tenantId, userId, "admin", true
)
if (!hasBreakGlass) {
    throw ForbiddenException("Break-glass permission not granted.")
}

Option 2: Separate Permission Table (granular control)

// Separate break_glass_permissions table using Flyway migration
// V2__create_break_glass_permissions.sql
CREATE TABLE break_glass_permissions (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    user_id BIGINT NOT NULL REFERENCES users(id),
    action VARCHAR(255) NOT NULL,  -- 'evidence.delete', 'period.reopen', etc.
    granted_at TIMESTAMP NOT NULL,
    expires_at TIMESTAMP,
    granted_by BIGINT NOT NULL REFERENCES users(id),
    grant_justification TEXT NOT NULL
);

// JPA Entity
@Entity
@Table(name = "break_glass_permissions")
data class BreakGlassPermission(
    @Id val id: UUID,
    val tenantId: UUID,
    val userId: Long,
    val action: String,
    val grantedAt: Instant,
    val expiresAt: Instant?,
    val grantedBy: Long,
    val grantJustification: String
)

Option 3: Quarkus Security Expression (policy-driven)

// Custom security expression
@ApplicationScoped("breakGlassChecker")
class BreakGlassChecker(
    private val breakGlassPermissionRepository: BreakGlassPermissionRepository
) {
    fun hasBreakGlassPermission(user: User, action: String): Boolean {
        val ctx = tenantContextHolder.getContext()

        // Only admins can use break-glass
        if (!ctx.hasRole("admin")) {
            return false
        }

        // Check specific action permission
        return breakGlassPermissionRepository.existsByTenantIdAndUserIdAndActionAndExpiresAtAfter(
            ctx.tenant.id,
            user.id,
            action,
            Instant.now()
        )
    }
}

// Usage in controller
@RolesAllowed("@breakGlassChecker.hasBreakGlassPermission(principal, 'evidence.delete')")
fun deleteEvidence(@PathParam id: UUID) { }

8.7 Workflow for Break-Glass Actions

Recommended Process:

  1. User initiates action with justification

    DELETE /api/evidence/{id}
    Content-Type: application/json
    
    {
        "justification": "Removing duplicate file uploaded in error..."
    }
    

  2. Server validates authorization

  3. Check admin role
  4. Validate justification length and content
  5. Check break-glass permission (if using database flag)

  6. Server logs BEFORE execution

    // Log before performing action (in case action fails)
    BreakGlass.logBreakGlassAction(...)
    

  7. Server performs action

    evidenceRepository.delete(evidence)
    

  8. Server logs completion (optional second audit entry)

    auditEventRepository.persist(AuditEvent(
        action = "evidence.delete.completed",
        severity = "HIGH",
        // ...
    ))
    

  9. Server returns confirmation

    return Response.ok().entity(mapOf(
        "message" to "Evidence deleted. Break-glass action logged.",
        "audit_id" to auditEvent.id
    ))
    

8.8 Alerting and Monitoring

Break-glass actions SHOULD trigger real-time alerts:

Alert Channels: - Security team notification (email, Slack) - Tenant admin dashboard notification - Platform monitoring system (e.g., Sentry, Datadog)

Alert Conditions: - Any CRITICAL severity audit event - Any HIGH severity audit event in production environment - Multiple break-glass actions by same user within 1 hour (potential abuse) - Break-glass action outside business hours (potential unauthorized access)

Example Monitoring Query:

-- Detect unusual break-glass activity
SELECT actor_id, COUNT(*) as break_glass_count
FROM audit_events
WHERE severity IN ('HIGH', 'CRITICAL')
  AND action LIKE '%.break_glass%'
  AND created_at > NOW() - INTERVAL '1 hour'
GROUP BY actor_id
HAVING COUNT(*) > 3;  -- Alert if >3 break-glass actions in 1 hour

8.9 Testing Requirements

Implementations MUST include automated tests for:

  • ✅ Break-glass action without justification → denied with clear error message
  • ✅ Break-glass action with short justification → denied
  • ✅ Break-glass action without admin role → denied
  • ✅ Valid break-glass action → allowed and logged with HIGH/CRITICAL severity
  • ✅ Break-glass audit event includes all mandatory fields
  • ✅ Break-glass action with expired permission → denied (if using time-bounded permissions)
  • ✅ Prohibited actions (e.g., delete audit logs) → denied even with break-glass
  • ✅ Break-glass log entry created BEFORE destructive action executes

9. RBAC Matrix as Source of Truth

🔒 AUTHORITATIVE SECURITY SPECIFICATION This section defines the role of the RBAC YAML matrix as the authoritative permission specification and its operational governance.

9.1 RBAC Matrix Role

The RBAC Matrix (YAML) is the authoritative, machine-readable specification of all role-based permissions in the ESG platform.

Status: Policy-as-code. The YAML file defines what MUST be implemented in Quarkus Security authorization components.

Purpose: 1. Single source of truth for permission grants across all roles 2. Machine-readable format enabling automated policy generation (future) 3. Reviewable artifact for security audits and ISO 27001 assessments 4. Version-controlled permission history

9.2 YAML Structure

The RBAC matrix defines:

version: 1  # Schema version for backwards compatibility

model:
  tenancy:
    boundary: tenant_id              # Hard isolation boundary
    scopes: [site_id, project_id]   # Sub-tenant scope constraints

  reporting_period_states:          # Workflow state machine
    - OPEN
    - IN_REVIEW
    - APPROVED
    - LOCKED

roles:
  collector: { description: "..." }
  reviewer: { description: "..." }
  approver: { description: "..." }
  admin: { description: "..." }
  auditor: { description: "..." }

segregation_of_duties:
  deny_self_approval: true
  deny_self_review: false
  discourage_admin_as_approver: true
  role_conflicts:
    - [collector, approver]

resources:
  submission:
    actions:
      create:
        allow: [collector, admin]
        period_state_allow: [OPEN]
        constraints:
          - owner.or_scoped_assignment
      approve_item:
        allow: [approver]
        period_state_allow: [IN_REVIEW]
        constraints:
          - sod.no_self_approval

9.3 Version Control Requirements

The RBAC YAML MUST be:

  1. Version controlled in the same repository as implementation code
  2. Location: /docs/security/rbac-matrix.v1.yml
  3. Never committed without review

  4. Reviewed before merge

  5. All changes require security-aware reviewer approval
  6. Breaking changes require architecture review

  7. Tagged with semantic versioning

  8. Major version: Breaking permission changes (e.g., removing permissions)
  9. Minor version: Additive changes (e.g., new roles, new actions)
  10. Patch version: Documentation clarifications

  11. Change-logged

  12. Document rationale for permission changes in commit messages
  13. Link to security review ticket or approval email

Example commit message:

chore(rbac): Add evidence.download permission to auditor role

Rationale: External auditors require evidence download for ISO 14001 assurance.
Security review: Approved by CISO via email 2024-11-20.
Risk: LOW (read-only permission, already have evidence.read)

RBAC Matrix: v1.2.0 → v1.3.0

9.4 Synchronization with Quarkus Security Authorization

Critical Requirement: Quarkus Security authorization components MUST faithfully implement the permissions defined in the RBAC YAML.

Policy Drift is a Defect: Any discrepancy between YAML specification and policy implementation is a security bug.

Verification Methods:

  1. Manual Code Review (minimum requirement)
  2. Reviewer compares policy logic to YAML permissions during PR review
  3. Use checklist: "Does this policy match rbac-matrix.v1.yml?"

  4. Automated Testing (recommended)

    // Example: Policy test derived from YAML
    @Test
    fun `test submission create permissions match YAML`() {
        // Parse YAML
        val yaml = YamlReader().readValue(
            File("docs/security/rbac-matrix.v1.yml"),
            Map::class.java
        )
        val allowedRoles = ((yaml["resources"] as Map<*, *>)["submission"] as Map<*, *>)
            .let { (it["actions"] as Map<*, *>)["create"] as Map<*, *> }
            .let { it["allow"] as List<*> }
    
        // Test each role
        listOf("collector", "reviewer", "approver", "admin", "auditor").forEach { role ->
            val user = createUserWithRole(role)
            val canCreate = submissionPolicy.canCreate(user)
    
            val shouldAllow = role in allowedRoles
            assertEquals(
                shouldAllow,
                canCreate,
                "Role '$role' permission mismatch with YAML for submission.create"
            )
        }
    }
    

  5. Policy Linter (future enhancement)

  6. Static analysis tool to compare policy code against YAML
  7. CI/CD pipeline check to prevent drift

9.5 YAML as Audit Evidence

The RBAC YAML serves as primary evidence for:

ISO 27001 A.9.4.1 (Information Access Restriction): - Demonstrates documented access control policy - Shows segregation of duties enforcement - Provides version history of permission changes

SOC 2 CC6.3 (Logical Access Controls): - Evidences role-based access model - Shows least-privilege assignment - Documents break-glass controls

GRC Compliance: - Machine-readable format supports automated compliance checks - Version history provides audit trail of authorization changes

Usage in Audit:

# Generate permission report for auditor
$ grep -A 5 "evidence:" docs/security/rbac-matrix.v1.yml
evidence:
  actions:
    read:
      allow: [collector, reviewer, approver, admin, auditor]
      scope_required_for: [collector]
    upload:
      allow: [collector, admin]

9.6 Change Management Process

Workflow for RBAC Changes:

  1. Proposal: Document permission change request
  2. Business justification
  3. Security impact assessment
  4. Affected roles and resources

  5. Security Review: Evaluate risk

  6. Principle of least privilege maintained?
  7. Segregation of duties preserved?
  8. Compliance implications?

  9. YAML Update: Modify rbac-matrix.v1.yml

  10. Update version number
  11. Add changelog entry
  12. Commit with detailed message

  13. Authorization Implementation: Update Quarkus Security authorization

  14. Implement YAML changes in relevant authorization components
  15. Write/update automated tests
  16. Verify no unintended permission grants

  17. Testing: Validate implementation

  18. Policy tests pass
  19. Integration tests pass
  20. Manual security testing (negative cases)

  21. Documentation: Update related docs

  22. Update this implementation guide if patterns change
  23. Update role descriptions if needed
  24. Notify stakeholders of permission changes

  25. Deployment: Release with approval

  26. Staging environment validation
  27. Production deployment
  28. Monitor audit logs for unexpected behavior

9.7 Extending the RBAC Matrix

Adding New Roles:

roles:
  external_auditor:  # New role
    description: Third-party assurance provider with time-limited access.
- Document use case and access duration - Define permissions conservatively (start minimal, expand as needed) - Consider scope constraints

Adding New Resources:

resources:
  carbon_credit:  # New resource type
    actions:
      read: { allow: [reviewer, approver, admin, auditor] }
      create: { allow: [admin] }
      retire: { allow: [approver, admin] }
- Follow existing action patterns (read, create, update, delete) - Consider state dependencies (period_state_allow) - Document business logic constraints

Adding New Constraints:

constraints:
  - sod.no_self_approval      # Existing
  - owner.or_scoped_assignment  # Existing
  - approval.requires_two_party  # New constraint
- Implement in policy enforcement logic - Add constraint validation tests - Document constraint semantics in code comments

9.8 RBAC Matrix Schema Validation

Recommended: Validate YAML schema in CI/CD pipeline.

# Example: JSON Schema validation (convert YAML to JSON first)
$ yamllint docs/security/rbac-matrix.v1.yml
$ yq eval -o=json docs/security/rbac-matrix.v1.yml | \
  ajv validate -s rbac-schema.json -d -

Schema Requirements: - version field present and integer - All resources use defined actions - All allow arrays reference defined roles - period_state_allow values match defined states - No duplicate resource/action definitions


10. Audit Logging Policy

📋 OPERATIONAL SPECIFICATION This section defines audit logging requirements for compliance, security monitoring, and forensic investigation.

10.1 Audit Logging Principle

All privileged actions, state changes, and data access MUST be logged to an append-only audit trail.

Purpose: 1. Compliance: ISO 27001, SOC 2, GDPR Article 30 (records of processing) 2. Security: Detect unauthorized access and suspicious activity 3. Forensics: Investigate incidents and data discrepancies 4. Accountability: Attribute actions to specific users and timestamps

10.2 Events Requiring Audit Logging

Mandatory Audit Events:

Event Category Examples Minimum Severity
Authentication Login, logout, failed login, MFA bypass LOW
Authorization Permission denied, role assignment, scope change MEDIUM
Data Modification Submission create/update, evidence upload/delete MEDIUM
Workflow Transitions Submission approval, period state change HIGH
Administrative User invite, role grant, tenant config change MEDIUM
Break-Glass Evidence deletion, period reopening, SoD override HIGH or CRITICAL
Export Data export, evidence download, audit pack generation MEDIUM
Compliance Report generation, external publish, certification HIGH

10.3 Audit Event Schema

Mandatory Fields (every audit event):

Field Type Purpose Example
id UUID Unique event identifier "550e8400-..."
tenant_id UUID Tenant scope (null for platform events) "acme-corp"
actor_id Integer/UUID User who performed action user.id
action String Dot-notation event type "submission.approved"
object_type String Resource type "Submission"
object_id UUID Resource identifier "uuid"
severity Enum Event criticality "LOW", "MEDIUM", "HIGH", "CRITICAL"
ip_address IP Source IP (for forensics) "192.0.2.1"
user_agent String Client identifier "Mozilla/5.0..."
created_at Timestamp Event time (immutable) "2024-11-20T14:35:00Z"

Conditional Fields:

Field Required When Purpose
before State change events Record previous state (JSON)
after State change events Record new state (JSON)
justification Break-glass events Human explanation for override
metadata Context-specific Additional event details (JSON)

10.4 Action Naming Convention

Format: <resource>.<action>

Examples: - submission.created - submission.updated - submission.approved - evidence.uploaded - evidence.deleted (break-glass) - reporting_period.state_transition - user.role_assigned - export.audit_pack_generated

Consistency: Use past tense for completed actions.

10.5 Severity Classification

Severity Definition Examples Alert Threshold
LOW Routine operations Data read, submission created No alert
MEDIUM Significant operations Submission approved, evidence uploaded Daily summary
HIGH Governance actions Period locked, break-glass used, role assigned Real-time alert
CRITICAL Security/compliance events Multiple failed logins, bulk deletion, unauthorized access attempt Immediate escalation

10.6 Audit Events for Break-Glass

Requirement: All break-glass actions MUST generate HIGH or CRITICAL severity events.

Mandatory Additional Fields: - justification (human explanation) - before state (what was changed) - after state (new value)

Example:

@ApplicationScoped
class AuditService(
    private val auditEventRepository: AuditEventRepository,
    private val request: ContainerRequestContext
) {
    fun logPeriodReopened(period: ReportingPeriod, justification: String) {
        val actorId = securityIdentity.principal.name.toLong()

        auditEventRepository.persist(
            AuditEvent(
                tenantId = period.tenantId,
                actorId = actorId,
                action = "reporting_period.reopened",
                objectType = "ReportingPeriod",
                objectId = period.id.toString(),
                severity = Severity.CRITICAL,
                before = mapOf("state" to "LOCKED"),
                after = mapOf("state" to "OPEN"),
                justification = "Reopening to incorporate auditor-identified data correction per CFO approval",
                ipAddress = request.remoteAddr,
                userAgent = request.getHeader("User-Agent")
            )
        )
    }
}

10.7 Audit Events for Evidence Access

Requirement: Evidence downloads and exports MUST be auditable.

Rationale: Evidence contains sensitive data (invoices, photos, personnel records). Access tracking supports: - GDPR Article 30 (records of processing) - ISO 27001 A.12.4.1 (event logging) - Forensic investigation of data leakage

Example:

fun logEvidenceDownload(evidence: Evidence, downloadMethod: String) {
    val actorId = securityIdentity.principal.name.toLong()

    auditEventRepository.persist(
        AuditEvent(
            tenantId = evidence.tenantId,
            actorId = actorId,
            action = "evidence.downloaded",
            objectType = "Evidence",
            objectId = evidence.id.toString(),
            severity = Severity.MEDIUM,
            metadata = mapOf(
                "filename" to evidence.filename,
                "size_bytes" to evidence.sizeBytes,
                "download_method" to downloadMethod  // 'direct_link' vs 'audit_pack'
            )
        )
    )
}

10.8 Audit Log Retention

Requirements:

Environment Retention Period Rationale
Production 7 years (minimum) ISO 27001, SOC 2, industry standard
Staging 90 days Debugging and testing
Development 30 days Ephemeral data

Immutability: Audit logs MUST NOT be deletable by any user role, including platform admins.

Archival: After online retention period, archive to immutable storage (e.g., S3 Glacier with object lock).

10.9 Audit Log Access Controls

Who can read audit logs:

Role Access Scope Use Case
Admin Full tenant audit log Tenant-level compliance and incident investigation
Auditor Full tenant audit log (read-only) External assurance and certification
Approver Limited (approval actions only) Verify own approval history
Reviewer Limited (review actions only) Verify own review history
Collector Limited (own actions only) Transparency into submission history

Export permissions: Only admin and auditor can export full audit logs.

10.10 Monitoring and Alerting

Real-Time Alerts (recommended for production):

  • Any CRITICAL severity event → Immediate Slack/email to security team
  • Multiple HIGH severity events from same user in <1 hour → Alert
  • Failed authentication attempts >5 in 10 minutes → Alert
  • Break-glass action outside business hours → Alert
  • Bulk export (>100 records) → Notify admin

Daily Summary Reports: - Count of events by severity - Top 10 most active users - All break-glass actions - Failed authorization attempts


11. Evidence Security Policy

📋 OPERATIONAL SPECIFICATION This section defines security requirements for evidence file handling, storage, and access.

11.1 Evidence Security Principle

Evidence files are high-risk data assets requiring integrity protection, access control, and malware scanning.

Rationale: - Sensitive data: Evidence may contain PII, financial data, trade secrets - Audit-critical: Evidence supports ESG claims and regulatory filings - Attack vector: File uploads are common malware delivery mechanism - Compliance: ISO 27001 A.12.3.1 (backup), GDPR Article 32 (security of processing)

11.2 Evidence Access Control

Authorization Requirements:

Action Authorization Scope Enforcement
Upload Collector or Admin Must be within user's site scope
View metadata All roles (within tenant) Scoped for collectors
Download file Reviewer, Approver, Admin, Auditor Logged as audit event
Delete Admin only (break-glass) Requires justification
Modify PROHIBITED Evidence is immutable once uploaded

Implementation:

// In EvidencePolicy
@ApplicationScoped
class EvidencePolicy {
    fun canDownload(user: User, evidence: Evidence): Boolean {
        val ctx = tenantContextHolder.getContext()

        // Tenant boundary check
        if (evidence.tenantId != ctx.tenant.id) {
            return false
        }

        // Role-based access
        if (ctx.hasRole("collector")) {
            // Collectors limited to scoped evidence
            return ctx.canAccessSite(evidence.siteId)
        }

        // Reviewers, approvers, admins, auditors have full access
        return ctx.hasAnyRole(listOf("reviewer", "approver", "admin", "auditor"))
    }
}

11.3 Integrity Verification

Requirement: All evidence files MUST have cryptographic hash stored at upload time.

Hash Algorithm: SHA-256 (minimum)

Purpose: 1. Detect file corruption 2. Verify evidence has not been tampered with 3. Support forensic chain of custody

Implementation:

// On evidence upload
@ApplicationScoped
class EvidenceUploadService(
    private val evidenceRepository: EvidenceRepository,
    private val storageService: StorageService
) {
    fun uploadEvidence(file: MultipartFile, tenantId: UUID): Evidence {
        val hash = DigestUtils.sha256Hex(file.inputStream)
        val storagePath = storageService.store(file, tenantId)

        val actorId = securityIdentity.principal.name.toLong()

        return evidenceRepository.persist(
            Evidence(
                id = UUID.randomUUID(),
                filename = file.originalFilename ?: "unknown",
                storagePath = storagePath,
                hashSha256 = hash,
                sizeBytes = file.size,
                mimeType = file.contentType ?: "application/octet-stream",
                uploadedBy = actorId,
                tenantId = tenantId
            )
        )
    }
}

Verification on Download:

// Before serving file
@ApplicationScoped
class EvidenceDownloadService(
    private val storageService: StorageService,
    private val auditEventRepository: AuditEventRepository
) {
    fun verifyAndDownload(evidence: Evidence): ByteArray {
        val fileBytes = storageService.retrieve(evidence.storagePath)
        val computedHash = DigestUtils.sha256Hex(fileBytes)

        if (computedHash != evidence.hashSha256) {
            // File corrupted or tampered
            auditEventRepository.persist(
                AuditEvent(
                    action = "evidence.integrity_violation",
                    severity = Severity.CRITICAL,
                    objectId = evidence.id.toString(),
                    tenantId = evidence.tenantId,
                    actorId = 0 // System event
                )
            )

            throw WebApplicationException(Response.Status.
                Response.Status.INTERNAL_SERVER_ERROR,
                "Evidence file integrity check failed"
            )
        }

        return fileBytes
    }
}

11.4 Malware Scanning

Requirement: All uploaded evidence files SHOULD be scanned for malware.

Recommended Approach:

Option 1: Synchronous Scan (for small files)

// Example using ClamAV or similar service
@Path
@RequestMapping("/api/evidence")
class EvidenceController(
    private val malwareScannerService: MalwareScannerService,
    private val evidenceUploadService: EvidenceUploadService,
    private val auditEventRepository: AuditEventRepository
) {
    @POST("/upload")
    fun upload(@QueryParam file: MultipartFile, @QueryParam tenantId: UUID): Response<Evidence> {
        // Scan before storage
        val scanResult = malwareScannerService.scan(file.inputStream)

        if (scanResult.isMalicious) {
            auditEventRepository.persist(
                AuditEvent(
                    action = "evidence.upload_blocked_malware",
                    severity = Severity.HIGH,
                    metadata = mapOf("filename" to (file.originalFilename ?: "unknown")),
                    tenantId = tenantId,
                    actorId = securityIdentity.principal.name.toLong()
                )
            )

            return Response.unprocessableEntity()
                .body(null)  // Or throw exception with message
        }

        // Proceed with storage
        val evidence = evidenceUploadService.uploadEvidence(file, tenantId)
        return Response.status(Response.Status.CREATED).entity(evidence)
    }
}

Option 2: Asynchronous Scan (for large files)

// Upload to quarantine storage
@ApplicationScoped
class EvidenceQuarantineService(
    private val storageService: StorageService,
    private val evidenceRepository: EvidenceRepository,
    private val taskExecutor: TaskExecutor
) {
    fun uploadToQuarantine(file: MultipartFile, tenantId: UUID): Evidence {
        val tempPath = storageService.store(file, tenantId, "quarantine")

        val evidence = evidenceRepository.persist(
            Evidence(
                id = UUID.randomUUID(),
                filename = file.originalFilename ?: "unknown",
                storagePath = tempPath,
                scanStatus = ScanStatus.PENDING,
                tenantId = tenantId
                // ... other fields
            )
        )

        // Dispatch background scan
        taskExecutor.execute(ScanEvidenceTask(evidence.id))

        return evidence
    }
}

// Background task
@ApplicationScoped
class ScanEvidenceTask(
    private val evidenceId: UUID
) : Runnable {
    @Inject
    private lateinit var evidenceRepository: EvidenceRepository
    @Inject
    private lateinit var malwareScannerService: MalwareScannerService
    @Inject
    private lateinit var storageService: StorageService

    override fun run() {
        val evidence = evidenceRepository.findByIdOptional(evidenceId).orElseThrow()
        val scanResult = malwareScannerService.scanFile(evidence.storagePath)

        if (scanResult.isMalicious) {
            // Delete file and mark evidence
            storageService.delete(evidence.storagePath)
            evidence.scanStatus = ScanStatus.MALWARE_DETECTED
            evidenceRepository.persist(evidence)

            // Alert admin
            // notificationService.send(...)
        } else {
            // Move to production storage and mark clean
            evidence.scanStatus = ScanStatus.CLEAN
            evidenceRepository.persist(evidence)
        }
    }
}

Third-Party Services (recommended for production): - AWS GuardDuty / S3 Malware Scan - ClamAV (open-source) - VirusTotal API (for additional validation)

11.5 Storage Security

Requirements:

  1. Encryption at rest: Evidence files MUST be encrypted in storage
  2. AWS S3: Enable default encryption (AES-256)
  3. Local filesystem: Use encrypted volumes (LUKS, BitLocker)

  4. Access control: Storage bucket/volume MUST NOT be publicly accessible

  5. S3: Block public access, use pre-signed URLs for downloads
  6. Local: Restrict file permissions to web server user only

  7. Backup: Evidence storage MUST be backed up

  8. Retention: Match audit log retention (7 years minimum)
  9. Backup encryption: Required
  10. Disaster recovery: Test restoration quarterly

  11. Geographic storage: Consider data residency requirements

  12. EU tenants: Store evidence in EU region (GDPR compliance)
  13. Multi-region: Replicate to secondary region for DR

Example (AWS S3):

// Store with server-side encryption using AWS SDK for Kotlin
@ApplicationScoped
class S3StorageService(
    private val s3Client: S3Client
) {
    fun storeEvidence(file: MultipartFile, tenantId: UUID): String {
        val actorId = securityIdentity.principal.name.toLong()
        val key = "evidence/$tenantId/${UUID.randomUUID()}-${file.originalFilename}"

        val putRequest = PutObjectRequest {
            bucket = "esg-evidence-bucket"
            this.key = key
            serverSideEncryption = ServerSideEncryption.Aes256
            metadata = mapOf(
                "tenant-id" to tenantId.toString(),
                "uploaded-by" to actorId.toString()
            )
        }

        s3Client.putObject(putRequest, file.inputStream.readBytes())

        return key
    }

    // Generate time-limited download URL (presigned URL)
    fun generateDownloadUrl(storagePath: String, durationMinutes: Long = 5): String {
        val getRequest = GetObjectRequest {
            bucket = "esg-evidence-bucket"
            key = storagePath
        }

        val presigner = S3Presigner.builder().build()
        val presignRequest = PresignedGetObjectRequest.builder()
            .signatureDuration(Duration.ofMinutes(durationMinutes))
            .getObjectRequest(getRequest)
            .build()

        return presigner.presignGetObject(presignRequest).url().toString()
    }
}

11.6 File Type Restrictions

Recommended: Limit evidence uploads to known-safe file types.

Allowed Types (example policy): - Documents: PDF, DOCX, XLSX, CSV - Images: JPEG, PNG, WEBP - Archives: ZIP (with malware scan)

Prohibited Types: - Executables: EXE, BAT, SH, APP - Scripts: JS, VBS, PS1 - Macros: DOC (old format), XLS (old format)

Implementation:

// In upload validation using Bean Validation (javax.validation)
@Path
@RequestMapping("/api/evidence")
class EvidenceController {

    @POST("/upload")
    fun upload(
        @QueryParam @Valid file: MultipartFile,
        @QueryParam tenantId: UUID
    ): Response<Evidence> {
        // Validate file size (10MB max)
        if (file.size > 10 * 1024 * 1024) {
            throw WebApplicationException(Response.Status.Response.Status.BAD_REQUEST, "File size exceeds 10MB limit")
        }

        // Validate MIME type
        val allowedMimeTypes = listOf(
            "application/pdf",
            "image/jpeg",
            "image/png",
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            "text/csv"
        )

        if (file.contentType !in allowedMimeTypes) {
            throw WebApplicationException(Response.Status.
                Response.Status.BAD_REQUEST,
                "File type not allowed. Allowed types: PDF, JPG, PNG, XLSX, CSV"
            )
        }

        // Proceed with upload
        // ...
    }
}

11.7 Evidence Download Audit

Requirement: Every evidence file download MUST be logged.

Audit Event:

// On evidence download
@ApplicationScoped
class EvidenceDownloadService(
    private val storageService: StorageService,
    private val auditEventRepository: AuditEventRepository,
    private val request: ContainerRequestContext
) {
    fun downloadEvidence(evidence: Evidence): Response<Resource> {
        val actorId = securityIdentity.principal.name.toLong()

        // Log download
        auditEventRepository.persist(
            AuditEvent(
                tenantId = evidence.tenantId,
                actorId = actorId,
                action = "evidence.downloaded",
                objectType = "Evidence",
                objectId = evidence.id.toString(),
                severity = Severity.MEDIUM,
                metadata = mapOf(
                    "filename" to evidence.filename,
                    "size_bytes" to evidence.sizeBytes,
                    "hash_sha256" to evidence.hashSha256
                ),
                ipAddress = request.remoteAddr
            )
        )

        // Serve file
        val fileBytes = storageService.retrieve(evidence.storagePath)
        val resource = ByteArrayResource(fileBytes)

        return Response.ok().entity()
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"${evidence.filename}\"")
            .body(resource)
    }
}

Monitoring: Alert on unusual download patterns: - Single user downloading >50 evidence files in 1 hour - Evidence downloads outside business hours - Evidence downloads from unexpected geographic locations

11.8 Evidence Deletion (Break-Glass)

Requirement: Evidence deletion MUST be treated as break-glass action.

Process: 1. Require admin role 2. Require justification (minimum 15 characters) 3. Log BEFORE deletion (in case deletion fails) 4. Soft delete (set deleted_at) rather than hard delete 5. Retain metadata even after file deletion

Example:

// In EvidenceService::deleteEvidence()
@ApplicationScoped
class EvidenceService(
    private val evidenceRepository: EvidenceRepository,
    private val breakGlassHelper: BreakGlassHelper,
    private val storageService: StorageService
) {
    @Transactional
    fun deleteEvidence(evidence: Evidence, justification: String) {
        val actorId = securityIdentity.principal.name.toLong()

        // Require break-glass justification
        breakGlassHelper.requireJustification(justification)

        // Log before deletion
        breakGlassHelper.logBreakGlassAction(
            tenantId = evidence.tenantId,
            actorId = actorId,
            action = "evidence.deleted",
            objectType = "Evidence",
            objectId = evidence.id,
            justification = justification
        )

        // Soft delete (preserves metadata) - set deletedAt timestamp
        evidence.deletedAt = Instant.now()
        evidenceRepository.persist(evidence)

        // Optionally: move file to quarantine rather than deleting
        storageService.move(
            evidence.storagePath,
            "evidence/deleted/${evidence.id}/${evidence.filename}"
        )
    }
}


12. Folder Structure

📋 Reference Implementation: The following structure supports the security specifications defined in Sections 4-11.

Recommended Quarkus Kotlin project structure:

esg-backend/
├── src/main/kotlin/
│   ├── domain/
│   │   ├── tenancy/
│   │   │   ├── entity/
│   │   │   │   ├── Tenant.kt
│   │   │   │   ├── Site.kt
│   │   │   │   ├── Project.kt
│   │   │   │   └── ReportingPeriod.kt
│   │   │   ├── enums/
│   │   │   │   └── ReportingPeriodState.kt
│   │   │   ├── repository/
│   │   │   │   ├── TenantRepository.kt
│   │   │   │   ├── SiteRepository.kt
│   │   │   │   └── ReportingPeriodRepository.kt
│   │   │   └── service/
│   │   │       ├── TenantContext.kt
│   │   │       └── ScopeResolver.kt
│   │   └── esg/
│   │       ├── entity/
│   │       │   ├── Submission.kt
│   │       │   ├── Evidence.kt
│   │       │   ├── Template.kt
│   │       │   ├── Comment.kt
│   │       │   └── AuditEvent.kt
│   │       ├── repository/
│   │       │   ├── SubmissionRepository.kt
│   │       │   └── EvidenceRepository.kt
│   │       └── service/
│   │           ├── ReportingStateGuard.kt
│   │           ├── SubmissionWorkflow.kt
│   │           └── EvidenceIntegrity.kt
│   ├── security/
│   │   ├── authorization/
│   │   │   ├── TenantAuthorizationService.kt
│   │   │   ├── SiteAuthorizationService.kt
│   │   │   ├── ProjectAuthorizationService.kt
│   │   │   ├── ReportingPeriodAuthorizationService.kt
│   │   │   ├── TemplateAuthorizationService.kt
│   │   │   ├── SubmissionAuthorizationService.kt
│   │   │   ├── EvidenceAuthorizationService.kt
│   │   │   ├── CommentAuthorizationService.kt
│   │   │   ├── AuditLogAuthorizationService.kt
│   │   │   ├── ReportAuthorizationService.kt
│   │   │   └── ExportAuthorizationService.kt
│   │   ├── auth/
│   │   │   ├── Roles.kt              # Role constants/enum
│   │   │   ├── Permissions.kt        # Permission helpers
│   │   │   └── SoD.kt                # Segregation of duties helpers
│   │   ├── breakglass/
│   │   │   └── BreakGlass.kt         # Break-glass validation
│   │   └── filter/
│   │       ├── RequireTenantContextFilter.kt
│   │       ├── EnforceTenantBoundaryFilter.kt
│   │       ├── EnforceScopeFilter.kt
│   │       ├── EnforceReportingPeriodStateFilter.kt
│   │       └── EnforceBreakGlassFilter.kt
│   └── controller/
│       └── api/
│           ├── TenantController.kt
│           ├── SubmissionController.kt
│           └── EvidenceController.kt
├── src/main/resources/
│   ├── db/migration/
│   │   ├── V1__create_tenants_table.sql
│   │   ├── V2__create_sites_table.sql
│   │   ├── V3__create_projects_table.sql
│   │   ├── V4__create_reporting_periods_table.sql
│   │   ├── V5__create_tenant_user_roles_table.sql
│   │   ├── V6__create_tenant_user_scopes_table.sql
│   │   ├── V7__create_submissions_table.sql
│   │   ├── V8__create_evidence_table.sql
│   │   └── V9__create_audit_events_table.sql
│   └── application.properties
└── src/test/kotlin/
    ├── integration/
    └── unit/

13. Database Schema

📋 Reference Implementation: Database schema supporting the security specifications defined in Sections 4-11.

13.1 Core Tables

Tenants

-- Flyway migration: V1__create_tenants_table.sql
CREATE TABLE tenants (
    id UUID PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    country_code CHAR(2) NOT NULL,
    company_number VARCHAR(255),
    frameworks_enabled JSONB DEFAULT '["GRI"]'::jsonb,
    timezone VARCHAR(50) DEFAULT 'UTC',
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP
);

CREATE INDEX idx_tenants_deleted_at ON tenants(deleted_at);

Sites

-- Flyway migration: V2__create_sites_table.sql
CREATE TABLE sites (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    name VARCHAR(255) NOT NULL,
    country_code CHAR(2) NOT NULL,
    operational_type VARCHAR(100) NOT NULL, -- manufacturing, office, etc.
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_sites_tenant_id_is_active ON sites(tenant_id, is_active);

Reporting Periods

-- Flyway migration: V3__create_reporting_periods_table.sql
CREATE TABLE reporting_periods (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    name VARCHAR(255) NOT NULL, -- e.g., "FY2024"
    start_date DATE NOT NULL,
    end_date DATE NOT NULL,
    state VARCHAR(20) NOT NULL DEFAULT 'OPEN'
        CHECK (state IN ('OPEN', 'IN_REVIEW', 'APPROVED', 'LOCKED')),
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_reporting_periods_tenant_id_state ON reporting_periods(tenant_id, state);

13.2 RBAC Tables

Tenant User Roles

-- Flyway migration: V4__create_tenant_user_roles_table.sql
CREATE TABLE tenant_user_roles (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role VARCHAR(50) NOT NULL, -- collector, reviewer, approver, admin, auditor
    expires_at TIMESTAMP,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE (tenant_id, user_id, role)
);

CREATE INDEX idx_tenant_user_roles_tenant_user ON tenant_user_roles(tenant_id, user_id);

Tenant User Scopes

-- Flyway migration: V5__create_tenant_user_scopes_table.sql
CREATE TABLE tenant_user_scopes (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    site_id UUID REFERENCES sites(id) ON DELETE CASCADE,
    project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_tenant_user_scopes_tenant_user ON tenant_user_scopes(tenant_id, user_id);
CREATE INDEX idx_tenant_user_scopes_tenant_user_site ON tenant_user_scopes(tenant_id, user_id, site_id);

13.3 ESG Data Tables

Submissions

-- Flyway migration: V6__create_submissions_table.sql
CREATE TABLE submissions (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    reporting_period_id UUID NOT NULL REFERENCES reporting_periods(id) ON DELETE CASCADE,
    site_id UUID REFERENCES sites(id) ON DELETE CASCADE,
    template_id UUID NOT NULL REFERENCES templates(id) ON DELETE CASCADE,
    created_by BIGINT NOT NULL REFERENCES users(id),
    status VARCHAR(20) NOT NULL DEFAULT 'draft'
        CHECK (status IN ('draft', 'submitted', 'in_review', 'approved', 'rejected')),
    data JSONB NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP
);

CREATE INDEX idx_submissions_tenant_period_status ON submissions(tenant_id, reporting_period_id, status);
CREATE INDEX idx_submissions_tenant_site ON submissions(tenant_id, site_id);
CREATE INDEX idx_submissions_created_by ON submissions(created_by);

Evidence

-- Flyway migration: V7__create_evidence_table.sql
CREATE TABLE evidence (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    submission_id UUID REFERENCES submissions(id) ON DELETE CASCADE,
    reporting_period_id UUID NOT NULL REFERENCES reporting_periods(id) ON DELETE CASCADE,
    site_id UUID REFERENCES sites(id) ON DELETE CASCADE,
    uploaded_by BIGINT NOT NULL REFERENCES users(id),
    filename VARCHAR(500) NOT NULL,
    mime_type VARCHAR(100) NOT NULL,
    storage_path VARCHAR(1000) NOT NULL,
    hash_sha256 CHAR(64) NOT NULL,
    size_bytes BIGINT NOT NULL,
    tags JSONB,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP
);

CREATE INDEX idx_evidence_tenant_period ON evidence(tenant_id, reporting_period_id);
CREATE INDEX idx_evidence_tenant_site ON evidence(tenant_id, site_id);
CREATE INDEX idx_evidence_hash ON evidence(hash_sha256);

13.4 Audit Table

-- Flyway migration: V8__create_audit_events_table.sql
CREATE TABLE audit_events (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    actor_id BIGINT NOT NULL REFERENCES users(id),
    action VARCHAR(255) NOT NULL, -- e.g., 'submission.approved'
    object_type VARCHAR(100) NOT NULL, -- e.g., 'Submission'
    object_id UUID NOT NULL,
    severity VARCHAR(20) NOT NULL DEFAULT 'LOW'
        CHECK (severity IN ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL')),
    before JSONB,
    after JSONB,
    justification TEXT, -- for break-glass
    ip_address VARCHAR(45), -- IPv6 support
    user_agent VARCHAR(500),
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_audit_events_tenant_created ON audit_events(tenant_id, created_at);
CREATE INDEX idx_audit_events_actor_created ON audit_events(actor_id, created_at);
CREATE INDEX idx_audit_events_severity_created ON audit_events(severity, created_at);

14. Code Implementations

📋 Reference Implementation: Example code patterns implementing the security specifications from Sections 4-11. ⚠️ Important: The policy examples in sections 14.5 and 14.6 are simplified for illustration. Refer to Sections 4-11 for complete security requirements.

14.1 ReportingPeriodState Enum

File: src/main/kotlin/domain/tenancy/enums/ReportingPeriodState.kt

package com.esg.domain.tenancy.enums

enum class ReportingPeriodState {
    OPEN,
    IN_REVIEW,
    APPROVED,
    LOCKED;

    fun isWritable(): Boolean {
        return this in listOf(OPEN, IN_REVIEW)
    }

    fun allowsDataEntry(): Boolean {
        return this == OPEN
    }

    fun allowsReview(): Boolean {
        return this == IN_REVIEW
    }

    fun allowsApproval(): Boolean {
        return this == IN_REVIEW
    }

    fun isLocked(): Boolean {
        return this == LOCKED
    }
}

14.2 TenantContext Service

File: src/main/kotlin/domain/tenancy/services/TenantContext.kt

package com.esg.domain.tenancy.services

import com.esg.domain.tenancy.models.Tenant
import java.util.UUID

data class TenantContext(
    val tenant: Tenant,
    val roles: List<String>,        // e.g., ["collector", "reviewer"]
    val siteIds: List<UUID>,         // allowed site UUIDs
    val projectIds: List<UUID>       // allowed project UUIDs
) {
    fun hasRole(role: String): Boolean {
        return role in roles
    }

    fun hasAnyRole(roles: List<String>): Boolean {
        return this.roles.any { it in roles }
    }

    fun canAccessSite(siteId: UUID?): Boolean {
        // Null site_id = tenant-level resource
        if (siteId == null) {
            return true
        }

        // Empty scopes = access to all sites
        if (siteIds.isEmpty()) {
            return true
        }

        return siteId in siteIds
    }

    fun canAccessProject(projectId: UUID?): Boolean {
        if (projectId == null) {
            return true
        }

        if (projectIds.isEmpty()) {
            return true
        }

        return projectId in projectIds
    }
}

// CDI Request-scoped bean for TenantContext
@RequestScoped
class TenantContextHolder {
    private var context: TenantContext? = null

    fun setContext(context: TenantContext) {
        this.context = context
    }

    fun getContext(): TenantContext {
        return context
            ?: throw IllegalStateException("Tenant context not initialized")
    }

    fun clear() {
        context = null
    }
}

14.3 SoD (Segregation of Duties) Helper

File: src/main/kotlin/support/auth/SoD.kt

package com.esg.support.auth

import javax.enterprise.context.ApplicationScoped
import javax.ws.rs.ForbiddenException

@ApplicationScoped
class SoD {
    /**
     * Deny self-approval: the user cannot approve their own submission.
     *
     * @throws ForbiddenException
     */
    fun denySelfApproval(createdBy: Long?, actorId: Long) {
        if (createdBy != null && createdBy == actorId) {
            throw ForbiddenException(
                "Segregation of duties violation: You cannot approve your own submission."
            )
        }
    }

    /**
     * Optionally deny self-review.
     *
     * @throws ForbiddenException
     */
    fun denySelfReview(createdBy: Long?, actorId: Long) {
        if (createdBy != null && createdBy == actorId) {
            throw ForbiddenException(
                "Segregation of duties violation: You cannot review your own submission."
            )
        }
    }
}

14.4 Break-Glass Helper

File: src/main/kotlin/support/breakglass/BreakGlass.kt

package com.esg.support.breakglass

import com.esg.domain.esg.models.AuditEvent
import com.esg.domain.esg.repositories.AuditEventRepository
import com.esg.domain.tenancy.enums.Severity
import org.springframework.security.access.ForbiddenException
import javax.enterprise.context.ApplicationScoped
import java.util.UUID
import jakarta.servlet.http.ContainerRequestContext

@ApplicationScoped
class BreakGlass(
    private val auditEventRepository: AuditEventRepository,
    private val request: ContainerRequestContext
) {
    companion object {
        private const val MIN_JUSTIFICATION_LENGTH = 15
    }

    /**
     * Require a valid justification for break-glass actions.
     *
     * @throws ForbiddenException
     */
    fun requireJustification(justification: String?) {
        val j = justification?.trim() ?: ""

        if (j.length < MIN_JUSTIFICATION_LENGTH) {
            throw ForbiddenException(
                "Break-glass action requires a clear justification (minimum $MIN_JUSTIFICATION_LENGTH characters)."
            )
        }
    }

    /**
     * Log a break-glass action to the audit trail.
     */
    fun logBreakGlassAction(
        tenantId: UUID,
        actorId: Long,
        action: String,
        objectType: String,
        objectId: UUID,
        justification: String
    ) {
        auditEventRepository.persist(
            AuditEvent(
                id = UUID.randomUUID(),
                tenantId = tenantId,
                actorId = actorId,
                action = action,
                objectType = objectType,
                objectId = objectId.toString(),
                severity = Severity.HIGH,
                justification = justification,
                ipAddress = request.remoteAddr,
                userAgent = request.getHeader("User-Agent")
            )
        )
    }
}

14.5 SubmissionPolicy (Complete Example)

File: src/main/kotlin/policies/SubmissionPolicy.kt

package com.esg.policies

import com.esg.domain.esg.models.Submission
import com.esg.domain.esg.enums.SubmissionStatus
import com.esg.domain.tenancy.enums.ReportingPeriodState
import com.esg.domain.tenancy.services.TenantContextHolder
import com.esg.models.User
import com.esg.support.auth.SoD
import org.springframework.security.access.ForbiddenException
import javax.enterprise.context.ApplicationScoped
import java.util.UUID

@ApplicationScoped
class SubmissionPolicy {

    /**
     * View a submission.
     */
    fun canView(user: User, submission: Submission): Boolean {
        val ctx = tenantContextHolder.getContext()

        // Must be within the same tenant
        if (submission.tenantId != ctx.tenant.id) {
            return false
        }

        // Collectors: must be within scope
        if (ctx.hasRole("collector")) {
            return ctx.canAccessSite(submission.siteId)
        }

        // Reviewer, approver, admin, auditor can read within tenant
        return ctx.hasAnyRole(listOf("reviewer", "approver", "admin", "auditor"))
    }

    /**
     * Create a new submission.
     */
    fun canCreate(user: User, periodState: ReportingPeriodState?, siteId: UUID?): Boolean {
        val ctx = tenantContextHolder.getContext()

        // Period must be OPEN
        if (periodState != ReportingPeriodState.OPEN) {
            return false
        }

        // Admin can always create
        if (ctx.hasRole("admin")) {
            return true
        }

        // Collector can create if within scope
        if (ctx.hasRole("collector")) {
            return ctx.canAccessSite(siteId)
        }

        return false
    }

    /**
     * Update a submission.
     */
    fun canUpdate(user: User, submission: Submission): Boolean {
        val ctx = tenantContextHolder.getContext()

        // Must be within the same tenant
        if (submission.tenantId != ctx.tenant.id) {
            return false
        }

        // Period must allow writes
        if (!submission.reportingPeriod.state.allowsDataEntry()) {
            return false
        }

        // Admin can always update
        if (ctx.hasRole("admin")) {
            return true
        }

        // Collector can update their own submissions within scope
        if (ctx.hasRole("collector")) {
            return submission.createdBy == user.id
                && ctx.canAccessSite(submission.siteId)
        }

        return false
    }

    /**
     * Delete a draft submission.
     */
    fun canDeleteDraft(user: User, submission: Submission): Boolean {
        val ctx = tenantContextHolder.getContext()

        if (submission.tenantId != ctx.tenant.id) {
            return false
        }

        if (submission.status != SubmissionStatus.DRAFT) {
            return false
        }

        if (!submission.reportingPeriod.state.allowsDataEntry()) {
            return false
        }

        // Admin can delete with break-glass (if needed)
        if (ctx.hasRole("admin")) {
            return true
        }

        // Collector can delete their own drafts
        if (ctx.hasRole("collector")) {
            return submission.createdBy == user.id
        }

        return false
    }

    /**
     * Submit for review.
     */
    fun canSubmit(user: User, submission: Submission): Boolean {
        val ctx = tenantContextHolder.getContext()

        if (submission.tenantId != ctx.tenant.id) {
            return false
        }

        if (!submission.reportingPeriod.state.allowsDataEntry()) {
            return false
        }

        // Admin can submit
        if (ctx.hasRole("admin")) {
            return true
        }

        // Collector can submit their own
        if (ctx.hasRole("collector")) {
            return submission.createdBy == user.id
                && ctx.canAccessSite(submission.siteId)
        }

        return false
    }

    /**
     * Return submission to collector.
     */
    fun canReturnToCollector(user: User, submission: Submission): Boolean {
        val ctx = tenantContextHolder.getContext()

        if (submission.tenantId != ctx.tenant.id) {
            return false
        }

        if (!submission.reportingPeriod.state.allowsReview()) {
            return false
        }

        return ctx.hasAnyRole(listOf("reviewer", "admin"))
    }

    /**
     * Mark as reviewed.
     */
    fun canMarkReviewed(user: User, submission: Submission): Boolean {
        val ctx = tenantContextHolder.getContext()

        if (submission.tenantId != ctx.tenant.id) {
            return false
        }

        if (!submission.reportingPeriod.state.allowsReview()) {
            return false
        }

        return ctx.hasAnyRole(listOf("reviewer", "admin"))
    }

    /**
     * Approve a submission (SoD enforced).
     */
    fun canApprove(user: User, submission: Submission) {
        val ctx = tenantContextHolder.getContext()

        if (submission.tenantId != ctx.tenant.id) {
            throw ForbiddenException("Not authorized for this tenant.")
        }

        if (!submission.reportingPeriod.state.allowsApproval()) {
            throw ForbiddenException("Reporting period is not in a state that allows approval.")
        }

        if (!ctx.hasRole("approver")) {
            throw ForbiddenException("Only approvers can approve submissions.")
        }

        // Segregation of duties: deny self-approval
        SoD.denySelfApproval(submission.createdBy, user.id)
    }
}

14.6 EvidencePolicy

File: src/main/kotlin/policies/EvidencePolicy.kt

package com.esg.policies

import com.esg.domain.esg.models.Evidence
import com.esg.domain.tenancy.enums.ReportingPeriodState
import com.esg.domain.tenancy.services.TenantContextHolder
import com.esg.models.User
import com.esg.support.breakglass.BreakGlass
import org.springframework.security.access.ForbiddenException
import javax.enterprise.context.ApplicationScoped
import java.util.UUID

@ApplicationScoped
class EvidencePolicy(
    private val breakGlass: BreakGlass
) {

    fun canView(user: User, evidence: Evidence): Boolean {
        val ctx = tenantContextHolder.getContext()

        if (evidence.tenantId != ctx.tenant.id) {
            return false
        }

        if (ctx.hasRole("collector")) {
            return ctx.canAccessSite(evidence.siteId)
        }

        return ctx.hasAnyRole(listOf("reviewer", "approver", "admin", "auditor"))
    }

    fun canUpload(user: User, periodState: ReportingPeriodState?): Boolean {
        val ctx = tenantContextHolder.getContext()

        // Period must be OPEN
        if (periodState != ReportingPeriodState.OPEN) {
            return false
        }

        return ctx.hasAnyRole(listOf("collector", "admin"))
    }

    fun canUpdateTags(user: User, evidence: Evidence): Boolean {
        val ctx = tenantContextHolder.getContext()

        if (evidence.tenantId != ctx.tenant.id) {
            return false
        }

        // Allow metadata updates during OPEN and IN_REVIEW
        val periodState = evidence.reportingPeriod.state
        if (periodState !in listOf(ReportingPeriodState.OPEN, ReportingPeriodState.IN_REVIEW)) {
            return false
        }

        return ctx.hasAnyRole(listOf("collector", "reviewer", "admin"))
    }

    fun canDelete(user: User, evidence: Evidence, justification: String?) {
        val ctx = tenantContextHolder.getContext()

        if (evidence.tenantId != ctx.tenant.id) {
            throw ForbiddenException("Not authorized for this tenant.")
        }

        if (!ctx.hasRole("admin")) {
            throw ForbiddenException("Only admins can delete evidence.")
        }

        // Require break-glass justification
        breakGlass.requireJustification(justification)

        // Log the break-glass action
        breakGlass.logBreakGlassAction(
            tenantId = ctx.tenant.id,
            actorId = user.id,
            action = "evidence.delete",
            objectType = "Evidence",
            objectId = evidence.id,
            justification = justification!!
        )
    }
}

15. Middleware Stack

15.1 RequireTenantContext

File: src/main/kotlin/http/filters/RequireTenantContextFilter.kt

package com.esg.http.filters

import com.esg.domain.tenancy.repositories.TenantRepository
import com.esg.domain.tenancy.repositories.TenantUserRoleRepository
import com.esg.domain.tenancy.repositories.TenantUserScopeRepository
import com.esg.domain.tenancy.services.TenantContext
import com.esg.domain.tenancy.services.TenantContextHolder
import io.quarkus.security.identity.SecurityIdentity
import java.time.Instant
import java.util.UUID
import javax.enterprise.context.ApplicationScoped
import javax.inject.Inject
import javax.ws.rs.container.ContainerRequestContext
import javax.ws.rs.container.ContainerRequestFilter
import javax.ws.rs.core.Response
import javax.ws.rs.ext.Provider

@Provider
@ApplicationScoped
class RequireTenantContextFilter : ContainerRequestFilter {

    @Inject
    lateinit var tenantRepository: TenantRepository

    @Inject
    lateinit var tenantUserRoleRepository: TenantUserRoleRepository

    @Inject
    lateinit var tenantUserScopeRepository: TenantUserScopeRepository

    @Inject
    lateinit var securityIdentity: SecurityIdentity

    @Inject
    lateinit var tenantContextHolder: TenantContextHolder

    override fun filter(requestContext: ContainerRequestContext) {
        val tenantIdHeader = requestContext.getHeaderString("X-Tenant-Id")
            ?: throw javax.ws.rs.WebApplicationException(
                Response.status(Response.Status.BAD_REQUEST)
                    .entity("Tenant ID is required")
                    .build()
            )

        val tenantId = try {
            UUID.fromString(tenantIdHeader)
        } catch (e: IllegalArgumentException) {
            throw javax.ws.rs.WebApplicationException(
                Response.status(Response.Status.BAD_REQUEST)
                    .entity("Invalid tenant ID format")
                    .build()
            )
        }

        val tenant = tenantRepository.findByIdOptional(tenantId).orElseThrow {
            javax.ws.rs.WebApplicationException(
                Response.status(Response.Status.NOT_FOUND)
                    .entity("Invalid tenant")
                    .build()
            )
        }

        // Get authenticated user ID from SecurityIdentity
        if (securityIdentity.isAnonymous) {
            throw javax.ws.rs.WebApplicationException(
                Response.status(Response.Status.UNAUTHORIZED)
                    .entity("Unauthenticated")
                    .build()
            )
        }

        val userId = securityIdentity.principal.name.toLong()

        // Fetch user's roles for this tenant
        val roles = tenantUserRoleRepository
            .findByTenantIdAndUserIdAndExpiresAtAfter(tenantId, userId, Instant.now())
            .map { it.role }

        if (roles.isEmpty()) {
            throw javax.ws.rs.WebApplicationException(
                Response.status(Response.Status.FORBIDDEN)
                    .entity("Not authorized for this tenant")
                    .build()
            )
        }

        // Fetch scopes
        val scopes = tenantUserScopeRepository.findByTenantIdAndUserId(tenantId, userId)

        val siteIds = scopes.mapNotNull { it.siteId }.distinct()
        val projectIds = scopes.mapNotNull { it.projectId }.distinct()

        // Bind TenantContext for this request (using @RequestScoped bean)
        val context = TenantContext(
            tenant = tenant,
            roles = roles,
            siteIds = siteIds,
            projectIds = projectIds
        )

        tenantContextHolder.setContext(context)
    }
}

15.2 EnforceTenantBoundary

Note: In Quarkus/Hibernate, tenant boundary enforcement is implemented via Hibernate Filters applied in the TenantFilterInterceptor (shown in Section 5.3).

Implementation Pattern:

// Tenant filter applied in TenantFilterInterceptor (see Section 5.3)
// This filter enables the Hibernate @Filter on all tenant-scoped entities

@Provider
@ApplicationScoped
class TenantFilterInterceptor : ContainerRequestFilter {

    @Inject
    lateinit var sessionFactory: SessionFactory

    @Inject
    lateinit var tenantContextHolder: TenantContextHolder

    override fun filter(requestContext: ContainerRequestContext) {
        val tenantContext = tenantContextHolder.getContext()

        // Enable the tenant filter for all tenant-scoped entities
        val session = sessionFactory.currentSession
        session.enableFilter("tenantFilter")
            .setParameter("tenantId", tenantContext.tenant.id.toString())
    }
}

16. Policy Patterns

16.1 Policy Registration

File: src/main/kotlin/config/SecurityConfig.kt

package com.esg.config

import com.esg.policies.EvidencePolicy
import com.esg.policies.ReportingPeriodPolicy
import com.esg.policies.SitePolicy
import com.esg.policies.SubmissionPolicy
import javax.enterprise.inject.Produces
// No configuration class needed in Quarkus
// Quarkus uses @RolesAllowed for method security.method.DefaultMethodSecurityExpressionHandler
// Quarkus uses @RolesAllowed for method security.method.MethodSecurityExpressionHandler
// Quarkus security config in application.properties.annotation.method.configuration.EnableGlobalMethodSecurity
// Quarkus security config in application.properties.annotation.web.builders.HttpSecurity
// Quarkus security config in application.properties.annotation.web.configuration.EnableWebSecurity
// JAX-RS handles web security.SecurityFilterChain

// Quarkus uses CDI for bean discovery
@ApplicationScoped
@ApplicationScoped(prePostEnabled = true)
// Quarkus does not require a SecurityConfig class
// Policies are CDI beans registered automatically
// Security is configured in application.properties

    @Produces
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .authorizeRequests()
            .anyRequest().authenticated()
            .and()
            .httpBasic()

        return http.build()
    }

    @Produces
    fun methodSecurityExpressionHandler(): MethodSecurityExpressionHandler {
        return DefaultMethodSecurityExpressionHandler()
    }

    // Policy beans
    @Produces
    fun submissionPolicy() = SubmissionPolicy()

    @Produces
    fun evidencePolicy() = EvidencePolicy()

    @Produces
    fun sitePolicy() = SitePolicy()

    @Produces
    fun reportingPeriodPolicy() = ReportingPeriodPolicy()
}

16.2 Common Policy Patterns

Pattern 1: Tenant Boundary Check

val ctx = tenantContextHolder.getContext()
if (resource.tenantId != ctx.tenant.id) {
    return false
}

Pattern 2: Role Check

val ctx = tenantContextHolder.getContext()
if (!ctx.hasRole("admin")) {
    return false
}

Pattern 3: Scope Constraint

val ctx = tenantContextHolder.getContext()
if (ctx.hasRole("collector")) {
    return ctx.canAccessSite(resource.siteId)
}

Pattern 4: State Gate

if (!resource.reportingPeriod.state.allowsDataEntry()) {
    return false
}

Pattern 5: Ownership Check

if (resource.createdBy != user.id) {
    return false
}

17. Usage in Controllers

17.1 Standard Authorization

package com.esg.http.controllers.api

import com.esg.domain.esg.models.Submission
import com.esg.domain.esg.models.AuditEvent
import com.esg.domain.esg.repositories.SubmissionRepository
import com.esg.domain.esg.repositories.AuditEventRepository
import com.esg.domain.tenancy.enums.Severity
import com.esg.domain.tenancy.services.TenantContextHolder
import com.esg.policies.SubmissionPolicy
import javax.ws.rs.core.HttpStatus
import javax.ws.rs.core.Response
import org.springframework.security.core.context.SecurityIdentity
import javax.ws.rs.bind.annotation.*
import java.util.UUID
import javax.validation.Valid
import javax.validation.constraints.NotNull

@Path
@RequestMapping("/api/submissions")
class SubmissionController(
    private val submissionRepository: SubmissionRepository,
    private val submissionPolicy: SubmissionPolicy,
    private val auditEventRepository: AuditEventRepository
) {

    @GET("/{id}")
    fun show(@PathParam id: UUID): Response<Submission> {
        val submission = submissionRepository.findByIdOptional(id).orElseThrow()
        val user = getCurrentUser()

        if (!submissionPolicy.canView(user, submission)) {
            return Response.status(Response.Status.FORBIDDEN).build()
        }

        return Response.ok().entity(submission)
    }

    @POST
    fun store(@Valid method parameter request: CreateSubmissionRequest): Response<Submission> {
        val user = getCurrentUser()
        val ctx = tenantContextHolder.getContext()

        if (!submissionPolicy.canCreate(user, request.periodState, request.siteId)) {
            return Response.status(Response.Status.FORBIDDEN).build()
        }

        val submission = submissionRepository.persist(
            Submission(
                id = UUID.randomUUID(),
                tenantId = ctx.tenant.id,
                reportingPeriodId = request.reportingPeriodId,
                siteId = request.siteId,
                templateId = request.templateId,
                createdBy = user.id,
                data = request.data
            )
        )

        return Response.status(Response.Status.CREATED).entity(submission)
    }

    @PUT("/{id}")
    fun update(
        @PathParam id: UUID,
        @Valid method parameter request: UpdateSubmissionRequest
    ): Response<Submission> {
        val submission = submissionRepository.findByIdOptional(id).orElseThrow()
        val user = getCurrentUser()

        if (!submissionPolicy.canUpdate(user, submission)) {
            return Response.status(Response.Status.FORBIDDEN).build()
        }

        submission.data = request.data
        submissionRepository.persist(submission)

        return Response.ok().entity(submission)
    }

    @POST("/{id}/approve")
    fun approve(@PathParam id: UUID): Response<Submission> {
        val submission = submissionRepository.findByIdOptional(id).orElseThrow()
        val user = getCurrentUser()

        submissionPolicy.canApprove(user, submission) // Throws if not allowed

        submission.status = SubmissionStatus.APPROVED
        submissionRepository.persist(submission)

        // Log audit event
        auditEventRepository.persist(
            AuditEvent(
                tenantId = submission.tenantId,
                actorId = user.id,
                action = "submission.approved",
                objectType = "Submission",
                objectId = submission.id.toString(),
                severity = Severity.MEDIUM,
                after = mapOf("status" to "approved")
            )
        )

        return Response.ok().entity(submission)
    }

    private fun getCurrentUser(): User {
        val authentication = securityIdentity.authentication
        return authentication.principal as User
    }
}

data class CreateSubmissionRequest(
    @field:NotNull val reportingPeriodId: UUID,
    val siteId: UUID?,
    @field:NotNull val templateId: UUID,
    @field:NotNull val data: Map<String, Any>,
    val periodState: ReportingPeriodState?
)

data class UpdateSubmissionRequest(
    @field:NotNull val data: Map<String, Any>
)

17.2 Break-Glass Action Example

@DELETE("/evidence/{id}")
fun deleteEvidence(
    @PathParam id: UUID,
    @Valid method parameter request: DeleteEvidenceRequest
): Response<Map<String, String>> {
    val evidence = evidenceRepository.findByIdOptional(id).orElseThrow()
    val user = getCurrentUser()

    // This will internally check break-glass requirements and throw if not allowed
    evidencePolicy.canDelete(user, evidence, request.justification)

    evidenceRepository.delete(evidence)

    return Response.ok().entity(mapOf("message" to "Evidence deleted"))
}

data class DeleteEvidenceRequest(
    @field:Size(min = 15, message = "Justification must be at least 15 characters")
    val justification: String
)

18. Testing Strategies

18.1 Policy Tests

File: src/test/kotlin/policies/SubmissionPolicyTest.kt

package com.esg.policies

import com.esg.domain.esg.models.Submission
import com.esg.domain.tenancy.enums.ReportingPeriodState
import com.esg.domain.tenancy.models.ReportingPeriod
import com.esg.domain.tenancy.models.Site
import com.esg.domain.tenancy.models.Tenant
import com.esg.domain.tenancy.services.TenantContext
import com.esg.domain.tenancy.services.TenantContextHolder
import com.esg.models.User
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import javax.inject.Inject
import io.quarkus.test.junit.QuarkusTest
// QuarkusTest handles JUnit integration
import javax.transaction.Transactional
import java.util.UUID
import kotlin.test.assertTrue
import kotlin.test.assertFalse


@QuarkusTest
@Transactional
class SubmissionPolicyTest {

    @Inject
    private lateinit var submissionPolicy: SubmissionPolicy

    @Test
    fun `collector can view own submission within scope`() {
        val tenant = createTenant()
        val site = createSite(tenant.id)
        val period = createReportingPeriod(tenant.id, ReportingPeriodState.OPEN)
        val user = createUser()

        // Assign collector role with site scope
        createTenantUserRole(tenant.id, user.id, "collector")
        createTenantUserScope(tenant.id, user.id, site.id)

        val submission = createSubmission(
            tenantId = tenant.id,
            reportingPeriodId = period.id,
            siteId = site.id,
            createdBy = user.id
        )

        // Set up TenantContext
        val context = TenantContext(
            tenant = tenant,
            roles = listOf("collector"),
            siteIds = listOf(site.id),
            projectIds = emptyList()
        )
        TenantContextHolder.setContext(context)

        assertTrue(submissionPolicy.canView(user, submission))

        TenantContextHolder.clear()
    }

    @Test
    fun `approver cannot approve own submission`() {
        val tenant = createTenant()
        val period = createReportingPeriod(tenant.id, ReportingPeriodState.IN_REVIEW)
        val user = createUser()

        createTenantUserRole(tenant.id, user.id, "approver")

        val submission = createSubmission(
            tenantId = tenant.id,
            reportingPeriodId = period.id,
            createdBy = user.id
        )

        val context = TenantContext(
            tenant = tenant,
            roles = listOf("approver"),
            siteIds = emptyList(),
            projectIds = emptyList()
        )
        TenantContextHolder.setContext(context)

        // Should throw ForbiddenException due to SoD violation
        assertThrows<ForbiddenException> {
            submissionPolicy.canApprove(user, submission)
        }

        TenantContextHolder.clear()
    }

    // Helper functions
    private fun createTenant(): Tenant = Tenant(id = UUID.randomUUID(), name = "Test Tenant")
    private fun createSite(tenantId: UUID): Site = Site(id = UUID.randomUUID(), tenantId = tenantId)
    private fun createReportingPeriod(tenantId: UUID, state: ReportingPeriodState): ReportingPeriod =
        ReportingPeriod(id = UUID.randomUUID(), tenantId = tenantId, state = state)
    private fun createUser(): User = User(id = 1L, email = "test@example.com")
    private fun createSubmission(
        tenantId: UUID,
        reportingPeriodId: UUID,
        siteId: UUID? = null,
        createdBy: Long
    ): Submission = Submission(
        id = UUID.randomUUID(),
        tenantId = tenantId,
        reportingPeriodId = reportingPeriodId,
        siteId = siteId,
        createdBy = createdBy
    )
}

18.2 Integration Tests

@Test
fun `submission approval workflow`() {
    // Setup: collector creates, reviewer marks reviewed, approver approves
    // Assert: status transitions, audit events logged, SoD enforced
}

@Test
fun `locked period prevents edits`() {
    // Setup: period is LOCKED
    // Assert: all write operations fail
}

@Test
fun `break glass requires justification`() {
    // Setup: admin attempts to delete evidence without justification
    // Assert: authorization fails with ForbiddenException
}

Summary

This implementation guide provides:

  1. Policy-as-code: The RBAC Matrix YAML as the source of truth
  2. Layered security: Middleware → Policies → Gates → Scopes
  3. Tenant isolation: Hard boundary enforcement at every layer
  4. Workflow gates: Reporting period state controls
  5. SoD enforcement: Automated checks in policies
  6. Break-glass controls: Mandatory justification and audit trails
  7. Comprehensive audit: Append-only logs for all privileged actions

Next Steps

  1. Implement the database migrations
  2. Create model classes with tenant scopes
  3. Implement policies for all resources
  4. Register filters/interceptors in src/main/kotlin/config/SecurityConfig.kt
  5. Write comprehensive tests
  6. Deploy and monitor audit logs

Related Documentation: - Tenancy & Role Model - RBAC Matrix (YAML) - Backend Domain Models