Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions escalated.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Plugin Name: Escalated
* Plugin URI: https://github.com/escalated-dev/escalated-wordpress
* Description: A full-featured helpdesk and ticketing system with multi-role support, SLA tracking, escalation rules, inbound email, macros, and REST API.
* Version: 1.3.0
* Version: 1.4.0
* Author: Escalated
* Author URI: https://escalated.dev
* License: MIT
Expand All @@ -18,7 +18,7 @@
exit;
}

define('ESCALATED_VERSION', '1.3.0');
define('ESCALATED_VERSION', '1.4.0');
define('ESCALATED_PLUGIN_FILE', __FILE__);
define('ESCALATED_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('ESCALATED_PLUGIN_URL', plugin_dir_url(__FILE__));
Expand Down
73 changes: 66 additions & 7 deletions includes/Admin/class-admin-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Escalated\Admin;

use Escalated\Models\AuditLog;
use Escalated\Models\Setting;

class Admin_Settings
Expand Down Expand Up @@ -39,7 +40,21 @@ public function handle_save(): void
wp_die(esc_html__('Security check failed.', 'escalated'));
}

$fields = [
$this->persist(wp_unslash($_POST));

$redirect = admin_url('admin.php?page=escalated-settings&message=saved');
wp_safe_redirect($redirect);
exit;
}

/**
* The editable settings keys and their sanitizers.
*
* @return array<string, string>
*/
private function fields(): array
{
return [
// General
'ticket_reference_prefix' => 'sanitize_text_field',
'default_priority' => 'sanitize_text_field',
Expand Down Expand Up @@ -86,11 +101,36 @@ public function handle_save(): void
// Maintenance
'activity_purge_days' => 'absint',
];
}

/**
* Persist a submitted (already unslashed) settings payload and write a
* single `settings.updated` audit entry capturing the keys that changed.
*
* Extracted from handle_save() so it is unit-testable without the
* redirect/exit at the HTTP boundary. Webhook fields (webhook_url,
* webhook_secret) flow through here too, so webhook changes are audited.
*
* @param array<string, mixed> $input
*/
public function persist(array $input): void
{
$fields = $this->fields();

$audit_keys = array_merge(
array_keys($fields),
['guest_policy_mode', 'guest_policy_user_id', 'guest_policy_signup_url_template']
);

// Snapshot the prior values so we can diff after saving.
$before = [];
foreach ($audit_keys as $key) {
$before[$key] = Setting::get($key);
}

foreach ($fields as $key => $sanitizer) {
if (isset($_POST[$key])) {
$value = wp_unslash($_POST[$key]);
$value = call_user_func($sanitizer, $value);
if (isset($input[$key])) {
$value = call_user_func($sanitizer, $input[$key]);

if ($key === 'ticket_reference_prefix' && ! $this->is_valid_ticket_reference_prefix((string) $value)) {
continue;
Expand Down Expand Up @@ -128,9 +168,28 @@ public function handle_save(): void
Setting::set('guest_policy_signup_url_template', '');
}

$redirect = admin_url('admin.php?page=escalated-settings&message=saved');
wp_safe_redirect($redirect);
exit;
// Diff and record a single audit entry for the changed keys. Secret
// values are recorded as a redacted marker rather than plaintext.
$secret_keys = ['webhook_secret', 'inbound_email_password'];
$old_values = [];
$new_values = [];
foreach ($audit_keys as $key) {
$after = Setting::get($key);
if ((string) $after === (string) $before[$key]) {
continue;
}
if (in_array($key, $secret_keys, true)) {
$old_values[$key] = $before[$key] !== null && $before[$key] !== '' ? '********' : null;
$new_values[$key] = $after !== null && $after !== '' ? '********' : null;
} else {
$old_values[$key] = $before[$key];
$new_values[$key] = $after;
}
}

if ($new_values !== []) {
AuditLog::record('settings.updated', 'Settings', null, $old_values, $new_values);
}
}

/**
Expand Down
11 changes: 11 additions & 0 deletions includes/Admin/class-admin-users.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Escalated\Admin;

use Escalated\Models\AuditLog;

/**
* Users management admin page.
*
Expand Down Expand Up @@ -191,6 +193,15 @@ public static function update_role(int $user_id, string $role, bool $value, ?int
}
}

AuditLog::record(
$value ? 'user.role_granted' : 'user.role_revoked',
'User',
(int) $user->ID,
null,
['role' => $role, 'value' => $value],
$current_user_id
);

return ['ok' => true];
}

Expand Down
1 change: 1 addition & 0 deletions includes/Api/class-api-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public function register_routes(): void
new Dashboard_Controller,
new Api_Token_Controller,
new Two_Factor_Controller,
new Audit_Log_Controller,
new Events_Controller,
new Widget_Controller,
new Article_Controller,
Expand Down
24 changes: 24 additions & 0 deletions includes/Api/class-api-token-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
namespace Escalated\Api;

use Escalated\Models\ApiToken;
use Escalated\Models\AuditLog;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
Expand All @@ -20,6 +21,16 @@ class Api_Token_Controller extends Base_Controller
*/
protected $rest_base = 'admin/api-tokens';

/**
* User ID resolved by the permission callback for the current request.
*
* Bearer-token requests never call wp_set_current_user, so get_current_user_id()
* is 0 inside handlers — we cache the resolved token user here for auditing.
*
* @var int|null
*/
protected $acting_user_id = null;

/**
* Register routes.
*/
Expand Down Expand Up @@ -111,6 +122,8 @@ public function admin_permissions_check(WP_REST_Request $request)
return $this->error('escalated_unauthorized', __('Invalid or expired API token.', 'escalated'), 401);
}

$this->acting_user_id = $user_id;

$user = get_userdata($user_id);

if (! $user || ! $user->has_cap('escalated_api_token_manage')) {
Expand Down Expand Up @@ -232,6 +245,12 @@ public function create_item($request)

$token = ApiToken::find($result['record']->id);

AuditLog::record('api_token.created', 'ApiToken', (int) $token->id, null, [
'name' => $name,
'user_id' => $user_id,
'abilities' => $abilities,
], $this->acting_user_id);

return $this->success([
'message' => __('API token created successfully. Store this token securely - it will not be shown again.', 'escalated'),
'token' => [
Expand Down Expand Up @@ -274,6 +293,11 @@ public function delete_item($request)
return $this->error('escalated_delete_failed', __('Failed to revoke API token.', 'escalated'), 500);
}

AuditLog::record('api_token.deleted', 'ApiToken', $token_id, [
'name' => $token->name,
'user_id' => (int) $token->user_id,
], null, $this->acting_user_id);

return $this->success([
'message' => __('API token revoked successfully.', 'escalated'),
]);
Expand Down
23 changes: 21 additions & 2 deletions includes/Api/class-article-category-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
namespace Escalated\Api;

use Escalated\Models\ArticleCategory;
use Escalated\Models\AuditLog;
use WP_Error;
use WP_REST_Request;
use WP_REST_Server;
Expand Down Expand Up @@ -132,6 +133,11 @@ public function store(WP_REST_Request $request)
return $this->error('escalated_create_failed', __('Failed to create category.', 'escalated'), 500);
}

AuditLog::record('kb_category.created', 'ArticleCategory', (int) $id, null, [
'name' => $validated['name'],
'slug' => $validated['slug'],
]);

return $this->success(['id' => (int) $id, 'category' => $this->format_category(ArticleCategory::find($id))], 201);
}

Expand All @@ -141,7 +147,8 @@ public function store(WP_REST_Request $request)
public function update(WP_REST_Request $request)
{
$id = (int) $request->get_param('id');
if (! ArticleCategory::find($id)) {
$existing = ArticleCategory::find($id);
if (! $existing) {
return $this->error('escalated_not_found', __('Category not found.', 'escalated'), 404);
}

Expand All @@ -160,6 +167,12 @@ public function update(WP_REST_Request $request)
return $this->error('escalated_update_failed', __('Failed to update category.', 'escalated'), 500);
}

AuditLog::record('kb_category.updated', 'ArticleCategory', $id, [
'name' => $existing->name,
], [
'name' => $validated['name'],
]);

return $this->success(['category' => $this->format_category(ArticleCategory::find($id))]);
}

Expand All @@ -169,12 +182,18 @@ public function update(WP_REST_Request $request)
public function destroy(WP_REST_Request $request)
{
$id = (int) $request->get_param('id');
if (! ArticleCategory::find($id)) {
$existing = ArticleCategory::find($id);
if (! $existing) {
return $this->error('escalated_not_found', __('Category not found.', 'escalated'), 404);
}

ArticleCategory::delete($id);

AuditLog::record('kb_category.deleted', 'ArticleCategory', $id, [
'name' => $existing->name,
'slug' => $existing->slug,
], null);

return $this->success(null, 204);
}

Expand Down
24 changes: 23 additions & 1 deletion includes/Api/class-article-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

use Escalated\Models\Article;
use Escalated\Models\ArticleCategory;
use Escalated\Models\AuditLog;
use WP_Error;
use WP_REST_Request;
use WP_REST_Server;
Expand Down Expand Up @@ -201,6 +202,12 @@ public function store(WP_REST_Request $request)
return $this->error('escalated_create_failed', __('Failed to create article.', 'escalated'), 500);
}

AuditLog::record('kb_article.created', 'Article', (int) $id, null, [
'title' => $validated['title'],
'slug' => $validated['slug'],
'status' => $validated['status'],
]);

return $this->success(['id' => (int) $id, 'article' => $this->format_article(Article::find($id))], 201);
}

Expand Down Expand Up @@ -235,6 +242,14 @@ public function update(WP_REST_Request $request)
return $this->error('escalated_update_failed', __('Failed to update article.', 'escalated'), 500);
}

AuditLog::record('kb_article.updated', 'Article', $id, [
'title' => $article->title,
'status' => $article->status,
], [
'title' => $validated['title'],
'status' => $validated['status'],
]);

return $this->success(['article' => $this->format_article(Article::find($id))]);
}

Expand All @@ -244,12 +259,19 @@ public function update(WP_REST_Request $request)
public function destroy(WP_REST_Request $request)
{
$id = (int) $request->get_param('id');
if (! Article::find($id)) {
$article = Article::find($id);
if (! $article) {
return $this->error('escalated_not_found', __('Article not found.', 'escalated'), 404);
}

Article::delete($id);

AuditLog::record('kb_article.deleted', 'Article', $id, [
'title' => $article->title,
'slug' => $article->slug,
'status' => $article->status,
], null);

return $this->success(null, 204);
}

Expand Down
Loading