diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml
index 51972b9..4a634d0 100644
--- a/.github/workflows/plugin-ci-workflow.yml
+++ b/.github/workflows/plugin-ci-workflow.yml
@@ -238,3 +238,8 @@ jobs:
exit 1
fi
+ CLI_STATUS=$(mysql -u cactiuser -p'cactiuser' -h 127.0.0.1 cacti -se "select request_status from audit_log where action = 'cli' order by id desc limit 1;")
+ if [ "$CLI_STATUS" != "completed" ]; then
+ echo "Unexpected CLI request status: $CLI_STATUS"
+ exit 1
+ fi
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8954bc4..aeb9096 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
--- develop ---
+* feature: Verify user realm permission saves against the resulting database state
+* security: Group Audit Log User and Audit Log Admin permissions under Audit Plugin
* feature: Add normalized compliance event identifiers, categories, actors, targets, outcomes, timing, and integrity metadata
* feature: Deliver finalized request outcomes to external log consumers
* feature: Audit audit-log views, searches, event detail access, exports, and purges
diff --git a/README.md b/README.md
index b2bd0d8..fd51efc 100644
--- a/README.md
+++ b/README.md
@@ -53,11 +53,22 @@ not by itself prove that page-specific validation or database work succeeded.
`operation_outcome` remains `unknown` unless an authoritative Cacti 1.2.x hook
or plugin-owned operation supplies the result.
+For direct user realm permission saves, the plugin verifies the resulting
+`user_auth_realm` rows against the submitted realm set after Cacti processes the
+request. Matching state is recorded as `success` with outcome reason
+`realm_permissions_verified`; a mismatch is recorded as `failure`.
+
The plugin also audits access to its own event list, searches, event details,
exports and purge operations. Logout and session-timeout events are captured
through Cacti's supported `logout_pre_session_destroy` hook. Database-level
changes, API activity and MFA events are outside the current Cacti 1.2.x scope.
+## Permissions
+
+The plugin groups its Cacti realms under the Audit Plugin permissions section.
+Audit Log User permits access to the audit log. Audit Log Admin permits plugin
+administration and purging audit events.
+
## Possible Bugs
If you figure out this problem, see the Cacti forums!
diff --git a/audit.php b/audit.php
index 67e7efd..19c26e8 100644
--- a/audit.php
+++ b/audit.php
@@ -39,7 +39,7 @@
exit;
}
- if (!api_plugin_user_realm_auth('audit_manage.php') || !csrf_check(false)) {
+ if (!audit_user_is_admin() || !csrf_check(false)) {
http_response_code(403);
exit;
}
@@ -92,6 +92,9 @@ function audit_render_event_details($data) {
$output .= '
' . __('Event ID:', 'audit') . ' ' . html_escape($data['event_uuid']) . '';
$output .= '
' . __('Request Status:', 'audit') . ' ' . html_escape($data['request_status']) . '';
$output .= '
' . __('Operation Outcome:', 'audit') . ' ' . html_escape($data['operation_outcome']) . '';
+ if ($data['outcome_reason'] != '') {
+ $output .= '
' . __('Outcome Reason:', 'audit') . ' ' . html_escape($data['outcome_reason']) . '';
+ }
$output .= '
' . __('External Delivery:', 'audit') . ' ' . html_escape($data['external_status']) . '';
if ($data['external_error'] != '') {
$output .= '
' . __('External Error:', 'audit') . ' ' . html_escape($data['external_error']) . '';
@@ -407,7 +410,7 @@ function audit_log() {
-
+
diff --git a/audit_functions.php b/audit_functions.php
index 882f50c..0d56410 100644
--- a/audit_functions.php
+++ b/audit_functions.php
@@ -1,5 +1,9 @@
'invalid',
+ 'outcome_reason' => 'realm_permissions_request_invalid'
+ );
+ }
+
+ $expected_realm_ids = array();
+ foreach ($post as $field => $value) {
+ $field = (string) $field;
+
+ if (strpos($field, 'section') !== 0) {
+ continue;
+ }
+
+ if (!preg_match('/^section([1-9][0-9]*)$/', $field, $matches)) {
+ return array(
+ 'type' => 'invalid',
+ 'outcome_reason' => 'realm_permissions_request_invalid'
+ );
+ }
+
+ $expected_realm_ids[] = (int) $matches[1];
+ }
+
+ $expected_realm_ids = array_values(array_unique($expected_realm_ids));
+ sort($expected_realm_ids, SORT_NUMERIC);
+
+ return array(
+ 'type' => 'user_realm_permissions',
+ 'target_user_id' => (int) $target_user_id,
+ 'expected_realm_ids' => $expected_realm_ids
+ );
+}
+
+function audit_verify_operation($verifier) {
+ if (!is_array($verifier) || empty($verifier['type'])) {
+ return array('outcome' => 'unknown', 'reason' => null);
+ }
+
+ if ($verifier['type'] == 'invalid') {
+ return array(
+ 'outcome' => 'unknown',
+ 'reason' => $verifier['outcome_reason'] ?? 'verification_request_invalid'
+ );
+ }
+
+ if ($verifier['type'] != 'user_realm_permissions') {
+ return array('outcome' => 'unknown', 'reason' => 'verification_type_unsupported');
+ }
+
+ $target_user_id = (int) ($verifier['target_user_id'] ?? 0);
+ $expected_realm_ids = $verifier['expected_realm_ids'] ?? array();
+ $user_count = db_fetch_cell_prepared('SELECT COUNT(*) FROM user_auth WHERE id = ?', array($target_user_id));
+
+ if ($user_count === false) {
+ return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed');
+ }
+
+ if ((int) $user_count !== 1) {
+ return array('outcome' => 'failure', 'reason' => 'target_user_not_found');
+ }
+
+ $rows = db_fetch_assoc_prepared('SELECT realm_id
+ FROM user_auth_realm
+ WHERE user_id = ?
+ ORDER BY realm_id',
+ array($target_user_id));
+
+ if (!is_array($rows)) {
+ return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed');
+ }
+
+ $actual_realm_ids = array();
+ foreach ($rows as $row) {
+ if (!isset($row['realm_id']) || !is_numeric($row['realm_id'])) {
+ return array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed');
+ }
+
+ $actual_realm_ids[] = (int) $row['realm_id'];
+ }
+
+ $actual_realm_ids = array_values(array_unique($actual_realm_ids));
+ sort($actual_realm_ids, SORT_NUMERIC);
+
+ if ($actual_realm_ids === $expected_realm_ids) {
+ return array('outcome' => 'success', 'reason' => 'realm_permissions_verified');
+ }
+
+ return array('outcome' => 'failure', 'reason' => 'realm_permissions_mismatch');
+}
+
+function audit_finalize_request($id, $started_at = null, $verifier = null) {
$status_code = http_response_code();
$status_code = is_int($status_code) ? $status_code : 200;
$request_status = audit_request_status(error_get_last(), $status_code);
$outcome = $request_status == 'failed' ? 'failure' : 'unknown';
+ $outcome_reason = $request_status == 'failed' ? 'request_failed' : null;
+
+ if ($request_status == 'completed' && $verifier !== null) {
+ $verification = audit_verify_operation($verifier);
+ $outcome = $verification['outcome'];
+ $outcome_reason = $verification['reason'];
+ }
+
$duration_ms = $started_at === null ? null : max(0, (int) round((microtime(true) - $started_at) * 1000));
$completed_time = audit_utc_time();
db_execute_prepared("UPDATE audit_log
SET request_status = ?,
+ outcome_reason = CASE WHEN operation_outcome = 'unknown' THEN ? ELSE outcome_reason END,
operation_outcome = CASE WHEN operation_outcome = 'unknown' THEN ? ELSE operation_outcome END,
http_status = ?,
completed_time = ?,
duration_ms = ?
WHERE id = ?
AND request_status = 'started'",
- array($request_status, $outcome, $status_code, $completed_time, $duration_ms, $id));
+ array($request_status, $outcome_reason, $outcome, $status_code, $completed_time, $duration_ms, $id));
$event = db_fetch_row_prepared('SELECT * FROM audit_log WHERE id = ?', array($id));
if (cacti_sizeof($event)) {
@@ -580,8 +692,9 @@ function audit_config_insert() {
}
$target_id = $post['id'] ?? null;
- $post = audit_json_encode($post);
$page = basename($_SERVER['SCRIPT_NAME']);
+ $verifier = audit_operation_verifier_for_request($page, $post);
+ $post = audit_json_encode($post);
$user_id = (isset($_SESSION['sess_user_id']) ? $_SESSION['sess_user_id'] : 0);
$event_time = audit_utc_time($started_at);
@@ -652,7 +765,7 @@ function audit_config_insert() {
$_SERVER['REQUEST_METHOD'] ?? null
));
$audit_id = db_fetch_insert_id();
- register_shutdown_function('audit_finalize_request', $audit_id, $started_at);
+ register_shutdown_function('audit_finalize_request', $audit_id, $started_at, $verifier);
if ($external_logging && $audit_log == '') {
set_config_option('audit_log_external_path', $base . '/log/audit.log');
diff --git a/setup.php b/setup.php
index 5ccb4e3..4c1be32 100644
--- a/setup.php
+++ b/setup.php
@@ -37,12 +37,69 @@ function plugin_audit_install() {
/* hook for table replication */
api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php');
- api_plugin_register_realm('audit', 'audit.php', __('View Cacti Audit Log', 'audit'), 1);
- api_plugin_register_realm('audit', 'audit_manage.php', __('Manage Cacti Audit Log', 'audit'), 1);
+ audit_setup_realms(true);
audit_setup_table();
}
+function audit_setup_realms($grant_installing_user = false) {
+ $realms = array(
+ 'audit.php' => __('Audit Log User', 'audit'),
+ 'audit_manage.php' => __('Audit Log Admin', 'audit')
+ );
+
+ foreach ($realms as $file => $display) {
+ api_plugin_register_realm('audit', $file, $display, $grant_installing_user ? 1 : 0);
+ }
+
+ if (!$grant_installing_user) {
+ $admin_user = (int) read_config_option('admin_user');
+
+ if ($admin_user > 0) {
+ $realm_ids = db_fetch_assoc_prepared('SELECT id + 100 AS realm_id
+ FROM plugin_realms
+ WHERE plugin = ?
+ AND file IN (?, ?)',
+ array('audit', 'audit.php', 'audit_manage.php'));
+
+ foreach ($realm_ids as $realm) {
+ db_execute_prepared('REPLACE INTO user_auth_realm
+ (user_id, realm_id)
+ VALUES (?, ?)',
+ array($admin_user, $realm['realm_id']));
+ }
+ }
+ }
+}
+
+function audit_remove_deprecated_realms() {
+ $realms = db_fetch_assoc_prepared('SELECT id
+ FROM plugin_realms
+ WHERE plugin = ?
+ AND file = ?',
+ array('audit', 'audit_purge.php'));
+
+ foreach ($realms as $realm) {
+ $realm_id = $realm['id'] + 100;
+
+ db_execute_prepared('DELETE FROM user_auth_realm
+ WHERE realm_id = ?',
+ array($realm_id));
+
+ db_execute_prepared('DELETE FROM user_auth_group_realm
+ WHERE realm_id = ?',
+ array($realm_id));
+
+ db_execute_prepared('DELETE FROM plugin_realms
+ WHERE id = ?',
+ array($realm['id']));
+ }
+
+ if (cacti_sizeof($realms)) {
+ api_plugin_replicate_config();
+ }
+}
+
function plugin_audit_uninstall() {
db_execute('DROP TABLE IF EXISTS audit_log');
return true;
@@ -103,6 +160,8 @@ function audit_check_upgrade() {
db_execute("ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_status varchar(20) NOT NULL DEFAULT 'unknown' AFTER object_data");
db_execute('ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS external_error varchar(1024) DEFAULT NULL AFTER external_status');
audit_upgrade_event_schema();
+ audit_setup_realms();
+ audit_remove_deprecated_realms();
db_execute_prepared('UPDATE plugin_config
SET version = ?
@@ -121,7 +180,6 @@ function audit_check_upgrade() {
api_plugin_register_hook('audit', 'replicate_out', 'audit_replicate_out', 'setup.php', '1');
api_plugin_register_hook('audit', 'is_console_page', 'audit_is_console_page', 'setup.php', 1);
api_plugin_register_hook('audit', 'logout_pre_session_destroy', 'audit_logout_pre_session_destroy', 'setup.php', 1);
- api_plugin_register_realm('audit', 'audit_manage.php', __('Manage Cacti Audit Log', 'audit'), 1);
}
}
@@ -378,7 +436,7 @@ function audit_config_arrays() {
$menu[__('Utilities')]['plugins/audit/audit.php'] = __('Audit Log', 'audit');
if (function_exists('auth_augment_roles')) {
- auth_augment_roles(__('System Administration'), array('audit.php'));
+ auth_augment_roles(__('Audit Plugin', 'audit'), array('audit.php', 'audit_manage.php'));
}
audit_check_upgrade();
diff --git a/tests/controller_security_test.php b/tests/controller_security_test.php
index 0f38d6d..890b682 100644
--- a/tests/controller_security_test.php
+++ b/tests/controller_security_test.php
@@ -1,14 +1,16 @@
__('Audit Log User'",
+ "'audit_manage.php' => __('Audit Log Admin'",
+ 'audit_setup_realms(true)',
+ 'audit_setup_realms()',
+ 'audit_remove_deprecated_realms()',
+ "auth_augment_roles(__('Audit Plugin', 'audit'), array('audit.php', 'audit_manage.php'))",
'api_plugin_register_hook(\'audit\', \'replicate_out\'',
'request_status',
'ADD COLUMN IF NOT EXISTS external_status',
@@ -41,6 +48,35 @@
}
}
+$required_verifier_fragments = array(
+ 'audit_operation_verifier_for_request',
+ "'user_realm_permissions'",
+ "'realm_permissions_verified'",
+ "register_shutdown_function('audit_finalize_request', \$audit_id, \$started_at, \$verifier)"
+);
+
+foreach ($required_verifier_fragments as $fragment) {
+ if (strpos($functions, $fragment) === false) {
+ fwrite(STDERR, 'Missing operation verification requirement: ' . $fragment . PHP_EOL);
+ exit(1);
+ }
+}
+
+if (strpos($functions, "api_plugin_user_realm_auth('audit_manage.php')") === false) {
+ fwrite(STDERR, 'Audit administrators must be authorized to purge.' . PHP_EOL);
+ exit(1);
+}
+
+if (strpos($functions, "api_plugin_user_realm_auth('audit_purge.php')") !== false) {
+ fwrite(STDERR, 'The deprecated delegated purge permission must not authorize purge.' . PHP_EOL);
+ exit(1);
+}
+
+if (substr_count($controller, 'audit_user_is_admin()') < 2) {
+ fwrite(STDERR, 'Purge authorization must protect both the action and its UI control.' . PHP_EOL);
+ exit(1);
+}
+
if (strpos($javascript, "loadPageNoHeader('audit.php?action=purge") !== false) {
fwrite(STDERR, 'Purge must not use the legacy GET request path.' . PHP_EOL);
exit(1);
diff --git a/tests/security_functions_test.php b/tests/security_functions_test.php
index a87b1b3..4a736a5 100644
--- a/tests/security_functions_test.php
+++ b/tests/security_functions_test.php
@@ -2,6 +2,45 @@
require_once dirname(__DIR__) . '/audit_functions.php';
+$audit_test_realms = array();
+$audit_test_existing_users = array();
+$audit_test_user_realms = array();
+$audit_test_user_query_failure = false;
+$audit_test_realm_query_failure = false;
+
+function api_plugin_user_realm_auth($filename = '') {
+ global $audit_test_realms;
+
+ return !empty($audit_test_realms[$filename]);
+}
+
+function db_fetch_cell_prepared($sql, $params = array()) {
+ global $audit_test_existing_users, $audit_test_user_query_failure;
+
+ if ($audit_test_user_query_failure) {
+ return false;
+ }
+
+ $user_id = (int) ($params[0] ?? 0);
+
+ return in_array($user_id, $audit_test_existing_users, true) ? 1 : 0;
+}
+
+function db_fetch_assoc_prepared($sql, $params = array()) {
+ global $audit_test_user_realms, $audit_test_realm_query_failure;
+
+ if ($audit_test_realm_query_failure) {
+ return false;
+ }
+
+ $user_id = (int) ($params[0] ?? 0);
+ $realm_ids = $audit_test_user_realms[$user_id] ?? array();
+
+ return array_map(function($realm_id) {
+ return array('realm_id' => $realm_id);
+ }, $realm_ids);
+}
+
function audit_test_assert_same($expected, $actual, $message) {
if ($expected !== $actual) {
fwrite(STDERR, $message . PHP_EOL);
@@ -11,6 +50,84 @@ function audit_test_assert_same($expected, $actual, $message) {
}
}
+audit_test_assert_same(false, audit_user_is_admin(), 'Audit users must not be treated as audit administrators.');
+$audit_test_realms['audit_manage.php'] = true;
+audit_test_assert_same(true, audit_user_is_admin(), 'Audit plugin administrators must be able to purge.');
+$audit_test_realms = array();
+
+$verifier = audit_operation_verifier_for_request('user_admin.php', array(
+ 'action' => 'save',
+ 'id' => '4',
+ 'save_component_realm_perms' => '1',
+ 'section110' => 'on',
+ 'section106' => 'on'
+));
+audit_test_assert_same(
+ array(
+ 'type' => 'user_realm_permissions',
+ 'target_user_id' => 4,
+ 'expected_realm_ids' => array(106, 110)
+ ),
+ $verifier,
+ 'User realm permission saves must capture a normalized post-condition verifier.'
+);
+
+audit_test_assert_same(
+ null,
+ audit_operation_verifier_for_request('host.php', array('id' => '4')),
+ 'Unrelated requests must not receive a user realm verifier.'
+);
+
+$invalid_verifier = audit_operation_verifier_for_request('user_admin.php', array(
+ 'id' => 'invalid',
+ 'save_component_realm_perms' => '1'
+));
+audit_test_assert_same(
+ 'invalid',
+ $invalid_verifier['type'],
+ 'Invalid realm permission requests must not be verified as successful.'
+);
+
+$audit_test_existing_users = array(4);
+$audit_test_user_realms = array(4 => array(110, 106));
+audit_test_assert_same(
+ array('outcome' => 'success', 'reason' => 'realm_permissions_verified'),
+ audit_verify_operation($verifier),
+ 'Matching stored realm permissions must produce a verified success outcome.'
+);
+
+$audit_test_user_realms = array(4 => array(106));
+audit_test_assert_same(
+ array('outcome' => 'failure', 'reason' => 'realm_permissions_mismatch'),
+ audit_verify_operation($verifier),
+ 'Mismatched stored realm permissions must produce a failure outcome.'
+);
+
+$audit_test_existing_users = array();
+audit_test_assert_same(
+ array('outcome' => 'failure', 'reason' => 'target_user_not_found'),
+ audit_verify_operation($verifier),
+ 'A missing target user must produce a failure outcome.'
+);
+
+$audit_test_existing_users = array(4);
+$audit_test_user_query_failure = true;
+audit_test_assert_same(
+ array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'),
+ audit_verify_operation($verifier),
+ 'A failed target-user query must preserve an unknown outcome.'
+);
+$audit_test_user_query_failure = false;
+
+$audit_test_existing_users = array(4);
+$audit_test_realm_query_failure = true;
+audit_test_assert_same(
+ array('outcome' => 'unknown', 'reason' => 'realm_permissions_verification_failed'),
+ audit_verify_operation($verifier),
+ 'A failed verification query must preserve an unknown outcome.'
+);
+$audit_test_realm_query_failure = false;
+
$request = array(
'username' => 'operator',
'password' => 'top-secret',