Files
api/services/nginx/app/routes/departmentGoalsRoute.php
T
OpenClaw 51a87655d6 feat(auth): add scope-based access control to all existing routes (TRU-149)
Adds a scope-based access control layer to all 81 existing API routes.
Sits alongside existing session-cookie auth (does not replace it).

What this PR does:
- Audits every existing route and documents required scope per route
  (see documentation/auth/route-scope-audit.md)
- Adds classes/auth/scope.php with 10 scope constants and role→scope defaults
- Adds classes/auth/scope_middleware.php with requireScope/requireAnyScope/requireRole
- Applies require*() calls to all 81 existing routes
- Adds ScopeMiddlewareTest (unit, 178 lines) and RouteScopeTest (integration, 212 lines)

Coexistence note:
This branch's classes/auth/scope.php is a stub that will be replaced
by classes/auth/scope_registry.php (from TRU-145 / PR #396) when that
PR merges first. The two have compatible APIs.

Refs: TRU-149
2026-08-17 11:43:13 +00:00

426 lines
21 KiB
PHP

<?php
namespace routes;
use classes\authentication;
use classes\response;
use classes\email as Email;
use classes\gatewayapi as GatewayAPI;
use classes\slack as Slack;
use goals\classes\goals_criteria;
use goals\services\goals_progress_alert_renderer;
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
use objects\departments_o;
use objects\department_goals_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentGoalsRoute
{
use route_t;
public function run(): void
{
// List or get single department goal(s)
$this->get('/goals/department', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/goals/department');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_list');
// Current user and access helpers
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Unauthorized', 401);
}
$isSuperuser = method_exists($user, 'hasPermission') && $user->hasPermission('superuser');
$userDepartments = method_exists($user, 'getGroup') ? (array)$user->getGroup()->getDepartments() : [];
$hasAllDepartments = function (array $goalDepartments) use ($userDepartments): bool {
// Ensure ints
$goalDepartments = array_map('intval', $goalDepartments);
$userDepartments = array_map('intval', $userDepartments);
return count(array_diff($goalDepartments, $userDepartments)) === 0;
};
$goals = new department_goals_o();
// Single by id
if (self::isParametersSet(['id'])) {
$id = self::getParameter('id');
if (!is_numeric($id)) {
$response->error('id must be numeric', 400);
}
$goal = (new department_goals_o())->select((int)$id);
if (!$goal->exists()) {
$response->error('Goal not found', 404);
}
// Access control: must have all departments unless superuser
if (!$isSuperuser && !$hasAllDepartments((array)$goal->departments->value())) {
$missingDepartments = array_values(array_map('intval', array_diff(array_map('intval', (array)$goal->departments->value()), array_map('intval', $userDepartments))));
$response->forbidden([
...array_map(static fn($departmentId) => 'department_access_' . $departmentId, $missingDepartments),
'superuser'
]);
}
$response->success($goal->asArray());
}
// List with pagination if set (page, limit, search, filters, order supported by db_object_t)
$list = $goals->listObjectsWithPaginationIfSet(
function ($row) {
return (new department_goals_o())->select((int)$row['id'])->asArray();
}
);
// Filter unauthorized results unless superuser
if (!$isSuperuser && is_array($list)) {
$list = array_values(array_filter($list, function ($item) use ($hasAllDepartments) {
$deps = isset($item['departments']) && is_array($item['departments']) ? $item['departments'] : [];
return $hasAllDepartments($deps);
}));
}
$response->success($list);
}, [
'goals_department_list' => 'List department goals or retrieve a single goal when id is provided'
]);
// Create a new department goal
$this->post('/goals/department', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_create');
// Authenticated user (creator)
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Unauthorized', 401);
}
$isSuperuser = method_exists($user, 'hasPermission') && $user->hasPermission('superuser');
$userDepartments = method_exists($user, 'getGroup') ? (array)$user->getGroup()->getDepartments() : [];
// Validate input
$this->requireParameters(['departments', 'criteria']);
$departmentsInput = self::getParameter('departments');
$criteriaInput = self::getParameter('criteria');
$this->requireType($departmentsInput, 'array');
$this->requireTypeIn($criteriaInput, ['array', 'object']);
// Build departments objects array
$departments = [];
foreach ($departmentsInput as $deptId) {
if (!is_numeric($deptId)) {
$response->error('All department ids must be numeric', 400);
}
$dept = (new departments_o())->select((int)$deptId);
if (!$dept->exists()) {
$response->error('Department not found: ' . $deptId, 404);
}
$departments[] = $dept;
}
// Access control: creator must have all departments unless superuser
if (!$isSuperuser) {
$goalDepartments = array_map(fn($d) => (int)$d->id, $departments);
$missing = array_diff($goalDepartments, array_map('intval', $userDepartments));
if (count($missing) > 0) {
$response->forbidden([
...array_map(static fn($departmentId) => 'department_access_' . (int)$departmentId, $missing),
'superuser'
]);
}
}
// Parse criteria (preserve unicode characters like æ, ø, å)
$criteria = goals_criteria::fromJson(json_encode($criteriaInput, JSON_UNESCAPED_UNICODE));
// Create the goal
$goal = (new department_goals_o())->add($user, $departments, $criteria);
$response->success($goal->asArray(), 201);
}, [
'goals_department_create' => 'Create a new department goal'
]);
// Update an existing department goal
$this->put('/goals/department', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_update');
$this->requireParameters(['id']);
$id = self::getParameter('id');
if (!is_numeric($id)) {
$response->error('id must be numeric', 400);
}
$goal = (new department_goals_o())->select((int)$id);
if (!$goal->exists()) {
$response->error('Goal not found', 404);
}
// Current user and access helpers
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Unauthorized', 401);
}
$isSuperuser = method_exists($user, 'hasPermission') && $user->hasPermission('superuser');
$isCreator = ((int)$goal->created_by->value()) === (int)$user->id;
$userDepartments = method_exists($user, 'getGroup') ? (array)$user->getGroup()->getDepartments() : [];
$hasAllDepartments = function (array $goalDepartments, array $userDepartments) {
$goalDepartments = array_map('intval', $goalDepartments);
$userDepartments = array_map('intval', $userDepartments);
return count(array_diff($goalDepartments, $userDepartments)) === 0;
};
// If not superuser or creator, require access to existing goal departments
if (!$isSuperuser && !$isCreator && !$hasAllDepartments((array)$goal->departments->value(), $userDepartments)) {
$missingDepartments = array_values(array_map('intval', array_diff(array_map('intval', (array)$goal->departments->value()), array_map('intval', $userDepartments))));
$response->forbidden([
...array_map(static fn($departmentId) => 'department_access_' . $departmentId, $missingDepartments),
'superuser'
]);
}
$dataToUpdate = [];
// Optional: departments
if ($response->isRequestParameterSet('departments')) {
$departmentsInput = self::getParameter('departments');
$this->requireType($departmentsInput, 'array');
foreach ($departmentsInput as $deptId) {
if (!is_numeric($deptId)) {
$response->error('All department ids must be numeric', 400);
}
}
// If not superuser or creator, ensure new departments are within user departments
if (!$isSuperuser && !$isCreator) {
$missing = array_diff(array_map('intval', $departmentsInput), array_map('intval', $userDepartments));
if (count($missing) > 0) {
$response->forbidden([
...array_map(static fn($departmentId) => 'department_access_' . (int)$departmentId, $missing),
'superuser'
]);
}
}
// store as ids array in column
$dataToUpdate['departments'] = array_map('intval', $departmentsInput);
}
// Optional: criteria
if ($response->isRequestParameterSet('criteria')) {
$criteriaInput = self::getParameter('criteria');
$this->requireTypeIn($criteriaInput, ['array', 'object']);
//var_dump($criteriaInput);
// Preserve unicode characters when (re)encoding criteria
$criteria = goals_criteria::fromJson(json_encode($criteriaInput, JSON_UNESCAPED_UNICODE));
// Ensure the criteria object knows its departments (from input or existing object)
// so toArray() preserves department-specific daily targets.
$deptIds = isset($dataToUpdate['departments']) ? $dataToUpdate['departments'] : (array)$goal->departments->value();
$deptObjs = [];
foreach ($deptIds as $dId) {
$d = (new departments_o())->select((int)$dId);
if ($d->exists()) { $deptObjs[] = $d; }
}
$criteria->departments->set($deptObjs);
// Add empty daily_department_targets for any departments that have none but are in the criteria, to avoid validation errors and ensure they are preserved in output
$criteria->validateAndSanitize();
$dataToUpdate['criteria'] = $criteria->toArray();
}
if (empty($dataToUpdate)) {
$response->error('No updatable fields provided. Allowed: departments, criteria', 400);
}
$goal->criteria->update($dataToUpdate['criteria'] ?? $goal->criteria->value());
$goal->departments->update($dataToUpdate['departments'] ?? $goal->departments->value());
$response->success($goal->asArray());
}, [
'goals_department_update' => 'Update an existing department goal'
]);
// Delete a department goal (soft delete if supported)
$this->delete('/goals/department', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_delete');
$this->requireParameters(['id']);
$id = self::getParameter('id');
if (!is_numeric($id)) {
$response->error('id must be numeric', 400);
}
$goal = (new department_goals_o())->select((int)$id);
if (!$goal->exists()) {
$response->error('Goal not found', 404);
}
// Current user and access helpers
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Unauthorized', 401);
}
$isSuperuser = method_exists($user, 'hasPermission') && $user->hasPermission('superuser');
$isCreator = ((int)$goal->created_by->value()) === (int)$user->id;
$userDepartments = method_exists($user, 'getGroup') ? (array)$user->getGroup()->getDepartments() : [];
$goalDepartments = (array)$goal->departments->value();
$hasAll = count(array_diff(array_map('intval', $goalDepartments), array_map('intval', $userDepartments))) === 0;
if (!$isSuperuser && !$isCreator && !$hasAll) {
$missingDepartments = array_values(array_map('intval', array_diff(array_map('intval', $goalDepartments), array_map('intval', $userDepartments))));
$response->forbidden([
...array_map(static fn($departmentId) => 'department_access_' . $departmentId, $missingDepartments),
'superuser'
]);
}
$goal->delete();
$response->success(['message' => 'Deleted']);
}, [
'goals_department_delete' => 'Delete a department goal (soft delete when supported)'
]);
// Send a test progress alert for a department goal
$this->post('/goals/department/progress-alert/test', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department/progress-alert/test');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_progress_alert_test');
// Required: id of the goal
$this->requireParameters(['id']);
$id = self::getParameter('id');
if (!is_numeric($id)) {
$response->error('id must be numeric', 400);
}
$goal = (new department_goals_o())->select((int)$id);
if (!$goal->exists()) {
$response->error('Goal not found', 404);
}
// Authorization: reuse list/get policy — must be superuser or have all goal departments
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Unauthorized', 401);
}
$isSuperuser = method_exists($user, 'hasPermission') && $user->hasPermission('superuser');
$userDepartments = method_exists($user, 'getGroup') ? (array)$user->getGroup()->getDepartments() : [];
$goalDepartments = (array)$goal->departments->value();
$hasAll = count(array_diff(array_map('intval', $goalDepartments), array_map('intval', $userDepartments))) === 0;
if (!$isSuperuser && !$hasAll) {
$missingDepartments = array_values(array_map('intval', array_diff(array_map('intval', $goalDepartments), array_map('intval', $userDepartments))));
$response->forbidden([
...array_map(static fn($departmentId) => 'department_access_' . $departmentId, $missingDepartments),
'superuser'
]);
}
// Rebuild criteria from stored array
$criteriaArray = (array)$goal->criteria->value();
$criteria = goals_criteria::fromJson(json_encode($criteriaArray, JSON_UNESCAPED_UNICODE));
// Attach departments to criteria (so renderer can include them)
$deptObjs = [];
foreach ($goalDepartments as $deptId) {
if (is_numeric($deptId)) {
$dept = (new departments_o())->select((int)$deptId);
if ($dept->exists()) {
$deptObjs[] = $dept;
}
}
}
if (isset($criteria->departments)) {
$criteria->departments->set($deptObjs);
}
// Allow overriding the destination for testing
$destination = $criteria->progress_alert_destination ?? Dest::NONE;
if ($response->isRequestParameterSet('overrideDestination')) {
$override = (string)self::getParameter('overrideDestination');
$try = \goals\helpers\goals_criteria_progress_alert_destination::tryFrom($override);
if ($try !== null) {
$destination = $try;
}
}
// Render message
$message = goals_progress_alert_renderer::render($criteria);
// Dispatch according to destination
$providerResponse = null;
$target = null;
try {
switch ($destination) {
case Dest::EMAIL:
$this->requireParameters(['email_to']);
$to = (string)self::getParameter('email_to');
$subject = $response->isRequestParameterSet('subject') ? (string)self::getParameter('subject') : 'Goals progress alert';
(new Email())->sendEmail($to, 'Goals Tester', $subject, $message, '');
$providerResponse = 'Email queued/sent';
$target = $to;
break;
case Dest::SMS:
$this->requireParameters(['sms_to']);
$smsParam = self::getParameter('sms_to');
$numbers = [];
if (is_string($smsParam)) {
$numbers = array_values(array_filter(array_map('trim', preg_split('/[,;]/', $smsParam))));
} elseif (is_array($smsParam)) {
$numbers = array_map('strval', $smsParam);
} else {
$response->error('sms_to must be string or array', 400);
}
(new GatewayAPI())->send($numbers, $message);
$providerResponse = 'SMS sent';
$target = $numbers;
break;
case Dest::SLACK:
// Prefer explicit webhook; else allow department_id; else fallback to default webhook
if ($response->isRequestParameterSet('slack_webhook')) {
$webhook = (string)self::getParameter('slack_webhook');
$providerResponse = (new Slack())->send_webhook_message($message, $webhook);
$target = $webhook;
} elseif ($response->isRequestParameterSet('department_id')) {
$deptId = self::getParameter('department_id');
if (!is_numeric($deptId)) {
$response->error('department_id must be numeric', 400);
}
(new Slack())->send_department_booking_notification((int)$deptId, $message);
$providerResponse = 'Slack message sent to department webhook';
$target = (int)$deptId;
} elseif (isset($criteria->departments) && is_array($criteria->departments->list()) && count($criteria->departments->list()) > 0) {
// Loop through departments and send notifications
/** @var departments_o $dept */
foreach ( $criteria->departments->list() as $dept) {
$slackWebhook = $dept->slack_webhook->value();
if (empty($slackWebhook)) {
continue;
}
//(new Slack())->send_message((string)goals_progress_alert_renderer::render($criteria, $dept), (string)$dept->name->value());
(new Slack())->send_webhook_message((string)goals_progress_alert_renderer::render($criteria, $dept), (string)$dept->slack_webhook->value());
}
} else {
(new Slack())->send_message($message);
$providerResponse = 'Slack message sent to default webhook';
}
break;
default:
$response->error('Unsupported or NONE destination for progress alert', 400);
}
} catch (\Exception $e) {
$response->error('Failed to send progress alert: ' . $e->getMessage(), 500);
}
$response->success([
'id' => (int)$goal->id,
'destination' => (string)$destination->name,
'target' => $target,
'message_preview' => $message,
'provider_response' => $providerResponse,
]);
}, [
'goals_department_progress_alert_test' => 'Send a test progress alert for a department goal (actual delivery)'
]);
}
}