Skip to content

Laravel RBAC Implementation Guide

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

Table of Contents

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

1. Overview

This guide demonstrates how to implement the ESG platform's RBAC system in Laravel with:

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

Key Design Principles

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

2. Backend Architecture

2.1 Architecture Diagram

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

2.2 Security Layers

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

3. Core Implementation Concepts

3.1 Tenant Context (Mandatory)

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

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

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

3.2 Scope Constraints

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

Roles can be scoped to specific sites/projects:

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

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

3.3 Reporting Period State Gates

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

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

Reopening: Admin only, with break-glass justification.

3.4 Segregation of Duties (SoD)

Rule: A user cannot approve a submission they created.

// Enforced in SubmissionPolicy::approveItem()
if ($submission->created_by === auth()->id()) {
    throw new AuthorizationException('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 JWT alone does not grant tenant access.

4.2.2 Web Session Requests

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

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

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

4.2.3 CLI / Background Jobs

Input: Command-line parameter or job payload

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

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

4.3 Membership Validation Rules

All tenant context resolution MUST enforce:

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

4.4 Failure Behavior (Mandatory)

Principle: Fail closed. Ambiguity is denial.

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

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

4.5 Cross-Tenant Access

Default: Cross-tenant data access is prohibited.

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

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

  5. Consolidated reporting (multi-subsidiary groups):

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

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

4.6 Implementation Invariants

Implementations MUST guarantee:

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

4.7 Testing Requirements

Every implementation MUST include automated tests for:

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

5. Fail-Closed Tenant Isolation

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

5.1 Isolation Principle

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

Enforcement Method: Laravel global query scopes applied in model boot lifecycle.

5.2 Scope Application Requirements

Every model representing tenant-owned data MUST:

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

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

5.3 Global Scope Implementation Pattern

Canonical Implementation:

// In each tenant-scoped model (e.g., Submission, Evidence, Site)
protected static function booted(): void
{
    static::addGlobalScope('tenant', function (Builder $builder) {
        // Only apply scope if tenant context exists
        if (! app()->bound(TenantContext::class)) {
            // CRITICAL: Absence of tenant context MUST NOT silently succeed
            throw new \RuntimeException(
                'Tenant context not initialized. All tenant-scoped queries require valid tenant context.'
            );
        }

        $tenantId = app(TenantContext::class)->tenant->id;
        $builder->where($builder->getModel()->getTable() . '.tenant_id', $tenantId);
    });
}

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

5.4 Fail-Closed Behavior

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

5.5 Exceptional Scope Bypass

Permitted Cases (require explicit justification):

  1. System-level aggregations:
    // EXPLICIT: Aggregate across all tenants for platform metrics
    Submission::withoutGlobalScope('tenant')
        ->selectRaw('tenant_id, COUNT(*) as count')
        ->groupBy('tenant_id')
        ->get();
    
  2. Must include comment explaining business justification
  3. Should execute in dedicated admin/system context

  4. Data migration scripts:

  5. Must run outside request lifecycle (artisan commands)
  6. Must log all accessed tenant IDs

  7. Platform admin panel:

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

Security Rule: Every withoutGlobalScope('tenant') 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
    )
);

Eloquent Relationship Scoping:

// In Submission model
public function evidence(): HasMany
{
    return $this->hasMany(Evidence::class)
        ->where('evidence.tenant_id', $this->tenant_id);  // Explicit tenant match
}

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:

// app/Services/SystemLevelService.php
final class SystemAggregationService
{
    /**
     * Aggregate metrics across all tenants.
     *
     * SECURITY: This bypasses tenant isolation by design.
     * Authorization: Requires platform admin role.
     * Audit: Logs access to all tenant data.
     */
    public function aggregateMetrics(): array
    {
        // Log elevated access
        AuditEvent::logSystemAccess(
            actor: auth()->id(),
            action: 'system.aggregate_metrics',
            justification: 'Platform-wide analytics'
        );

        return Submission::withoutGlobalScope('tenant')
            ->selectRaw('tenant_id, COUNT(*) as total')
            ->groupBy('tenant_id')
            ->get()
            ->toArray();
    }
}

Documentation Requirement: All system-level services MUST include security notice in docblock 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
public function canAccessSite(?string $siteId): bool
{
    // Null site_id = tenant-level resource, always accessible
    if ($siteId === null) {
        return true;
    }

    // Empty scope array = unscoped = access all sites
    if (empty($this->siteIds)) {
        return true;
    }

    // Scoped: check membership
    return in_array($siteId, $this->siteIds, strict: true);
}

6.4 Roles and Default Scope Behavior

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

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

6.5 Scope Enforcement Points

Scope constraints MUST be checked in:

  1. Policy authorization:

    // In SubmissionPolicy::view()
    if ($ctx->hasRole('collector')) {
        return $ctx->canAccessSite($submission->site_id);
    }
    

  2. Query scopes (for list operations):

    // In SubmissionController::index()
    $query = Submission::query();  // Already filtered by tenant via global scope
    
    if ($ctx->hasRole('collector') && !empty($ctx->siteIds)) {
        $query->whereIn('site_id', $ctx->siteIds);
    }
    

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

    // When loading submissions with related evidence
    $submissions = Submission::with(['evidence' => function ($query) use ($ctx) {
        if ($ctx->hasRole('collector') && !empty($ctx->siteIds)) {
            $query->whereIn('site_id', $ctx->siteIds);
        }
    }])->get();
    

6.6 Database Constraints for Scope Integrity

Invariants (enforced at database level):

  1. Scope cannot reference non-existent resources:

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

  2. Scope references must belong to same tenant:

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

6.7 Scope Assignment Rules

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

Validation requirements:

// When creating scope assignment
public function assignScope(User $user, Tenant $tenant, array $siteIds): void
{
    // 1. Verify admin is performing assignment
    Gate::authorize('manage-scopes', $tenant);

    // 2. Verify all site IDs belong to tenant
    $validSites = Site::where('tenant_id', $tenant->id)
        ->whereIn('id', $siteIds)
        ->pluck('id');

    if ($validSites->count() !== count($siteIds)) {
        throw new ValidationException('Invalid site IDs for tenant');
    }

    // 3. Create scope records
    foreach ($siteIds as $siteId) {
        TenantUserScope::create([
            'tenant_id' => $tenant->id,
            'user_id' => $user->id,
            'site_id' => $siteId,
        ]);
    }

    // 4. Audit scope assignment
    AuditEvent::create([
        'tenant_id' => $tenant->id,
        'actor_id' => auth()->id(),
        'action' => 'scope.assigned',
        'object_type' => 'User',
        'object_id' => $user->id,
        'severity' => 'MEDIUM',
        'after' => ['site_ids' => $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
$periodState = request()->input('period_state');
if ($periodState === 'OPEN') {
    // Allow edit
}

Required Pattern:

// ✅ SECURE: Always load state from database
$period = ReportingPeriod::findOrFail($submission->reporting_period_id);
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
public function update(User $user, Submission $submission): bool
{
    // 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 !== 'draft') {
        return false;  // Submission already submitted
    }

    // 3. Check user authorization
    return (string)$submission->created_by === (string)$user->id;
}

7.4 Server-Side State Loading Requirements

All policies MUST load period state via Eloquent relationship:

// ✅ Correct: Eager load when needed
$submissions = Submission::with('reportingPeriod')->get();

foreach ($submissions as $submission) {
    if ($submission->reportingPeriod->state === ReportingPeriodState::OPEN) {
        // ...
    }
}

Performance Consideration: Use eager loading (with('reportingPeriod')) 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
public function approve(User $user, ReportingPeriod $period): Response
{
    $ctx = app(TenantContext::class);

    // 1. Verify current state allows approval
    if ($period->state !== ReportingPeriodState::IN_REVIEW) {
        return Response::deny('Period must be in review status to approve.');
    }

    // 2. Verify user has approver role
    if (!$ctx->hasRole('approver')) {
        return Response::deny('Only approvers can approve periods.');
    }

    // 3. Verify all submissions are reviewed
    $unreviewed = $period->submissions()->where('status', '!=', 'reviewed')->exists();
    if ($unreviewed) {
        return Response::deny('Cannot approve period with unreviewed submissions.');
    }

    return Response::allow();
}

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
AuditEvent::create([
    'tenant_id' => $period->tenant_id,
    'actor_id' => auth()->id(),
    'action' => 'reporting_period.state_transition',
    'object_type' => 'ReportingPeriod',
    'object_id' => $period->id,
    'severity' => 'HIGH',
    'before' => ['state' => $previousState->value],
    'after' => ['state' => $newState->value],
    'justification' => $request->input('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
if ($ctx->hasRole('admin')) {
    // Can manage sites, users, templates
}

// Break-glass requires additional validation
public function deleteEvidence(User $user, Evidence $evidence): Response
{
    // 1. Require admin role
    if (!app(TenantContext::class)->hasRole('admin')) {
        return Response::deny('Only admins can delete evidence.');
    }

    // 2. Require break-glass justification
    $justification = request()->input('justification');
    try {
        BreakGlass::requireJustification($justification);
    } catch (AuthorizationException $e) {
        return Response::deny($e->getMessage());
    }

    // 3. Log break-glass action (before execution)
    BreakGlass::logBreakGlassAction(
        tenantId: $evidence->tenant_id,
        actorId: $user->id,
        action: 'evidence.delete',
        objectType: 'Evidence',
        objectId: $evidence->id,
        justification: $justification
    );

    return Response::allow();
}

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 BreakGlass helper
public static function requireJustification(
    ?string $justification,
    int $minLength = 15
): void {
    $j = trim((string)$justification);

    if (mb_strlen($j) < $minLength) {
        throw new AuthorizationException(
            sprintf(
                'Break-glass action requires meaningful justification (minimum %d characters). ' .
                'Example: "Correcting data entry error in Q3 water metrics per CFO approval."',
                $minLength
            )
        );
    }
}

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
Schema::table('tenant_user_roles', function (Blueprint $table) {
    $table->boolean('break_glass_enabled')->default(false);
});

// Check in policy
$roleGrant = DB::table('tenant_user_roles')
    ->where('tenant_id', $tenantId)
    ->where('user_id', $userId)
    ->where('role', 'admin')
    ->where('break_glass_enabled', true)
    ->exists();

if (!$roleGrant) {
    return Response::deny('Break-glass permission not granted.');
}

Option 2: Separate Permission Table (granular control)

// Separate break_glass_permissions table
Schema::create('break_glass_permissions', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignUuid('tenant_id')->constrained();
    $table->foreignId('user_id')->constrained();
    $table->string('action');  // 'evidence.delete', 'period.reopen', etc.
    $table->timestamp('granted_at');
    $table->timestamp('expires_at')->nullable();
    $table->foreignId('granted_by')->constrained('users');
    $table->text('grant_justification');
});

Option 3: Laravel Gate (policy-driven)

// In AuthServiceProvider
Gate::define('break-glass', function (User $user, string $action) {
    $ctx = app(TenantContext::class);

    // Only admins can use break-glass
    if (!$ctx->hasRole('admin')) {
        return false;
    }

    // Check specific action permission
    return BreakGlassPermission::where('tenant_id', $ctx->tenant->id)
        ->where('user_id', $user->id)
        ->where('action', $action)
        ->where(function ($q) {
            $q->whereNull('expires_at')->orWhere('expires_at', '>', now());
        })
        ->exists();
});

8.7 Workflow for Break-Glass Actions

Recommended Process:

  1. User initiates action with justification

    DELETE /api/evidence/{id}
    {
        "justification": "Removing duplicate file uploaded in error..."
    }
    

  2. Server validates authorization

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

  6. Server logs BEFORE execution

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

  7. Server performs action

    $evidence->delete();
    

  8. Server logs completion (optional second audit entry)

    AuditEvent::create([
        'action' => 'evidence.delete.completed',
        'severity' => 'HIGH',
        // ...
    ]);
    

  9. Server returns confirmation

    return response()->json([
        'message' => 'Evidence deleted. Break-glass action logged.',
        'audit_id' => $auditEvent->id
    ]);
    

8.8 Alerting and Monitoring

Break-glass actions SHOULD trigger real-time alerts:

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

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

Example Monitoring Query:

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

8.9 Testing Requirements

Implementations MUST include automated tests for:

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

9. RBAC Matrix as Source of Truth

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

9.1 RBAC Matrix Role

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

Status: Policy-as-code. The YAML file defines what MUST be implemented in Laravel policies.

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

9.2 YAML Structure

The RBAC matrix defines:

version: 1  # Schema version for backwards compatibility

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

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

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

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

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

9.3 Version Control Requirements

The RBAC YAML MUST be:

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

  4. Reviewed before merge

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

  7. Tagged with semantic versioning

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

  11. Change-logged

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

Example commit message:

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

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

RBAC Matrix: v1.2.0 → v1.3.0

9.4 Synchronization with Laravel Policies

Critical Requirement: Laravel policy implementations MUST faithfully implement the permissions defined in the RBAC YAML.

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

Verification Methods:

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

  4. Automated Testing (recommended)

    // Example: Policy test derived from YAML
    public function test_submission_create_permissions_match_yaml()
    {
        // Parse YAML
        $yaml = Yaml::parseFile(base_path('docs/security/rbac-matrix.v1.yml'));
        $allowedRoles = $yaml['resources']['submission']['actions']['create']['allow'];
    
        // Test each role
        foreach (['collector', 'reviewer', 'approver', 'admin', 'auditor'] as $role) {
            $user = $this->createUserWithRole($role);
            $canCreate = $user->can('create', Submission::class);
    
            $shouldAllow = in_array($role, $allowedRoles);
            $this->assertEquals(
                $shouldAllow,
                $canCreate,
                "Role '$role' permission mismatch with YAML for submission.create"
            );
        }
    }
    

  5. Policy Linter (future enhancement)

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

9.5 YAML as Audit Evidence

The RBAC YAML serves as primary evidence for:

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

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

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

Usage in Audit:

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

9.6 Change Management Process

Workflow for RBAC Changes:

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

  5. Security Review: Evaluate risk

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

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

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

  13. Policy Implementation: Update Laravel policies

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

  17. Testing: Validate implementation

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

  21. Documentation: Update related docs

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

  25. Deployment: Release with approval

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

9.7 Extending the RBAC Matrix

Adding New Roles:

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

Adding New Resources:

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

Adding New Constraints:

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

9.8 RBAC Matrix Schema Validation

Recommended: Validate YAML schema in CI/CD pipeline.

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

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


10. Audit Logging Policy

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

10.1 Audit Logging Principle

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

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

10.2 Events Requiring Audit Logging

Mandatory Audit Events:

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

10.3 Audit Event Schema

Mandatory Fields (every audit event):

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

Conditional Fields:

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

10.4 Action Naming Convention

Format: <resource>.<action>

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

Consistency: Use past tense for completed actions.

10.5 Severity Classification

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

10.6 Audit Events for Break-Glass

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

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

Example:

AuditEvent::create([
    'tenant_id' => $period->tenant_id,
    'actor_id' => auth()->id(),
    'action' => 'reporting_period.reopened',
    'object_type' => 'ReportingPeriod',
    'object_id' => $period->id,
    'severity' => 'CRITICAL',
    'before' => ['state' => 'LOCKED'],
    'after' => ['state' => 'OPEN'],
    'justification' => 'Reopening to incorporate auditor-identified data correction per CFO approval',
    'ip_address' => request()->ip(),
    'user_agent' => request()->userAgent(),
]);

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:

AuditEvent::create([
    'tenant_id' => $evidence->tenant_id,
    'actor_id' => auth()->id(),
    'action' => 'evidence.downloaded',
    'object_type' => 'Evidence',
    'object_id' => $evidence->id,
    'severity' => 'MEDIUM',
    'metadata' => [
        'filename' => $evidence->filename,
        'size_bytes' => $evidence->size_bytes,
        'download_method' => 'direct_link',  // vs 'audit_pack'
    ],
]);

10.8 Audit Log Retention

Requirements:

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

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

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

10.9 Audit Log Access Controls

Who can read audit logs:

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

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

10.10 Monitoring and Alerting

Real-Time Alerts (recommended for production):

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

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


11. Evidence Security Policy

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

11.1 Evidence Security Principle

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

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

11.2 Evidence Access Control

Authorization Requirements:

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

Implementation:

// In EvidencePolicy
public function download(User $user, Evidence $evidence): bool
{
    $ctx = app(TenantContext::class);

    // Tenant boundary check
    if ($evidence->tenant_id !== $ctx->tenant->id) {
        return false;
    }

    // Role-based access
    if ($ctx->hasRole('collector')) {
        // Collectors limited to scoped evidence
        return $ctx->canAccessSite($evidence->site_id);
    }

    // Reviewers, approvers, admins, auditors have full access
    return $ctx->hasAnyRole(['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
$hash = hash_file('sha256', $uploadedFile->getRealPath());

Evidence::create([
    'filename' => $uploadedFile->getClientOriginalName(),
    'storage_path' => $storagePath,
    'hash_sha256' => $hash,
    'size_bytes' => $uploadedFile->getSize(),
    'mime_type' => $uploadedFile->getMimeType(),
    'uploaded_by' => auth()->id(),
    'tenant_id' => $tenantId,
]);

Verification on Download:

// Before serving file
$file = Storage::get($evidence->storage_path);
$computedHash = hash('sha256', $file);

if ($computedHash !== $evidence->hash_sha256) {
    // File corrupted or tampered
    AuditEvent::create([
        'action' => 'evidence.integrity_violation',
        'severity' => 'CRITICAL',
        'object_id' => $evidence->id,
    ]);

    abort(500, 'Evidence file integrity check failed');
}

11.4 Malware Scanning

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

Recommended Approach:

Option 1: Synchronous Scan (for small files)

use Xendit\MalwareScanner;  // Example library

public function upload(Request $request)
{
    $file = $request->file('evidence');

    // Scan before storage
    $scanResult = MalwareScanner::scan($file->getRealPath());

    if ($scanResult->isMalicious()) {
        AuditEvent::create([
            'action' => 'evidence.upload_blocked_malware',
            'severity' => 'HIGH',
            'metadata' => ['filename' => $file->getClientOriginalName()],
        ]);

        return response()->json([
            'error' => 'File upload blocked: potential malware detected'
        ], 422);
    }

    // Proceed with storage
    $evidence = $this->storeEvidence($file);
    return response()->json($evidence, 201);
}

Option 2: Asynchronous Scan (for large files)

// Upload to quarantine storage
$tempPath = $file->store('evidence/quarantine');

// Dispatch background job
ScanEvidenceJob::dispatch($evidenceId);

// Job implementation
class ScanEvidenceJob implements ShouldQueue
{
    public function handle()
    {
        $evidence = Evidence::find($this->evidenceId);
        $scanResult = MalwareScanner::scan($evidence->storage_path);

        if ($scanResult->isMalicious()) {
            // Delete file and mark evidence
            Storage::delete($evidence->storage_path);
            $evidence->update(['scan_status' => 'malware_detected']);

            // Alert admin
            Notification::send(...);
        } else {
            // Move to production storage
            $evidence->update(['scan_status' => 'clean']);
        }
    }
}

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

11.5 Storage Security

Requirements:

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

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

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

  7. Backup: Evidence storage MUST be backed up

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

  11. Geographic storage: Consider data residency requirements

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

Example (AWS S3):

// Store with server-side encryption
Storage::disk('s3')->put($path, $fileContents, [
    'visibility' => 'private',
    'ServerSideEncryption' => 'AES256',
    'Metadata' => [
        'tenant-id' => $tenantId,
        'uploaded-by' => auth()->id(),
    ],
]);

// Generate time-limited download URL
$url = Storage::disk('s3')->temporaryUrl($evidence->storage_path, now()->addMinutes(5));

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
$request->validate([
    'evidence' => [
        'required',
        'file',
        'max:10240',  // 10MB max
        'mimes:pdf,jpg,jpeg,png,xlsx,csv',
    ],
]);

11.7 Evidence Download Audit

Requirement: Every evidence file download MUST be logged.

Audit Event:

// On evidence download
AuditEvent::create([
    'tenant_id' => $evidence->tenant_id,
    'actor_id' => auth()->id(),
    'action' => 'evidence.downloaded',
    'object_type' => 'Evidence',
    'object_id' => $evidence->id,
    'severity' => 'MEDIUM',
    'metadata' => [
        'filename' => $evidence->filename,
        'size_bytes' => $evidence->size_bytes,
        'hash_sha256' => $evidence->hash_sha256,
    ],
    'ip_address' => request()->ip(),
]);

// Serve file
return Storage::download($evidence->storage_path, $evidence->filename);

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 EvidencePolicy::delete()
BreakGlass::requireJustification($request->input('justification'));

BreakGlass::logBreakGlassAction(
    tenantId: $evidence->tenant_id,
    actorId: auth()->id(),
    action: 'evidence.deleted',
    objectType: 'Evidence',
    objectId: $evidence->id,
    justification: $request->input('justification')
);

// Soft delete (preserves metadata)
$evidence->delete();

// Optionally: move file to quarantine rather than deleting
Storage::move(
    $evidence->storage_path,
    "evidence/deleted/{$evidence->id}/{$evidence->filename}"
);


12. Folder Structure

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

Recommended Laravel 10/11 project structure:

esg-backend/
├── app/
│   ├── Domain/
│   │   ├── Tenancy/
│   │   │   ├── Models/
│   │   │   │   ├── Tenant.php
│   │   │   │   ├── Site.php
│   │   │   │   ├── Project.php
│   │   │   │   └── ReportingPeriod.php
│   │   │   ├── Enums/
│   │   │   │   └── ReportingPeriodState.php
│   │   │   └── Services/
│   │   │       ├── TenantContext.php
│   │   │       └── ScopeResolver.php
│   │   └── ESG/
│   │       ├── Models/
│   │       │   ├── Submission.php
│   │       │   ├── Evidence.php
│   │       │   ├── Template.php
│   │       │   ├── Comment.php
│   │       │   └── AuditEvent.php
│   │       └── Services/
│   │           ├── ReportingStateGuard.php
│   │           ├── SubmissionWorkflow.php
│   │           └── EvidenceIntegrity.php
│   ├── Policies/
│   │   ├── TenantPolicy.php
│   │   ├── SitePolicy.php
│   │   ├── ProjectPolicy.php
│   │   ├── ReportingPeriodPolicy.php
│   │   ├── TemplatePolicy.php
│   │   ├── SubmissionPolicy.php
│   │   ├── EvidencePolicy.php
│   │   ├── CommentPolicy.php
│   │   ├── AuditLogPolicy.php
│   │   ├── ReportPolicy.php
│   │   └── ExportPolicy.php
│   ├── Support/
│   │   ├── Auth/
│   │   │   ├── Roles.php              # Role constants/enum
│   │   │   ├── Permissions.php        # Permission helpers
│   │   │   └── SoD.php                # Segregation of duties helpers
│   │   └── BreakGlass/
│   │       └── BreakGlass.php         # Break-glass validation
│   └── Http/
│       └── Middleware/
│           ├── RequireTenantContext.php
│           ├── EnforceTenantBoundary.php
│           ├── EnforceScope.php
│           ├── EnforceReportingPeriodState.php
│           └── EnforceBreakGlass.php
├── database/
│   └── migrations/
│       ├── 2024_01_01_create_tenants_table.php
│       ├── 2024_01_02_create_sites_table.php
│       ├── 2024_01_03_create_projects_table.php
│       ├── 2024_01_04_create_reporting_periods_table.php
│       ├── 2024_01_05_create_tenant_user_roles_table.php
│       ├── 2024_01_06_create_tenant_user_scopes_table.php
│       ├── 2024_01_07_create_submissions_table.php
│       ├── 2024_01_08_create_evidence_table.php
│       └── 2024_01_09_create_audit_events_table.php
└── routes/
    └── api.php

13. Database Schema

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

13.1 Core Tables

Tenants

Schema::create('tenants', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('name');
    $table->string('country_code', 2);
    $table->string('company_number')->nullable();
    $table->json('frameworks_enabled')->default('["GRI"]');
    $table->string('timezone')->default('UTC');
    $table->timestamps();
    $table->softDeletes();
});

Sites

Schema::create('sites', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignUuid('tenant_id')->constrained()->cascadeOnDelete();
    $table->string('name');
    $table->string('country_code', 2);
    $table->string('operational_type'); // manufacturing, office, etc.
    $table->boolean('is_active')->default(true);
    $table->timestamps();

    $table->index(['tenant_id', 'is_active']);
});

Reporting Periods

Schema::create('reporting_periods', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignUuid('tenant_id')->constrained()->cascadeOnDelete();
    $table->string('name'); // e.g., "FY2024"
    $table->date('start_date');
    $table->date('end_date');
    $table->enum('state', ['OPEN', 'IN_REVIEW', 'APPROVED', 'LOCKED'])
          ->default('OPEN');
    $table->timestamps();

    $table->index(['tenant_id', 'state']);
});

13.2 RBAC Tables

Tenant User Roles

Schema::create('tenant_user_roles', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignUuid('tenant_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('role'); // collector, reviewer, approver, admin, auditor
    $table->timestamp('expires_at')->nullable();
    $table->timestamps();

    $table->unique(['tenant_id', 'user_id', 'role']);
    $table->index(['tenant_id', 'user_id']);
});

Tenant User Scopes

Schema::create('tenant_user_scopes', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignUuid('tenant_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->foreignUuid('site_id')->nullable()->constrained()->cascadeOnDelete();
    $table->foreignUuid('project_id')->nullable()->constrained()->cascadeOnDelete();
    $table->timestamps();

    $table->index(['tenant_id', 'user_id']);
    $table->index(['tenant_id', 'user_id', 'site_id']);
});

13.3 ESG Data Tables

Submissions

Schema::create('submissions', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignUuid('tenant_id')->constrained()->cascadeOnDelete();
    $table->foreignUuid('reporting_period_id')->constrained()->cascadeOnDelete();
    $table->foreignUuid('site_id')->nullable()->constrained()->cascadeOnDelete();
    $table->foreignUuid('template_id')->constrained()->cascadeOnDelete();
    $table->foreignId('created_by')->constrained('users');
    $table->enum('status', ['draft', 'submitted', 'in_review', 'approved', 'rejected'])
          ->default('draft');
    $table->json('data');
    $table->timestamps();
    $table->softDeletes();

    $table->index(['tenant_id', 'reporting_period_id', 'status']);
    $table->index(['tenant_id', 'site_id']);
    $table->index(['created_by']);
});

Evidence

Schema::create('evidence', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignUuid('tenant_id')->constrained()->cascadeOnDelete();
    $table->foreignUuid('submission_id')->nullable()->constrained()->cascadeOnDelete();
    $table->foreignUuid('reporting_period_id')->constrained()->cascadeOnDelete();
    $table->foreignUuid('site_id')->nullable()->constrained()->cascadeOnDelete();
    $table->foreignId('uploaded_by')->constrained('users');
    $table->string('filename');
    $table->string('mime_type');
    $table->string('storage_path');
    $table->string('hash_sha256', 64);
    $table->unsignedBigInteger('size_bytes');
    $table->json('tags')->nullable();
    $table->timestamps();
    $table->softDeletes();

    $table->index(['tenant_id', 'reporting_period_id']);
    $table->index(['tenant_id', 'site_id']);
    $table->index(['hash_sha256']);
});

13.4 Audit Table

Schema::create('audit_events', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignUuid('tenant_id')->constrained()->cascadeOnDelete();
    $table->foreignId('actor_id')->constrained('users');
    $table->string('action'); // e.g., 'submission.approved'
    $table->string('object_type'); // e.g., 'Submission'
    $table->uuid('object_id');
    $table->enum('severity', ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'])->default('LOW');
    $table->json('before')->nullable();
    $table->json('after')->nullable();
    $table->text('justification')->nullable(); // for break-glass
    $table->ipAddress('ip_address')->nullable();
    $table->string('user_agent')->nullable();
    $table->timestamp('created_at');

    $table->index(['tenant_id', 'created_at']);
    $table->index(['actor_id', 'created_at']);
    $table->index(['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: app/Domain/Tenancy/Enums/ReportingPeriodState.php

<?php

namespace App\Domain\Tenancy\Enums;

enum ReportingPeriodState: string
{
    case OPEN = 'OPEN';
    case IN_REVIEW = 'IN_REVIEW';
    case APPROVED = 'APPROVED';
    case LOCKED = 'LOCKED';

    public function isWritable(): bool
    {
        return in_array($this, [self::OPEN, self::IN_REVIEW], true);
    }

    public function allowsDataEntry(): bool
    {
        return $this === self::OPEN;
    }

    public function allowsReview(): bool
    {
        return $this === self::IN_REVIEW;
    }

    public function allowsApproval(): bool
    {
        return $this === self::IN_REVIEW;
    }

    public function isLocked(): bool
    {
        return $this === self::LOCKED;
    }
}

14.2 TenantContext Service

File: app/Domain/Tenancy/Services/TenantContext.php

<?php

namespace App\Domain\Tenancy\Services;

use App\Domain\Tenancy\Models\Tenant;

final class TenantContext
{
    public function __construct(
        public readonly Tenant $tenant,
        public readonly array $roles,        // e.g., ['collector', 'reviewer']
        public readonly array $siteIds,      // allowed site UUIDs
        public readonly array $projectIds,   // allowed project UUIDs
    ) {}

    public function hasRole(string $role): bool
    {
        return in_array($role, $this->roles, true);
    }

    public function hasAnyRole(array $roles): bool
    {
        return !empty(array_intersect($roles, $this->roles));
    }

    public function canAccessSite(?string $siteId): bool
    {
        if ($siteId === null) {
            return true; // tenant-level resource
        }

        // Empty scopes = access to all sites
        if (empty($this->siteIds)) {
            return true;
        }

        return in_array($siteId, $this->siteIds, true);
    }

    public function canAccessProject(?string $projectId): bool
    {
        if ($projectId === null) {
            return true;
        }

        if (empty($this->projectIds)) {
            return true;
        }

        return in_array($projectId, $this->projectIds, true);
    }
}

14.3 SoD (Segregation of Duties) Helper

File: app/Support/Auth/SoD.php

<?php

namespace App\Support\Auth;

use Illuminate\Auth\Access\AuthorizationException;

final class SoD
{
    /**
     * Deny self-approval: the user cannot approve their own submission.
     *
     * @throws AuthorizationException
     */
    public static function denySelfApproval(int|string|null $createdBy, int|string $actorId): void
    {
        if ($createdBy !== null && (string)$createdBy === (string)$actorId) {
            throw new AuthorizationException(
                'Segregation of duties violation: You cannot approve your own submission.'
            );
        }
    }

    /**
     * Optionally deny self-review.
     *
     * @throws AuthorizationException
     */
    public static function denySelfReview(int|string|null $createdBy, int|string $actorId): void
    {
        if ($createdBy !== null && (string)$createdBy === (string)$actorId) {
            throw new AuthorizationException(
                'Segregation of duties violation: You cannot review your own submission.'
            );
        }
    }
}

14.4 Break-Glass Helper

File: app/Support/BreakGlass/BreakGlass.php

<?php

namespace App\Support\BreakGlass;

use Illuminate\Auth\Access\AuthorizationException;

final class BreakGlass
{
    private const MIN_JUSTIFICATION_LENGTH = 15;

    /**
     * Require a valid justification for break-glass actions.
     *
     * @throws AuthorizationException
     */
    public static function requireJustification(?string $justification): void
    {
        $j = trim((string)$justification);

        if (mb_strlen($j) < self::MIN_JUSTIFICATION_LENGTH) {
            throw new AuthorizationException(
                sprintf(
                    'Break-glass action requires a clear justification (minimum %d characters).',
                    self::MIN_JUSTIFICATION_LENGTH
                )
            );
        }
    }

    /**
     * Log a break-glass action to the audit trail.
     */
    public static function logBreakGlassAction(
        string $tenantId,
        int $actorId,
        string $action,
        string $objectType,
        string $objectId,
        string $justification
    ): void {
        \App\Domain\ESG\Models\AuditEvent::create([
            'tenant_id' => $tenantId,
            'actor_id' => $actorId,
            'action' => $action,
            'object_type' => $objectType,
            'object_id' => $objectId,
            'severity' => 'HIGH',
            'justification' => $justification,
            'ip_address' => request()->ip(),
            'user_agent' => request()->userAgent(),
        ]);
    }
}

14.5 SubmissionPolicy (Complete Example)

File: app/Policies/SubmissionPolicy.php

<?php

namespace App\Policies;

use App\Domain\ESG\Models\Submission;
use App\Domain\Tenancy\Enums\ReportingPeriodState;
use App\Domain\Tenancy\Services\TenantContext;
use App\Models\User;
use App\Support\Auth\SoD;
use Illuminate\Auth\Access\Response;

final class SubmissionPolicy
{
    /**
     * Resolve tenant context from the container.
     */
    private function context(): TenantContext
    {
        return app(TenantContext::class);
    }

    /**
     * View a submission.
     */
    public function view(User $user, Submission $submission): bool
    {
        $ctx = $this->context();

        // Must be within the same tenant
        if ($submission->tenant_id !== $ctx->tenant->id) {
            return false;
        }

        // Collectors: must be within scope
        if ($ctx->hasRole('collector')) {
            return $ctx->canAccessSite($submission->site_id);
        }

        // Reviewer, approver, admin, auditor can read within tenant
        return $ctx->hasAnyRole(['reviewer', 'approver', 'admin', 'auditor']);
    }

    /**
     * Create a new submission.
     */
    public function create(User $user): bool
    {
        $ctx = $this->context();

        // Determine the reporting period state from request context
        // In practice, you'd inject or validate this in the controller
        $periodState = request()->input('period_state'); // simplified

        if ($periodState !== ReportingPeriodState::OPEN->value) {
            return false;
        }

        // Admin can always create
        if ($ctx->hasRole('admin')) {
            return true;
        }

        // Collector can create if within scope
        if ($ctx->hasRole('collector')) {
            $siteId = request()->input('site_id');
            return $ctx->canAccessSite($siteId);
        }

        return false;
    }

    /**
     * Update a submission.
     */
    public function update(User $user, Submission $submission): bool
    {
        $ctx = $this->context();

        // Must be within the same tenant
        if ($submission->tenant_id !== $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 (string)$submission->created_by === (string)$user->id
                && $ctx->canAccessSite($submission->site_id);
        }

        return false;
    }

    /**
     * Delete a draft submission.
     */
    public function deleteDraft(User $user, Submission $submission): bool
    {
        $ctx = $this->context();

        if ($submission->tenant_id !== $ctx->tenant->id) {
            return false;
        }

        if ($submission->status !== '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 (string)$submission->created_by === (string)$user->id;
        }

        return false;
    }

    /**
     * Submit for review.
     */
    public function submit(User $user, Submission $submission): bool
    {
        $ctx = $this->context();

        if ($submission->tenant_id !== $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 (string)$submission->created_by === (string)$user->id
                && $ctx->canAccessSite($submission->site_id);
        }

        return false;
    }

    /**
     * Return submission to collector.
     */
    public function returnToCollector(User $user, Submission $submission): bool
    {
        $ctx = $this->context();

        if ($submission->tenant_id !== $ctx->tenant->id) {
            return false;
        }

        if (!$submission->reportingPeriod->state->allowsReview()) {
            return false;
        }

        return $ctx->hasAnyRole(['reviewer', 'admin']);
    }

    /**
     * Mark as reviewed.
     */
    public function markReviewed(User $user, Submission $submission): bool
    {
        $ctx = $this->context();

        if ($submission->tenant_id !== $ctx->tenant->id) {
            return false;
        }

        if (!$submission->reportingPeriod->state->allowsReview()) {
            return false;
        }

        return $ctx->hasAnyRole(['reviewer', 'admin']);
    }

    /**
     * Approve a submission (SoD enforced).
     */
    public function approveItem(User $user, Submission $submission): Response
    {
        $ctx = $this->context();

        if ($submission->tenant_id !== $ctx->tenant->id) {
            return Response::deny('Not authorized for this tenant.');
        }

        if (!$submission->reportingPeriod->state->allowsApproval()) {
            return Response::deny('Reporting period is not in a state that allows approval.');
        }

        if (!$ctx->hasRole('approver')) {
            return Response::deny('Only approvers can approve submissions.');
        }

        // Segregation of duties: deny self-approval
        try {
            SoD::denySelfApproval($submission->created_by, $user->id);
        } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
            return Response::deny($e->getMessage());
        }

        return Response::allow();
    }
}

14.6 EvidencePolicy

File: app/Policies/EvidencePolicy.php

<?php

namespace App\Policies;

use App\Domain\ESG\Models\Evidence;
use App\Domain\Tenancy\Services\TenantContext;
use App\Models\User;
use App\Support\BreakGlass\BreakGlass;
use Illuminate\Auth\Access\Response;

final class EvidencePolicy
{
    private function context(): TenantContext
    {
        return app(TenantContext::class);
    }

    public function view(User $user, Evidence $evidence): bool
    {
        $ctx = $this->context();

        if ($evidence->tenant_id !== $ctx->tenant->id) {
            return false;
        }

        if ($ctx->hasRole('collector')) {
            return $ctx->canAccessSite($evidence->site_id);
        }

        return $ctx->hasAnyRole(['reviewer', 'approver', 'admin', 'auditor']);
    }

    public function upload(User $user): bool
    {
        $ctx = $this->context();

        // Simplified: assume period state is passed in request
        $periodState = request()->input('period_state');

        if ($periodState !== 'OPEN') {
            return false;
        }

        return $ctx->hasAnyRole(['collector', 'admin']);
    }

    public function updateTags(User $user, Evidence $evidence): bool
    {
        $ctx = $this->context();

        if ($evidence->tenant_id !== $ctx->tenant->id) {
            return false;
        }

        // Allow metadata updates during OPEN and IN_REVIEW
        $periodState = $evidence->reportingPeriod->state->value;
        if (!in_array($periodState, ['OPEN', 'IN_REVIEW'], true)) {
            return false;
        }

        return $ctx->hasAnyRole(['collector', 'reviewer', 'admin']);
    }

    public function delete(User $user, Evidence $evidence): Response
    {
        $ctx = $this->context();

        if ($evidence->tenant_id !== $ctx->tenant->id) {
            return Response::deny('Not authorized for this tenant.');
        }

        if (!$ctx->hasRole('admin')) {
            return Response::deny('Only admins can delete evidence.');
        }

        // Require break-glass justification
        $justification = request()->input('justification');

        try {
            BreakGlass::requireJustification($justification);
        } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
            return Response::deny($e->getMessage());
        }

        // Log the break-glass action
        BreakGlass::logBreakGlassAction(
            tenantId: $ctx->tenant->id,
            actorId: $user->id,
            action: 'evidence.delete',
            objectType: 'Evidence',
            objectId: $evidence->id,
            justification: $justification
        );

        return Response::allow();
    }
}

15. Middleware Stack

15.1 RequireTenantContext

File: app/Http/Middleware/RequireTenantContext.php

<?php

namespace App\Http\Middleware;

use App\Domain\Tenancy\Models\Tenant;
use App\Domain\Tenancy\Services\TenantContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class RequireTenantContext
{
    public function handle(Request $request, Closure $next): Response
    {
        $tenantId = $request->header('X-Tenant-Id') ?? $request->input('tenant_id');

        if (!$tenantId) {
            return response()->json(['error' => 'Tenant ID is required'], 400);
        }

        $tenant = Tenant::find($tenantId);

        if (!$tenant) {
            return response()->json(['error' => 'Invalid tenant'], 404);
        }

        // Verify user has access to this tenant
        $user = $request->user();

        if (!$user) {
            return response()->json(['error' => 'Unauthenticated'], 401);
        }

        // Fetch user's roles and scopes for this tenant
        $roles = \DB::table('tenant_user_roles')
            ->where('tenant_id', $tenantId)
            ->where('user_id', $user->id)
            ->where(function ($q) {
                $q->whereNull('expires_at')
                  ->orWhere('expires_at', '>', now());
            })
            ->pluck('role')
            ->toArray();

        if (empty($roles)) {
            return response()->json(['error' => 'Not authorized for this tenant'], 403);
        }

        // Fetch scopes
        $scopes = \DB::table('tenant_user_scopes')
            ->where('tenant_id', $tenantId)
            ->where('user_id', $user->id)
            ->get();

        $siteIds = $scopes->pluck('site_id')->filter()->unique()->values()->toArray();
        $projectIds = $scopes->pluck('project_id')->filter()->unique()->values()->toArray();

        // Bind TenantContext to the container for this request
        $context = new TenantContext(
            tenant: $tenant,
            roles: $roles,
            siteIds: $siteIds,
            projectIds: $projectIds,
        );

        app()->instance(TenantContext::class, $context);

        return $next($request);
    }
}

15.2 EnforceTenantBoundary

File: app/Http/Middleware/EnforceTenantBoundary.php

<?php

namespace App\Http\Middleware;

use App\Domain\Tenancy\Services\TenantContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnforceTenantBoundary
{
    public function handle(Request $request, Closure $next): Response
    {
        $context = app(TenantContext::class);

        // Apply global scope to all tenant-owned models
        // This ensures queries are automatically filtered by tenant_id

        // Example: use a global scope or apply it in model boot methods
        // See: https://laravel.com/docs/10.x/eloquent#global-scopes

        return $next($request);
    }
}

Note: In practice, you'd implement global scopes on your models:

// Example in Submission model
protected static function booted()
{
    static::addGlobalScope('tenant', function (Builder $builder) {
        if (app()->has(TenantContext::class)) {
            $builder->where('tenant_id', app(TenantContext::class)->tenant->id);
        }
    });
}

16. Policy Patterns

16.1 Policy Registration

File: app/Providers/AuthServiceProvider.php

<?php

namespace App\Providers;

use App\Domain\ESG\Models\Evidence;
use App\Domain\ESG\Models\Submission;
use App\Domain\Tenancy\Models\ReportingPeriod;
use App\Domain\Tenancy\Models\Site;
use App\Policies\EvidencePolicy;
use App\Policies\ReportingPeriodPolicy;
use App\Policies\SitePolicy;
use App\Policies\SubmissionPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;

class AuthServiceProvider extends ServiceProvider
{
    protected $policies = [
        Submission::class => SubmissionPolicy::class,
        Evidence::class => EvidencePolicy::class,
        Site::class => SitePolicy::class,
        ReportingPeriod::class => ReportingPeriodPolicy::class,
        // ... other policies
    ];

    public function boot(): void
    {
        $this->registerPolicies();
    }
}

16.2 Common Policy Patterns

Pattern 1: Tenant Boundary Check

if ($resource->tenant_id !== $this->context()->tenant->id) {
    return false;
}

Pattern 2: Role Check

if (!$this->context()->hasRole('admin')) {
    return false;
}

Pattern 3: Scope Constraint

if ($this->context()->hasRole('collector')) {
    return $this->context()->canAccessSite($resource->site_id);
}

Pattern 4: State Gate

if (!$resource->reportingPeriod->state->allowsDataEntry()) {
    return false;
}

Pattern 5: Ownership Check

if ((string)$resource->created_by !== (string)$user->id) {
    return false;
}

17. Usage in Controllers

17.1 Standard Authorization

<?php

namespace App\Http\Controllers\Api;

use App\Domain\ESG\Models\Submission;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class SubmissionController extends Controller
{
    public function show(Submission $submission)
    {
        $this->authorize('view', $submission);

        return response()->json($submission);
    }

    public function store(Request $request)
    {
        $this->authorize('create', Submission::class);

        $validated = $request->validate([
            'reporting_period_id' => 'required|uuid|exists:reporting_periods,id',
            'site_id' => 'nullable|uuid|exists:sites,id',
            'template_id' => 'required|uuid|exists:templates,id',
            'data' => 'required|array',
        ]);

        $ctx = app(\App\Domain\Tenancy\Services\TenantContext::class);

        $submission = Submission::create([
            'tenant_id' => $ctx->tenant->id,
            'reporting_period_id' => $validated['reporting_period_id'],
            'site_id' => $validated['site_id'] ?? null,
            'template_id' => $validated['template_id'],
            'created_by' => auth()->id(),
            'data' => $validated['data'],
        ]);

        return response()->json($submission, 201);
    }

    public function update(Request $request, Submission $submission)
    {
        $this->authorize('update', $submission);

        $validated = $request->validate([
            'data' => 'required|array',
        ]);

        $submission->update($validated);

        return response()->json($submission);
    }

    public function approve(Submission $submission)
    {
        $response = $this->authorize('approveItem', $submission);

        $submission->update(['status' => 'approved']);

        // Log audit event
        \App\Domain\ESG\Models\AuditEvent::create([
            'tenant_id' => $submission->tenant_id,
            'actor_id' => auth()->id(),
            'action' => 'submission.approved',
            'object_type' => 'Submission',
            'object_id' => $submission->id,
            'severity' => 'MEDIUM',
            'after' => ['status' => 'approved'],
        ]);

        return response()->json($submission);
    }
}

17.2 Break-Glass Action Example

public function deleteEvidence(Request $request, Evidence $evidence)
{
    $request->validate([
        'justification' => 'required|string|min:15',
    ]);

    // This will internally check break-glass requirements
    $this->authorize('delete', $evidence);

    $evidence->delete();

    return response()->json(['message' => 'Evidence deleted'], 200);
}

18. Testing Strategies

18.1 Policy Tests

File: tests/Feature/Policies/SubmissionPolicyTest.php

<?php

namespace Tests\Feature\Policies;

use App\Domain\ESG\Models\Submission;
use App\Domain\Tenancy\Enums\ReportingPeriodState;
use App\Domain\Tenancy\Models\ReportingPeriod;
use App\Domain\Tenancy\Models\Site;
use App\Domain\Tenancy\Models\Tenant;
use App\Domain\Tenancy\Services\TenantContext;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class SubmissionPolicyTest extends TestCase
{
    use RefreshDatabase;

    public function test_collector_can_view_own_submission_within_scope()
    {
        $tenant = Tenant::factory()->create();
        $site = Site::factory()->create(['tenant_id' => $tenant->id]);
        $period = ReportingPeriod::factory()->create([
            'tenant_id' => $tenant->id,
            'state' => ReportingPeriodState::OPEN,
        ]);

        $user = User::factory()->create();

        // Assign collector role with site scope
        \DB::table('tenant_user_roles')->insert([
            'id' => \Str::uuid(),
            'tenant_id' => $tenant->id,
            'user_id' => $user->id,
            'role' => 'collector',
        ]);

        \DB::table('tenant_user_scopes')->insert([
            'id' => \Str::uuid(),
            'tenant_id' => $tenant->id,
            'user_id' => $user->id,
            'site_id' => $site->id,
        ]);

        $submission = Submission::factory()->create([
            'tenant_id' => $tenant->id,
            'reporting_period_id' => $period->id,
            'site_id' => $site->id,
            'created_by' => $user->id,
        ]);

        // Mock TenantContext
        $context = new TenantContext(
            tenant: $tenant,
            roles: ['collector'],
            siteIds: [$site->id],
            projectIds: [],
        );
        $this->app->instance(TenantContext::class, $context);

        $this->actingAs($user);

        $this->assertTrue($user->can('view', $submission));
    }

    public function test_approver_cannot_approve_own_submission()
    {
        $tenant = Tenant::factory()->create();
        $period = ReportingPeriod::factory()->create([
            'tenant_id' => $tenant->id,
            'state' => ReportingPeriodState::IN_REVIEW,
        ]);

        $user = User::factory()->create();

        \DB::table('tenant_user_roles')->insert([
            'id' => \Str::uuid(),
            'tenant_id' => $tenant->id,
            'user_id' => $user->id,
            'role' => 'approver',
        ]);

        $submission = Submission::factory()->create([
            'tenant_id' => $tenant->id,
            'reporting_period_id' => $period->id,
            'created_by' => $user->id,
        ]);

        $context = new TenantContext(
            tenant: $tenant,
            roles: ['approver'],
            siteIds: [],
            projectIds: [],
        );
        $this->app->instance(TenantContext::class, $context);

        $this->actingAs($user);

        $this->assertFalse($user->can('approveItem', $submission));
    }
}

18.2 Integration Tests

public function test_submission_approval_workflow()
{
    // Setup: collector creates, reviewer marks reviewed, approver approves
    // Assert: status transitions, audit events logged, SoD enforced
}

public function test_locked_period_prevents_edits()
{
    // Setup: period is LOCKED
    // Assert: all write operations fail
}

public function test_break_glass_requires_justification()
{
    // Setup: admin attempts to delete evidence without justification
    // Assert: authorization fails
}

Summary

This implementation guide provides:

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

Next Steps

  1. Implement the database migrations
  2. Create model classes with tenant scopes
  3. Implement policies for all resources
  4. Register middleware in app/Http/Kernel.php
  5. Write comprehensive tests
  6. Deploy and monitor audit logs

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