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
- Overview
- Backend Architecture
- Core Implementation Concepts
- Tenant Context Resolution (Canonical)
- Fail-Closed Tenant Isolation
- Scope Semantics & Safety
- Reporting Period State Authority
- Break-Glass Access Model
- RBAC Matrix as Source of Truth
- Audit Logging Policy
- Evidence Security Policy
- Folder Structure
- Database Schema
- Code Implementations
- Middleware Stack
- Policy Patterns
- Usage in Controllers
- Testing Strategies
1. Overview
This guide demonstrates how to implement the ESG platform's RBAC system in Laravel with:
- Tenant isolation: Hard boundary at
tenant_idlevel - Scope constraints: Site/project-level access control for collectors
- Reporting period state gates: Workflow-based permissions (OPEN → IN_REVIEW → APPROVED → LOCKED)
- Segregation of duties (SoD): No self-approval enforcement
- Break-glass actions: Admin overrides with mandatory justification and audit trails
Key Design Principles
- Policy-as-code: The RBAC Matrix YAML is the source of truth
- Layered security: Middleware → Policies → Gates → Model scopes
- Audit everything: Append-only logs for all privileged actions
- Fail secure: Default deny; explicit allow
2. Backend Architecture
2.1 Architecture Diagram
Request
↓
Middleware Stack
├─ RequireTenantContext (set tenant from header/session)
├─ EnforceTenantBoundary (validate user tenant membership)
├─ EnforceScope (check site/project constraints)
└─ EnforceReportingPeriodState (validate period state for writes)
↓
Controller
↓
authorize() → Policy
├─ TenantContext check
├─ Role membership check
├─ Scope constraint check
├─ State gate check
└─ SoD / Break-glass checks
↓
Service / Repository
↓
Model (with tenant scope)
↓
Database
2.2 Security Layers
| Layer | Responsibility | Implementation |
|---|---|---|
| Middleware | Request-level enforcement | Tenant context, boundary validation |
| Policies | Action-level authorization | Role + scope + state checks |
| Gates | Custom permission logic | Complex SoD and break-glass rules |
| Model Scopes | Query-level isolation | Automatic tenant_id filtering |
| Audit Events | Traceability | Append-only logs on every write |
3. Core Implementation Concepts
3.1 Tenant Context (Mandatory)
⚠️ Simplified Summary: See Section 4: Tenant Context Resolution (Canonical) for the authoritative specification.
Every request must establish a tenant context containing: - The active tenant - User's roles in that tenant - Scoped site/project IDs
How it's set:
- API: X-Tenant-Id header
- Web: Session-based tenant selection
- CLI: Command parameter
3.2 Scope Constraints
⚠️ Simplified Example: See Section 6: Scope Semantics & Safety for the authoritative specification.
Roles can be scoped to specific sites/projects:
# Simplified Example: User assignment
user_id: 123
tenant_id: acme-corp
role: collector
scopes:
site_ids: [site-a, site-b]
project_ids: [] # empty = all projects
Enforcement: Collectors can only access submissions/evidence within their assigned sites.
3.3 Reporting Period State Gates
⚠️ Simplified Summary: See Section 7: Reporting Period State Authority for the authoritative specification.
| State | Collector Actions | Reviewer Actions | Approver Actions |
|---|---|---|---|
| OPEN | Create, update, submit | Read, comment | Read |
| IN_REVIEW | Read only | Review, return | Approve |
| APPROVED | Read only | Read only | Lock, publish |
| LOCKED | Read only | Read only | Read only |
Reopening: Admin only, with break-glass justification.
3.4 Segregation of Duties (SoD)
Rule: A user cannot approve a submission they created.
// Enforced in SubmissionPolicy::approveItem()
if ($submission->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
- Data export/migration: System-level operations
- Require
SYSTEMactor role (not bound to any tenant) - Must log all accessed tenant IDs
-
Should implement rate limiting
-
Consolidated reporting (multi-subsidiary groups):
- Must validate hierarchical tenant relationships (
parent_organisation_id) - Require explicit "group view" permission
- Log all child tenant access
Security Rule: Cross-tenant features require security architecture review and explicit documentation. They are not permitted by default.
4.6 Implementation Invariants
Implementations MUST guarantee:
- Atomicity: Tenant context is resolved once per request, immutably
- Visibility: Tenant context is accessible to all authorization layers (middleware, policies, gates)
- Traceability: All audit events include resolved
tenant_idandroleat time of action - Isolation: Database queries automatically filter by
tenant_idvia global scopes (see Section 5)
4.7 Testing Requirements
Every implementation MUST include automated tests for:
- ✅ Missing
X-Tenant-Id→ 400 - ✅ Invalid tenant UUID → 404
- ✅ Valid tenant but no user membership → 403
- ✅ Expired role grant → 403
- ✅ Multiple role memberships → all roles loaded
- ✅ Scoped role grants → scope constraints loaded
- ✅ Session-based tenant switching → revalidation on each request
- ✅ CLI job without tenant parameter → job failure
5. Fail-Closed Tenant Isolation
🔒 AUTHORITATIVE SECURITY SPECIFICATION This section defines mandatory query-level isolation behavior. Failure to implement global scopes correctly results in catastrophic data leakage.
5.1 Isolation Principle
All database queries against tenant-scoped models MUST automatically filter by the resolved tenant_id.
Enforcement Method: Laravel global query scopes applied in model boot lifecycle.
5.2 Scope Application Requirements
Every model representing tenant-owned data MUST:
- Implement automatic
tenant_idfiltering via global scope - Prevent scope bypass except via explicit, documented
withoutGlobalScope()usage - Log all scope bypasses at
MEDIUMseverity minimum
Tenant-Scoped Models (mandatory scope enforcement):
- Submission
- Evidence
- Site
- Project
- ReportingPeriod
- Template (if tenant-customizable)
- AuditEvent
- Comment
- MaterialTopic
5.3 Global Scope Implementation Pattern
Canonical Implementation:
// In each tenant-scoped 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):
- System-level aggregations:
- Must include comment explaining business justification
-
Should execute in dedicated admin/system context
-
Data migration scripts:
- Must run outside request lifecycle (artisan commands)
-
Must log all accessed tenant IDs
-
Platform admin panel:
- Separate route group without
RequireTenantContextmiddleware - Must implement alternative authorization (e.g., platform admin role)
Security Rule:
Every 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:
-
Policy authorization:
-
Query scopes (for list operations):
-
Eager loading (prevent data leakage via relationships):
6.6 Database Constraints for Scope Integrity
Invariants (enforced at database level):
-
Scope cannot reference non-existent resources:
-
Scope references must belong to same tenant:
6.7 Scope Assignment Rules
Who can assign scopes:
- Only admin role can create/modify tenant_user_scopes records
- Admins cannot reduce their own scope (prevents self-lockout)
Validation requirements:
// When creating scope assignment
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:
-
User initiates action with justification
-
Server validates authorization
- Check admin role
- Validate justification length and content
-
Check break-glass permission (if using database flag)
-
Server logs BEFORE execution
-
Server performs action
-
Server logs completion (optional second audit entry)
-
Server returns confirmation
8.8 Alerting and Monitoring
Break-glass actions SHOULD trigger real-time alerts:
Alert Channels: - Security team notification (email, Slack) - Tenant admin dashboard notification - Platform monitoring system (e.g., Sentry, Datadog)
Alert Conditions:
- Any CRITICAL severity audit event
- Any HIGH severity audit event in production environment
- Multiple break-glass actions by same user within 1 hour (potential abuse)
- Break-glass action outside business hours (potential unauthorized access)
Example Monitoring Query:
-- Detect unusual break-glass activity
SELECT actor_id, COUNT(*) as break_glass_count
FROM audit_events
WHERE severity IN ('HIGH', 'CRITICAL')
AND action LIKE '%.break_glass%'
AND created_at > NOW() - INTERVAL '1 hour'
GROUP BY actor_id
HAVING COUNT(*) > 3; -- Alert if >3 break-glass actions in 1 hour
8.9 Testing Requirements
Implementations MUST include automated tests for:
- ✅ Break-glass action without justification → denied with clear error message
- ✅ Break-glass action with short justification → denied
- ✅ Break-glass action without admin role → denied
- ✅ Valid break-glass action → allowed and logged with HIGH/CRITICAL severity
- ✅ Break-glass audit event includes all mandatory fields
- ✅ Break-glass action with expired permission → denied (if using time-bounded permissions)
- ✅ Prohibited actions (e.g., delete audit logs) → denied even with break-glass
- ✅ Break-glass log entry created BEFORE destructive action executes
9. RBAC Matrix as Source of Truth
🔒 AUTHORITATIVE SECURITY SPECIFICATION This section defines the role of the RBAC YAML matrix as the authoritative permission specification and its operational governance.
9.1 RBAC Matrix Role
The RBAC Matrix (YAML) is the authoritative, machine-readable specification of all role-based permissions in the ESG platform.
Status: Policy-as-code. The YAML file defines what MUST be implemented in 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:
- Version controlled in the same repository as implementation code
- Location:
/docs/security/rbac-matrix.v1.yml -
Never committed without review
-
Reviewed before merge
- All changes require security-aware reviewer approval
-
Breaking changes require architecture review
-
Tagged with semantic versioning
- Major version: Breaking permission changes (e.g., removing permissions)
- Minor version: Additive changes (e.g., new roles, new actions)
-
Patch version: Documentation clarifications
-
Change-logged
- Document rationale for permission changes in commit messages
- Link to security review ticket or approval email
Example commit message:
chore(rbac): Add evidence.download permission to auditor role
Rationale: External auditors require evidence download for ISO 14001 assurance.
Security review: Approved by CISO via email 2024-11-20.
Risk: LOW (read-only permission, already have evidence.read)
RBAC Matrix: v1.2.0 → v1.3.0
9.4 Synchronization with 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:
- Manual Code Review (minimum requirement)
- Reviewer compares policy logic to YAML permissions during PR review
-
Use checklist: "Does this policy match rbac-matrix.v1.yml?"
-
Automated Testing (recommended)
// Example: Policy test derived from YAML 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" ); } } -
Policy Linter (future enhancement)
- Static analysis tool to compare policy code against YAML
- CI/CD pipeline check to prevent drift
9.5 YAML as Audit Evidence
The RBAC YAML serves as primary evidence for:
ISO 27001 A.9.4.1 (Information Access Restriction): - Demonstrates documented access control policy - Shows segregation of duties enforcement - Provides version history of permission changes
SOC 2 CC6.3 (Logical Access Controls): - Evidences role-based access model - Shows least-privilege assignment - Documents break-glass controls
GRC Compliance: - Machine-readable format supports automated compliance checks - Version history provides audit trail of authorization changes
Usage in Audit:
# Generate permission report for auditor
$ grep -A 5 "evidence:" docs/security/rbac-matrix.v1.yml
evidence:
actions:
read:
allow: [collector, reviewer, approver, admin, auditor]
scope_required_for: [collector]
upload:
allow: [collector, admin]
9.6 Change Management Process
Workflow for RBAC Changes:
- Proposal: Document permission change request
- Business justification
- Security impact assessment
-
Affected roles and resources
-
Security Review: Evaluate risk
- Principle of least privilege maintained?
- Segregation of duties preserved?
-
Compliance implications?
-
YAML Update: Modify rbac-matrix.v1.yml
- Update version number
- Add changelog entry
-
Commit with detailed message
-
Policy Implementation: Update Laravel policies
- Implement YAML changes in relevant policy classes
- Write/update automated tests
-
Verify no unintended permission grants
-
Testing: Validate implementation
- Policy tests pass
- Integration tests pass
-
Manual security testing (negative cases)
-
Documentation: Update related docs
- Update this implementation guide if patterns change
- Update role descriptions if needed
-
Notify stakeholders of permission changes
-
Deployment: Release with approval
- Staging environment validation
- Production deployment
- Monitor audit logs for unexpected behavior
9.7 Extending the RBAC Matrix
Adding New Roles:
roles:
external_auditor: # New role
description: Third-party assurance provider with time-limited access.
Adding New Resources:
resources:
carbon_credit: # New resource type
actions:
read: { allow: [reviewer, approver, admin, auditor] }
create: { allow: [admin] }
retire: { allow: [approver, admin] }
period_state_allow)
- Document business logic constraints
Adding New Constraints:
constraints:
- sod.no_self_approval # Existing
- owner.or_scoped_assignment # Existing
- approval.requires_two_party # New constraint
9.8 RBAC Matrix Schema Validation
Recommended: Validate YAML schema in CI/CD pipeline.
# Example: JSON Schema validation (convert YAML to JSON first)
$ yamllint docs/security/rbac-matrix.v1.yml
$ yq eval -o=json docs/security/rbac-matrix.v1.yml | \
ajv validate -s rbac-schema.json -d -
Schema Requirements:
- version field present and integer
- All resources use defined actions
- All allow arrays reference defined roles
- period_state_allow values match defined states
- No duplicate resource/action definitions
10. Audit Logging Policy
📋 OPERATIONAL SPECIFICATION This section defines audit logging requirements for compliance, security monitoring, and forensic investigation.
10.1 Audit Logging Principle
All privileged actions, state changes, and data access MUST be logged to an append-only audit trail.
Purpose: 1. Compliance: ISO 27001, SOC 2, GDPR Article 30 (records of processing) 2. Security: Detect unauthorized access and suspicious activity 3. Forensics: Investigate incidents and data discrepancies 4. Accountability: Attribute actions to specific users and timestamps
10.2 Events Requiring Audit Logging
Mandatory Audit Events:
| Event Category | Examples | Minimum Severity |
|---|---|---|
| Authentication | Login, logout, failed login, MFA bypass | LOW |
| Authorization | Permission denied, role assignment, scope change | MEDIUM |
| Data Modification | Submission create/update, evidence upload/delete | MEDIUM |
| Workflow Transitions | Submission approval, period state change | HIGH |
| Administrative | User invite, role grant, tenant config change | MEDIUM |
| Break-Glass | Evidence deletion, period reopening, SoD override | HIGH or CRITICAL |
| Export | Data export, evidence download, audit pack generation | MEDIUM |
| Compliance | Report generation, external publish, certification | HIGH |
10.3 Audit Event Schema
Mandatory Fields (every audit event):
| Field | Type | Purpose | Example |
|---|---|---|---|
id |
UUID | Unique event identifier | "550e8400-..." |
tenant_id |
UUID | Tenant scope (null for platform events) | "acme-corp" |
actor_id |
Integer/UUID | User who performed action | user.id |
action |
String | Dot-notation event type | "submission.approved" |
object_type |
String | Resource type | "Submission" |
object_id |
UUID | Resource identifier | "uuid" |
severity |
Enum | Event criticality | "LOW", "MEDIUM", "HIGH", "CRITICAL" |
ip_address |
IP | Source IP (for forensics) | "192.0.2.1" |
user_agent |
String | Client identifier | "Mozilla/5.0..." |
created_at |
Timestamp | Event time (immutable) | "2024-11-20T14:35:00Z" |
Conditional Fields:
| Field | Required When | Purpose |
|---|---|---|
before |
State change events | Record previous state (JSON) |
after |
State change events | Record new state (JSON) |
justification |
Break-glass events | Human explanation for override |
metadata |
Context-specific | Additional event details (JSON) |
10.4 Action Naming Convention
Format: <resource>.<action>
Examples:
- submission.created
- submission.updated
- submission.approved
- evidence.uploaded
- evidence.deleted (break-glass)
- reporting_period.state_transition
- user.role_assigned
- export.audit_pack_generated
Consistency: Use past tense for completed actions.
10.5 Severity Classification
| Severity | Definition | Examples | Alert Threshold |
|---|---|---|---|
| LOW | Routine operations | Data read, submission created | No alert |
| MEDIUM | Significant operations | Submission approved, evidence uploaded | Daily summary |
| HIGH | Governance actions | Period locked, break-glass used, role assigned | Real-time alert |
| CRITICAL | Security/compliance events | Multiple failed logins, bulk deletion, unauthorized access attempt | Immediate escalation |
10.6 Audit Events for Break-Glass
Requirement: All break-glass actions MUST generate HIGH or CRITICAL severity events.
Mandatory Additional Fields:
- justification (human explanation)
- before state (what was changed)
- after state (new value)
Example:
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
CRITICALseverity event → Immediate Slack/email to security team - Multiple
HIGHseverity events from same user in <1 hour → Alert - Failed authentication attempts >5 in 10 minutes → Alert
- Break-glass action outside business hours → Alert
- Bulk export (>100 records) → Notify admin
Daily Summary Reports: - Count of events by severity - Top 10 most active users - All break-glass actions - Failed authorization attempts
11. Evidence Security Policy
📋 OPERATIONAL SPECIFICATION This section defines security requirements for evidence file handling, storage, and access.
11.1 Evidence Security Principle
Evidence files are high-risk data assets requiring integrity protection, access control, and malware scanning.
Rationale: - Sensitive data: Evidence may contain PII, financial data, trade secrets - Audit-critical: Evidence supports ESG claims and regulatory filings - Attack vector: File uploads are common malware delivery mechanism - Compliance: ISO 27001 A.12.3.1 (backup), GDPR Article 32 (security of processing)
11.2 Evidence Access Control
Authorization Requirements:
| Action | Authorization | Scope Enforcement |
|---|---|---|
| Upload | Collector or Admin | Must be within user's site scope |
| View metadata | All roles (within tenant) | Scoped for collectors |
| Download file | Reviewer, Approver, Admin, Auditor | Logged as audit event |
| Delete | Admin only (break-glass) | Requires justification |
| Modify | PROHIBITED | Evidence is immutable once uploaded |
Implementation:
// In EvidencePolicy
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:
- Encryption at rest: Evidence files MUST be encrypted in storage
- AWS S3: Enable default encryption (AES-256)
-
Local filesystem: Use encrypted volumes (LUKS, BitLocker)
-
Access control: Storage bucket/volume MUST NOT be publicly accessible
- S3: Block public access, use pre-signed URLs for downloads
-
Local: Restrict file permissions to web server user only
-
Backup: Evidence storage MUST be backed up
- Retention: Match audit log retention (7 years minimum)
- Backup encryption: Required
-
Disaster recovery: Test restoration quarterly
-
Geographic storage: Consider data residency requirements
- EU tenants: Store evidence in EU region (GDPR compliance)
- Multi-region: Replicate to secondary region for DR
Example (AWS S3):
// Store with server-side encryption
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
Pattern 2: Role Check
Pattern 3: Scope Constraint
if ($this->context()->hasRole('collector')) {
return $this->context()->canAccessSite($resource->site_id);
}
Pattern 4: State Gate
Pattern 5: Ownership Check
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:
- Policy-as-code: The RBAC Matrix YAML as the source of truth
- Layered security: Middleware → Policies → Gates → Scopes
- Tenant isolation: Hard boundary enforcement at every layer
- Workflow gates: Reporting period state controls
- SoD enforcement: Automated checks in policies
- Break-glass controls: Mandatory justification and audit trails
- Comprehensive audit: Append-only logs for all privileged actions
Next Steps
- Implement the database migrations
- Create model classes with tenant scopes
- Implement policies for all resources
- Register middleware in
app/Http/Kernel.php - Write comprehensive tests
- Deploy and monitor audit logs
Related Documentation: - Tenancy & Role Model - RBAC Matrix (YAML) - Backend Domain Models