5.6.5 Activity Authorization and Security Patterns
Last Updated: December 3, 2025
Status: Complete
Scope: Activities Plugin - Security & Authorization Patterns
Technical reference for Activity entity security patterns, mass assignment protection, and authorization workflows.
Table of Contents
- Mass Assignment Security
- Approvers Discovery Pattern
- Age-Based Eligibility
- Workflow Security
- Best Practices
Mass Assignment Security
CakePHP entities protect against mass assignment vulnerabilities by defining which fields can be set through newEntity() or patchEntity() methods.
Configuration Overview
The Activity entity’s $_accessible array defines secure field access patterns:
protected array $_accessible = [
// Core Configuration - Safe for admin input
"name" => true,
"term_length" => true,
"activity_group_id" => true,
"minimum_age" => true,
"maximum_age" => true,
"num_required_authorizors" => true,
"num_required_renewers" => true,
// Administrative Integration
"deleted" => true,
"permission_id" => true,
"grants_role_id" => true,
// Relationships
"activity_group" => true,
"role" => true,
"member_activities" => true,
"pending_authorizations" => true,
];
Protected vs. Accessible Fields
Accessible Fields: These fields are designed for administrator configuration and can be safely mass-assigned from user input:
| Field | Purpose | Security Notes |
|---|---|---|
| name | Activity identifier | Subject to form validation for uniqueness |
| term_length | Authorization duration | Positive integer validation required |
| activity_group_id | Categorization | Must reference existing ActivityGroup |
| minimum_age | Eligibility constraint | 0-127 range validation |
| maximum_age | Eligibility constraint | 0-127 range validation |
| num_required_authorizors | Approval count | 1+ positive integer |
| num_required_renewers | Renewal approvals | 1+ positive integer |
| deleted | Soft deletion | Managed by Trash behavior |
| permission_id | RBAC integration | Must reference valid Permission |
| grants_role_id | Auto-grant role | Must reference valid Role |
Protected Fields: These fields are NOT accessible via mass assignment and can only be set through explicit property assignment:
| Field | Reason | Management |
|---|---|---|
| id | Primary key | Database generated |
| created | Creation timestamp | Timestamp behavior |
| created_by | Creator tracking | Application code |
| modified | Update timestamp | Timestamp behavior |
| modified_by | Updater tracking | Application code |
Safe Entity Creation Pattern
// CORRECT: Using accessible fields with newEntity()
$activity = $activitiesTable->newEntity($this->request->getData());
// Only accessible fields from form are assigned
// AVOID: Directly assigning unvalidated data
// $activity->created = $userInput; // NOT possible - protected field
// $activity->created_by = $hack; // NOT possible - protected field
// CORRECT: Explicit assignment after validation for protected fields
$activity->created_by = $this->Authentication->getIdentity()->id;
$activity = $activitiesTable->save($activity);
Relationship Mass Assignment
Relationships can be mass-assigned for convenience, but should be validated:
// CORRECT: Mass-assign relationship via accessible field
$activity = $activitiesTable->newEntity([
'name' => 'Marshal Training',
'activity_group_id' => 1,
'activity_group' => $activityGroup, // Accessible - convenience assignment
'term_length' => 365,
]);
// CORRECT: Separate validation for relationship assignment
if ($isAdminUser) {
$activity->permission_id = $permissionId;
$activity->grants_role_id = $roleId;
}
Approvers Discovery Pattern
The approvers discovery mechanism is the core authorization integration point for authorization workflows.
How Approver Discovery Works
graph TD
A["Authorization Request Initiated"] --> B["Get Activity from Database"]
B --> C["Call getApproversQuery()"]
C --> D["Use permission_id to identify<br/>members with permission"]
D --> E["Filter by branch_id"]
E --> F["Return eligible approvers"]
F --> G["Route approval requests"]
G --> H["Create AuthorizationApproval<br/>entities for each approver"]
H --> I["Send notifications"]
getApproversQuery() Method
The Activity entity provides the getApproversQuery() method to discover eligible approvers:
/**
* Get Approvers Query for Activity Authorization
*
* @param int $branch_id Branch scope for approver identification
* @return \Cake\ORM\Query\SelectQuery Query object for eligible approvers
* @throws \Exception When permission_id is not configured
*/
public function getApproversQuery(int $branch_id): \Cake\ORM\Query\SelectQuery
{
if (!isset($this->permission_id)) {
throw new \Exception("Permission ID not set");
}
return PermissionsLoader::getMembersWithPermissionsQuery(
$this->permission_id,
$branch_id
);
}
Permission Configuration Requirement
For approver discovery to work, activities must have a permission_id configured:
// CORRECT: Activity with permission configuration
$activity = [
'name' => 'Heavy Weapons Authorization',
'permission_id' => 42, // Links to RBAC permission
'activity_group_id' => 1,
'term_length' => 1095,
'num_required_authorizors' => 2,
];
// INCORRECT: Activity without permission fails approver discovery
$activity = [
'name' => 'Broken Activity',
'permission_id' => null, // Will throw exception when approvers queried
'activity_group_id' => 1,
];
// RUNTIME ERROR EXAMPLE:
try {
$approvers = $activity->getApproversQuery($branchId); // Throws Exception
} catch (\Exception $e) {
// Exception: "Permission ID not set"
}
Approver Discovery in Authorization Workflow
The authorization manager uses approver discovery during request creation:
// From DefaultAuthorizationManager::request()
$activity = $activitiesTable->get($activityId);
// Discover eligible approvers
$approvesQuery = $activity->getApproversQuery($member->branch_id);
$approverCount = $approvesQuery->count();
// Validate sufficient approvers available
if ($approverCount < $activity->num_required_authorizors) {
return new ServiceResult(
false,
"Insufficient approvers available for authorization"
);
}
// Route request to each required approver
$approvers = $approvesQuery->limit($activity->num_required_authorizors)->toArray();
foreach ($approvers as $approver) {
// Create AuthorizationApproval entity for approver
// Send notification email
}
Performance Optimization
The approver discovery uses PermissionsLoader for optimized queries:
// RECOMMENDED: Query count only when needed
$approverCount = $activity->getApproversQuery($branchId)->count();
// LESS EFFICIENT: Fetch all approvers to count
$approvers = $activity->getApproversQuery($branchId)->toArray();
$count = count($approvers);
// RECOMMENDED: Chain conditions before execution
$approvers = $activity->getApproversQuery($branchId)
->where(['Members.status !=' => 'inactive'])
->limit($activity->num_required_authorizors)
->toArray();
// CACHE BENEFIT: Repeated permission queries leverage PermissionsLoader cache
$approvers1 = $activity->getApproversQuery($branchId1)->toArray();
$approvers2 = $activity->getApproversQuery($branchId1)->toArray(); // Uses cache
Error Handling
Proper error handling prevents authorization workflow failures:
// Defensive approver discovery
public function findApproversForAuthorization(Activity $activity, int $branchId)
{
try {
// Validate permission configuration exists
if (!$activity->permission_id) {
throw new \Exception("Activity missing permission configuration");
}
// Get approvers
$approvesQuery = $activity->getApproversQuery($branchId);
// Validate sufficient approvers
$count = $approvesQuery->count();
if ($count < $activity->num_required_authorizors) {
throw new \Exception(
"Insufficient approvers: need {$activity->num_required_authorizors}, " .
"found {$count}"
);
}
return $approvesQuery->toArray();
} catch (\Exception $e) {
Log::error("Approver discovery failed: " . $e->getMessage());
throw new AuthorizationFailedException(
"Unable to route authorization request",
0,
$e
);
}
}
Age-Based Eligibility
Activities define age-based member eligibility through minimum_age and maximum_age properties.
Configuration
// Youth activity: ages 13-17
$activity->minimum_age = 13;
$activity->maximum_age = 17;
// Adult activity: ages 18+
$activity->minimum_age = 18;
$activity->maximum_age = null; // No upper limit
// Unrestricted activity: all ages
$activity->minimum_age = null;
$activity->maximum_age = null;
Eligibility Validation
Age eligibility is validated before authorization request creation:
// Age eligibility check in authorization workflow
$isEligible = true;
if ($activity->minimum_age !== null && $member->age < $activity->minimum_age) {
$isEligible = false;
}
if ($activity->maximum_age !== null && $member->age > $activity->maximum_age) {
$isEligible = false;
}
if (!$isEligible) {
throw new \Exception(
"Member age {$member->age} does not meet activity requirements " .
"({$activity->minimum_age}-{$activity->maximum_age})"
);
}
Age Calculation
Member age is calculated from birth date:
// Assuming member has date_of_birth field
$dob = $member->date_of_birth;
$today = FrozenDate::now();
$age = $today->diffInYears($dob); // Elapsed years since birth
// Age validation
if ($age < 18) {
// Youth authorization required
} else {
// Adult authorization allowed
}
Workflow Security
Authorization Request Validation
Multiple security checks protect the authorization workflow:
/**
* Comprehensive validation before creating authorization request
*/
public function validateAuthorizationRequest(
Activity $activity,
Member $member,
int $requesterId,
bool $isRenewal = false
): ServiceResult
{
// 1. Activity existence and configuration
if (!$activity->id) {
return new ServiceResult(false, "Activity not found");
}
// 2. Age eligibility
if (!$this->validateAge($member, $activity)) {
return new ServiceResult(false, "Member does not meet age requirements");
}
// 3. Approver availability
try {
$approvers = $activity->getApproversQuery($member->branch_id);
if ($approvers->count() < $activity->num_required_authorizors) {
return new ServiceResult(false, "Insufficient approvers available");
}
} catch (\Exception $e) {
return new ServiceResult(false, "Activity not configured for authorization");
}
// 4. Existing authorization check
if (!$isRenewal && $this->hasPendingRequest($member, $activity)) {
return new ServiceResult(false, "Pending request already exists");
}
// 5. Renewal eligibility check
if ($isRenewal && !$this->hasValidAuthorizationToRenew($member, $activity)) {
return new ServiceResult(false, "No valid authorization to renew");
}
return new ServiceResult(true);
}
Permission-Based Authorization
Admin actions are protected by RBAC permissions:
// Admin authorization check before allowing activity modification
$canManage = $user->can('Can Manage Activities');
if (!$canManage) {
throw new ForbiddenException("Not authorized to manage activities");
}
// Admin check before revocation
$canRevoke = $user->can('Can Revoke Authorizations');
if (!$canRevoke) {
throw new ForbiddenException("Not authorized to revoke authorizations");
}
Audit Trail Integration
All authorization operations are audited:
// Created/modified tracking
$activity->created_by = $currentUser->id; // Set on creation
$activity->modified_by = $currentUser->id; // Updated on modifications
$activity->created = FrozenDateTime::now(); // Timestamp behavior
$activity->modified = FrozenDateTime::now(); // Timestamp behavior
// Authorization audit
$authorization->status = "Approved";
$authorization->revoker_id = $adminId; // Track who approved/revoked
$authorization->revoked_reason = $reason; // Audit reasoning
Best Practices
Activity Configuration
- Always Set Permission ID:
// CORRECT $activity->permission_id = $permissionId; // AVOID - authorization workflow will fail $activity->permission_id = null; - Validate Approver Availability:
// Verify approvers exist before completing setup $approvesQuery = $activity->getApproversQuery($branchId); if ($approvesQuery->count() == 0) { Log::warning("No approvers available for activity: {$activity->name}"); } - Set Appropriate Approval Requirements:
// Simple activity: single approver $activity->num_required_authorizors = 1; // Combat activity: dual approvers $activity->num_required_authorizors = 2;
Authorization Request Handling
- Validate Before Creating:
$validation = $this->validateAuthorizationRequest( $activity, $member, $requesterId ); if (!$validation->isSuccess()) { return $validation; // Return error instead of creating invalid request } - Handle Approver Discovery Failures:
try { $approvers = $activity->getApproversQuery($branchId)->toArray(); } catch (\Exception $e) { Log::error("Approver discovery failed: {$e->getMessage()}"); throw new AuthorizationFailedException("Unable to process authorization"); } - Cache Query Results When Appropriate:
// Reuse results if querying same activity multiple times $approvers = $activity->getApproversQuery($branchId)->toArray(); foreach ($approvers as $approver) { // Use cached approvers list }
Age Eligibility
- Validate Age Before Processing:
if (!$this->validateAge($member, $activity)) { return new ServiceResult(false, "Age requirement not met"); } - Handle Missing Birth Dates:
if ($member->date_of_birth === null) { // Cannot validate age - require member to complete profile return new ServiceResult(false, "Member must provide date of birth"); }
Table-Level Security Policies
The Activities plugin uses CakePHP’s Authorization plugin for table-level access control. Table policies provide centralized authorization logic for bulk operations and grid data filtering.
Policy Architecture
Table policies follow a consistent pattern:
ActivitiesTablePolicy
├── scopeIndex() # Filter records for index views
├── scopeGridData() # Filter records for DataTables grids (delegates to scopeIndex)
├── canAdd() # Permission to create new records
├── canEdit() # Permission to modify records
└── canDelete() # Permission to remove records
Grid Data Scoping Pattern
Table policies implement scopeGridData() to integrate with the KMP DataTables grid system:
class ActivitiesTablePolicy
{
/**
* Scope grid data using the standard index scope.
* All DataTables grids use this method for authorization filtering.
*/
public function scopeGridData(
IdentityInterface $user,
Query $query
): Query {
return $this->scopeIndex($user, $query);
}
}
This pattern ensures consistent authorization between traditional index views and AJAX-loaded grid data.
Available Table Policies
| Policy Class | Table | Purpose |
|---|---|---|
ActivitiesTablePolicy |
Activities | Activity record access control |
ActivityGroupsTablePolicy |
ActivityGroups | Activity group management |
AuthorizationsTablePolicy |
Authorizations | Authorization request filtering |
AuthorizationApprovalsTablePolicy |
AuthorizationApprovals | Approval workflow access |
PermissionsActivityGroupsTablePolicy |
PermissionsActivityGroups | Permission-to-activity mappings |
PermissionsActivityTablePolicy |
PermissionsActivity | Activity-specific permissions |
Implementation Notes
- Delegation Pattern:
scopeGridData()should delegate toscopeIndex()for DRY principles - Query Building: Scope methods receive a Query object and must return a modified Query
- Identity Access: The
$userparameter provides the authenticated user’s identity - Consistent Filtering: Both index views and grids apply identical authorization filters
Approval Queue Scoping Pattern
The AuthorizationApprovalsTablePolicy implements a two-tier access model for approval queues:
class AuthorizationApprovalsTablePolicy extends BasePolicy
{
/**
* Personal queue access - requires any approval authority.
*/
public function canMyQueue(KmpIdentityInterface $user, $entity): bool
{
return ActivitiesTable::canAuhtorizeAnyActivity($user);
}
/**
* Administrative access - permission-based.
*/
public function canAllQueues(KmpIdentityInterface $user, $entity): bool
{
return $this->_hasPolicy($user, __FUNCTION__, $entity);
}
/**
* Index scope - admin sees all, approvers see own.
*/
public function scopeIndex(KmpIdentityInterface $user, $query)
{
if ($this->canAllQueues($user, $table->newEmptyEntity())) {
return $query; // Admin: unfiltered
}
return $query->where(['approver_id' => $user->getIdentifier()]);
}
}
Queue Access Tiers:
| Scope Method | Access Level | Filter Applied |
|---|---|---|
scopeMyQueue() |
Personal | approver_id = user.id |
scopeIndex() (admin) |
Administrative | None (full access) |
scopeIndex() (approver) |
Personal | approver_id = user.id |
scopeView() |
Same as index | Consistent with queue access |
scopeMobileApprove() |
Personal or Admin | Based on canAllQueues() |
References
- Activity Entity Reference - Entity properties and methods
- Activities Plugin Workflows - Authorization lifecycle
- RBAC Security Architecture - Permission system
- CakePHP Entity Documentation - CakePHP accessible fields
- CakePHP Authorization Plugin - Authorization system