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
- Overview
- Backend Architecture
- Core Implementation Concepts
- Tenant Context Resolution (Canonical)
- Fail-Closed Tenant Isolation
- Scope Semantics & Safety
- Reporting Period State Authority
- Break-Glass Access Model
- RBAC Matrix as Source of Truth
- Audit Logging Policy
- Evidence Security Policy
- Folder Structure
- Database Schema
- Code Implementations
- Middleware Stack
- Policy Patterns
- Usage in Controllers
- 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_idlevel - 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
- Policy-as-code: The RBAC Matrix YAML is the source of truth
- Layered security: Middleware → Policies → Gates → Model scopes
- Audit everything: Append-only logs for all privileged actions
- 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.
| 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
- Data export/migration: System-level operations
- Require
SYSTEMactor role (not bound to any tenant) - Must log all accessed tenant IDs
-
Should implement rate limiting
-
Consolidated reporting (multi-subsidiary groups):
- Must validate hierarchical tenant relationships (
parent_organisation_id) - Require explicit "group view" permission
- 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:
- Atomicity: Tenant context is resolved once per request, immutably
- Visibility: Tenant context is accessible to all authorization layers (middleware, policies, gates)
- Traceability: All audit events include resolved
tenant_idandroleat time of action - Isolation: Database queries automatically filter by
tenant_idvia 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:
- Implement automatic
tenant_idfiltering via global scope - Prevent scope bypass except via explicit, documented
withoutGlobalScope()usage - Log all scope bypasses at
MEDIUMseverity 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):
- 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 - Must include comment explaining business justification
-
Should execute in dedicated admin/system context
-
Data migration scripts:
- Must run outside request lifecycle (Quarkus CLI runners or scheduled tasks)
-
Must log all accessed tenant IDs
-
Platform admin panel:
- Separate route group without
RequireTenantContextmiddleware - 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:
-
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 } } -
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 } -
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):
-
Scope cannot reference non-existent resources:
-
Scope references must belong to same tenant:
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:
-
User initiates action with justification
-
Server validates authorization
- Check admin role
- Validate justification length and content
-
Check break-glass permission (if using database flag)
-
Server logs BEFORE execution
-
Server performs action
-
Server logs completion (optional second audit entry)
-
Server returns confirmation
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:
- Version controlled in the same repository as implementation code
- Location:
/docs/security/rbac-matrix.v1.yml -
Never committed without review
-
Reviewed before merge
- All changes require security-aware reviewer approval
-
Breaking changes require architecture review
-
Tagged with semantic versioning
- Major version: Breaking permission changes (e.g., removing permissions)
- Minor version: Additive changes (e.g., new roles, new actions)
-
Patch version: Documentation clarifications
-
Change-logged
- Document rationale for permission changes in commit messages
- 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:
- Manual Code Review (minimum requirement)
- Reviewer compares policy logic to YAML permissions during PR review
-
Use checklist: "Does this policy match rbac-matrix.v1.yml?"
-
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" ) } } -
Policy Linter (future enhancement)
- Static analysis tool to compare policy code against YAML
- 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:
- Proposal: Document permission change request
- Business justification
- Security impact assessment
-
Affected roles and resources
-
Security Review: Evaluate risk
- Principle of least privilege maintained?
- Segregation of duties preserved?
-
Compliance implications?
-
YAML Update: Modify rbac-matrix.v1.yml
- Update version number
- Add changelog entry
-
Commit with detailed message
-
Authorization Implementation: Update Quarkus Security authorization
- Implement YAML changes in relevant authorization components
- Write/update automated tests
-
Verify no unintended permission grants
-
Testing: Validate implementation
- Policy tests pass
- Integration tests pass
-
Manual security testing (negative cases)
-
Documentation: Update related docs
- Update this implementation guide if patterns change
- Update role descriptions if needed
-
Notify stakeholders of permission changes
-
Deployment: Release with approval
- Staging environment validation
- Production deployment
- 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.
Adding New Resources:
resources:
carbon_credit: # New resource type
actions:
read: { allow: [reviewer, approver, admin, auditor] }
create: { allow: [admin] }
retire: { allow: [approver, admin] }
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
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
CRITICALseverity event → Immediate Slack/email to security team - Multiple
HIGHseverity 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:
- Encryption at rest: Evidence files MUST be encrypted in storage
- AWS S3: Enable default encryption (AES-256)
-
Local filesystem: Use encrypted volumes (LUKS, BitLocker)
-
Access control: Storage bucket/volume MUST NOT be publicly accessible
- S3: Block public access, use pre-signed URLs for downloads
-
Local: Restrict file permissions to web server user only
-
Backup: Evidence storage MUST be backed up
- Retention: Match audit log retention (7 years minimum)
- Backup encryption: Required
-
Disaster recovery: Test restoration quarterly
-
Geographic storage: Consider data residency requirements
- EU tenants: Store evidence in EU region (GDPR compliance)
- 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
Pattern 2: Role Check
Pattern 3: Scope Constraint
val ctx = tenantContextHolder.getContext()
if (ctx.hasRole("collector")) {
return ctx.canAccessSite(resource.siteId)
}
Pattern 4: State Gate
Pattern 5: Ownership Check
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:
- Policy-as-code: The RBAC Matrix YAML as the source of truth
- Layered security: Middleware → Policies → Gates → Scopes
- Tenant isolation: Hard boundary enforcement at every layer
- Workflow gates: Reporting period state controls
- SoD enforcement: Automated checks in policies
- Break-glass controls: Mandatory justification and audit trails
- Comprehensive audit: Append-only logs for all privileged actions
Next Steps
- Implement the database migrations
- Create model classes with tenant scopes
- Implement policies for all resources
- Register filters/interceptors in
src/main/kotlin/config/SecurityConfig.kt - Write comprehensive tests
- Deploy and monitor audit logs
Related Documentation: - Tenancy & Role Model - RBAC Matrix (YAML) - Backend Domain Models