AuditHook   F
last analyzed

Complexity

Total Complexity 75

Size/Duplication

Total Lines 423
Duplicated Lines 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
eloc 211
c 4
b 0
f 0
dl 0
loc 423
rs 2.4
wmc 75

16 Methods

Rating   Name   Duplication   Size   Complexity  
A bind_manipulation_capture() 0 40 5
A getAuditLogger() 0 8 1
B onAfterPublish() 0 37 10
A memberAutoLoggedIn() 0 6 2
A onAfterUnpublish() 0 14 4
A onAfterInit() 0 15 5
A onAfterDuplicate() 0 14 4
A afterMemberLoggedIn() 0 6 2
A onAfterRevertToLive() 0 15 4
A onAfterDelete() 0 14 4
A logPermissionDenied() 0 8 2
A onBeforeInit() 0 2 1
A onAfterRestoreToStage() 0 14 4
A authenticationFailed() 0 15 4
A afterMemberLoggedOut() 0 6 2
F handle_manipulation() 0 119 21

How to fix   Complexity   

Complex Class

Complex classes like AuditHook often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use AuditHook, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace SilverStripe\Auditor;
4
5
use SilverStripe\Control\Email\Email;
6
use SilverStripe\Core\Injector\Injector;
7
use SilverStripe\ORM\Connect\Database;
8
use SilverStripe\ORM\DataExtension;
9
use SilverStripe\ORM\DataObject;
10
use SilverStripe\ORM\DataObjectSchema;
11
use SilverStripe\ORM\DB;
12
use SilverStripe\Security\Group;
13
use SilverStripe\Security\Member;
14
use SilverStripe\Security\PermissionRole;
15
use SilverStripe\Security\PermissionRoleCode;
16
use SilverStripe\Security\Security;
17
18
/**
19
 * Provides logging hooks that are inserted into Framework objects.
20
 */
21
class AuditHook extends DataExtension
22
{
23
    protected function getAuditLogger()
24
    {
25
        // We cannot use the 'dependencies' private property, because this will prevent us
26
        // from injecting a mock logger for testing. This is because by the time the testing framework
27
        // is instantiated, the part of the object graph where AuditLogger lives has already been created.
28
        // In other words, Framework does not permit hooking in early enough to adjust the graph when
29
        // 'dependencies' is used :-(
30
        return Injector::inst()->get('AuditLogger');
31
    }
32
33
    /**
34
     * This will bind a new class dynamically so we can hook into manipulation
35
     * and capture it. It creates a new PHP file in the temp folder, then
36
     * loads it and sets it as the active DB class.
37
     *
38
     * @deprecated 2.1...3.0 Please use ProxyDBExtension with the tractorcow/silverstripe-proxy-db module instead
39
     */
40
    public static function bind_manipulation_capture()
41
    {
42
        $current = DB::get_conn();
43
        if (!$current || !$current->getConnector()->getSelectedDatabase() || @$current->isManipulationLoggingCapture) {
0 ignored issues
show
introduced by
$current is of type SilverStripe\ORM\Connect\Database, thus it always evaluated to true.
Loading history...
Bug introduced by
The property isManipulationLoggingCapture does not seem to exist on SilverStripe\ORM\Connect\Database.
Loading history...
44
            return;
45
        } // If not yet set, or its already captured, just return
46
47
        $type = get_class($current);
48
        $sanitisedType = str_replace('\\', '_', $type);
49
        $file = TEMP_FOLDER . "/.cache.CLC.$sanitisedType";
50
        $dbClass = 'AuditLoggerManipulateCapture_' . $sanitisedType;
51
52
        if (!is_file($file)) {
53
            file_put_contents($file, "<?php
54
				class $dbClass extends $type
55
                {
56
					public \$isManipulationLoggingCapture = true;
57
58
					public function manipulate(\$manipulation)
59
                    {
60
						\SilverStripe\Auditor\AuditHook::handle_manipulation(\$manipulation);
61
						return parent::manipulate(\$manipulation);
62
					}
63
				}
64
			");
65
        }
66
67
        require_once $file;
68
69
        /** @var Database $captured */
70
        $captured = new $dbClass();
71
72
        $captured->setConnector($current->getConnector());
73
        $captured->setQueryBuilder($current->getQueryBuilder());
74
        $captured->setSchemaManager($current->getSchemaManager());
75
76
        // The connection might have had it's name changed (like if we're currently in a test)
77
        $captured->selectDatabase($current->getConnector()->getSelectedDatabase());
78
79
        DB::set_conn($captured);
80
    }
81
82
    public static function handle_manipulation($manipulation)
83
    {
84
        $auditLogger = Injector::inst()->get('AuditLogger');
85
86
        $currentMember = Security::getCurrentUser();
87
        if (!($currentMember && $currentMember->exists())) {
88
            return false;
89
        }
90
91
        /** @var DataObjectSchema $schema */
92
        $schema = DataObject::getSchema();
93
94
        // The tables that we watch for manipulation on
95
        $watchedTables = [
96
            $schema->tableName(Member::class),
97
            $schema->tableName(Group::class),
98
            $schema->tableName(PermissionRole::class),
99
            $schema->tableName(PermissionRoleCode::class),
100
        ];
101
102
        foreach ($manipulation as $table => $details) {
103
            if (!in_array($details['command'], ['update', 'insert'])) {
104
                continue;
105
            }
106
107
            // logging writes to specific tables (just not when logging in, as it's noise)
108
            if (in_array($table, $watchedTables)
109
                && !preg_match('/Security/', @$_SERVER['REQUEST_URI'])
110
                && isset($details['id'])
111
            ) {
112
                $className = $schema->tableClass($table);
113
114
                $data = $className::get()->byID($details['id']);
115
                if (!$data) {
116
                    continue;
117
                }
118
                $actionText = 'modified '.$table;
119
120
                $extendedText = '';
121
                if ($table === $schema->tableName(Group::class)) {
122
                    $extendedText = sprintf(
123
                        'Effective permissions: %s',
124
                        implode($data->Permissions()->column('Code'), ', ')
0 ignored issues
show
Bug introduced by
', ' of type string is incompatible with the type array expected by parameter $pieces of implode(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

124
                        implode($data->Permissions()->column('Code'), /** @scrutinizer ignore-type */ ', ')
Loading history...
125
                    );
126
                }
127
                if ($table === $schema->tableName(PermissionRole::class)) {
128
                    $extendedText = sprintf(
129
                        'Effective groups: %s, Effective permissions: %s',
130
                        implode($data->Groups()->column('Title'), ', '),
131
                        implode($data->Codes()->column('Code'), ', ')
132
                    );
133
                }
134
                if ($table === $schema->tableName(PermissionRoleCode::class)) {
135
                    $extendedText = sprintf(
136
                        'Effective code: %s',
137
                        $data->Code
138
                    );
139
                }
140
                if ($table === $schema->tableName(Member::class)) {
141
                    $extendedText = sprintf(
142
                        'Effective groups: %s',
143
                        implode($data->Groups()->column('Title'), ', ')
144
                    );
145
                }
146
147
                $auditLogger->info(sprintf(
148
                    '"%s" (ID: %s) %s (ID: %s, ClassName: %s, Title: "%s", %s)',
149
                    $currentMember->Email ?: $currentMember->Title,
150
                    $currentMember->ID,
151
                    $actionText,
152
                    $details['id'],
153
                    $data->ClassName,
154
                    $data->Title,
155
                    $extendedText
156
                ));
157
            }
158
159
            // log PermissionRole being added to a Group
160
            if ($table === $schema->tableName(Group::class) . '_Roles') {
161
                $role = PermissionRole::get()->byId($details['fields']['PermissionRoleID']);
162
                $group = Group::get()->byId($details['fields']['GroupID']);
163
164
                // if the permission role isn't already applied to the group
165
                if (!DB::query(sprintf(
166
                    'SELECT "ID" FROM "Group_Roles" WHERE "GroupID" = %s AND "PermissionRoleID" = %s',
167
                    $details['fields']['GroupID'],
168
                    $details['fields']['PermissionRoleID']
169
                ))->value()) {
170
                    $auditLogger->info(sprintf(
171
                        '"%s" (ID: %s) added PermissionRole "%s" (ID: %s) to Group "%s" (ID: %s)',
172
                        $currentMember->Email ?: $currentMember->Title,
173
                        $currentMember->ID,
174
                        $role->Title,
175
                        $role->ID,
176
                        $group->Title,
177
                        $group->ID
178
                    ));
179
                }
180
            }
181
182
            // log Member added to a Group
183
            if ($table === $schema->tableName(Group::class) . '_Members') {
184
                $member = Member::get()->byId($details['fields']['MemberID']);
185
                $group = Group::get()->byId($details['fields']['GroupID']);
186
187
                // if the user isn't already in the group, log they've been added
188
                if (!DB::query(sprintf(
189
                    'SELECT "ID" FROM "Group_Members" WHERE "GroupID" = %s AND "MemberID" = %s',
190
                    $details['fields']['GroupID'],
191
                    $details['fields']['MemberID']
192
                ))->value()) {
193
                    $auditLogger->info(sprintf(
194
                        '"%s" (ID: %s) added Member "%s" (ID: %s) to Group "%s" (ID: %s)',
195
                        $currentMember->Email ?: $currentMember->Title,
196
                        $currentMember->ID,
197
                        $member->Email ?: $member->Title,
198
                        $member->ID,
199
                        $group->Title,
200
                        $group->ID
201
                    ));
202
                }
203
            }
204
        }
205
    }
206
207
    /**
208
     * Log a record being published.
209
     */
210
    public function onAfterPublish(&$original)
211
    {
212
        $member = Security::getCurrentUser();
213
        if (!$member || !$member->exists()) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
214
            return false;
215
        }
216
217
        $effectiveViewerGroups = '';
218
        if ($this->owner->CanViewType === 'OnlyTheseUsers') {
219
            $originalViewerGroups = $original ? $original->ViewerGroups()->column('Title') : [];
220
            $effectiveViewerGroups = implode(', ', $originalViewerGroups);
221
        }
222
        if (!$effectiveViewerGroups) {
223
            $effectiveViewerGroups = $this->owner->CanViewType;
224
        }
225
226
        $effectiveEditorGroups = '';
227
        if ($this->owner->CanEditType === 'OnlyTheseUsers') {
228
            $originalEditorGroups =  $original ? $original->EditorGroups()->column('Title') : [];
229
            $effectiveEditorGroups = implode(', ', $originalEditorGroups);
230
        }
231
        if (!$effectiveEditorGroups) {
232
            $effectiveEditorGroups = $this->owner->CanEditType;
233
        }
234
235
        $this->getAuditLogger()->info(sprintf(
236
            '"%s" (ID: %s) published %s "%s" (ID: %s, Version: %s, ClassName: %s, Effective ViewerGroups: %s, '
237
            . 'Effective EditorGroups: %s)',
238
            $member->Email ?: $member->Title,
239
            $member->ID,
240
            $this->owner->singular_name(),
241
            $this->owner->Title,
242
            $this->owner->ID,
243
            $this->owner->Version,
244
            $this->owner->ClassName,
245
            $effectiveViewerGroups,
246
            $effectiveEditorGroups
247
        ));
248
    }
249
250
    /**
251
     * Log a record being unpublished.
252
     */
253
    public function onAfterUnpublish()
254
    {
255
        $member = Security::getCurrentUser();
256
        if (!$member || !$member->exists()) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
257
            return false;
258
        }
259
260
        $this->getAuditLogger()->info(sprintf(
261
            '"%s" (ID: %s) unpublished %s "%s" (ID: %s)',
262
            $member->Email ?: $member->Title,
263
            $member->ID,
264
            $this->owner->singular_name(),
265
            $this->owner->Title,
266
            $this->owner->ID
267
        ));
268
    }
269
270
    /**
271
     * Log a record being reverted to live.
272
     */
273
    public function onAfterRevertToLive()
274
    {
275
        $member = Security::getCurrentUser();
276
        if (!$member || !$member->exists()) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
277
            return false;
278
        }
279
280
        $this->getAuditLogger()->info(sprintf(
281
            '"%s" (ID: %s) reverted %s "%s" (ID: %s) to it\'s live version (#%d)',
282
            $member->Email ?: $member->Title,
283
            $member->ID,
284
            $this->owner->singular_name(),
285
            $this->owner->Title,
286
            $this->owner->ID,
287
            $this->owner->Version
288
        ));
289
    }
290
291
    /**
292
     * Log a record being duplicated.
293
     */
294
    public function onAfterDuplicate()
295
    {
296
        $member = Security::getCurrentUser();
297
        if (!$member || !$member->exists()) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
298
            return false;
299
        }
300
301
        $this->getAuditLogger()->info(sprintf(
302
            '"%s" (ID: %s) duplicated %s "%s" (ID: %s)',
303
            $member->Email ?: $member->Title,
304
            $member->ID,
305
            $this->owner->singular_name(),
306
            $this->owner->Title,
307
            $this->owner->ID
308
        ));
309
    }
310
311
    /**
312
     * Log a record being deleted.
313
     */
314
    public function onAfterDelete()
315
    {
316
        $member = Security::getCurrentUser();
317
        if (!$member || !$member->exists()) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
318
            return false;
319
        }
320
321
        $this->getAuditLogger()->info(sprintf(
322
            '"%s" (ID: %s) deleted %s "%s" (ID: %s)',
323
            $member->Email ?: $member->Title,
324
            $member->ID,
325
            $this->owner->singular_name(),
326
            $this->owner->Title,
327
            $this->owner->ID
328
        ));
329
    }
330
331
    /**
332
     * Log a record being restored to stage.
333
     */
334
    public function onAfterRestoreToStage()
335
    {
336
        $member = Security::getCurrentUser();
337
        if (!$member || !$member->exists()) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
338
            return false;
339
        }
340
341
        $this->getAuditLogger()->info(sprintf(
342
            '"%s" (ID: %s) restored %s "%s" to stage (ID: %s)',
343
            $member->Email ?: $member->Title,
344
            $member->ID,
345
            $this->owner->singular_name(),
346
            $this->owner->Title,
347
            $this->owner->ID
348
        ));
349
    }
350
351
    /**
352
     * Log successful login attempts.
353
     */
354
    public function afterMemberLoggedIn()
355
    {
356
        $this->getAuditLogger()->info(sprintf(
357
            '"%s" (ID: %s) successfully logged in',
358
            $this->owner->Email ?: $this->owner->Title,
359
            $this->owner->ID
360
        ));
361
    }
362
363
    /**
364
     * Log successfully restored sessions from "remember me" cookies ("auto login").
365
     */
366
    public function memberAutoLoggedIn()
367
    {
368
        $this->getAuditLogger()->info(sprintf(
369
            '"%s" (ID: %s) successfully restored autologin session',
370
            $this->owner->Email ?: $this->owner->Title,
371
            $this->owner->ID
372
        ));
373
    }
374
375
    /**
376
     * Log failed login attempts.
377
     */
378
    public function authenticationFailed($data)
379
    {
380
        // LDAP authentication uses a "Login" POST field instead of Email.
381
        $login = isset($data['Login'])
382
            ? $data['Login']
383
            : (isset($data[Email::class]) ? $data[Email::class] : '');
384
385
        if (empty($login)) {
386
            return $this->getAuditLogger()->warning(
387
                'Could not determine username/email of failed authentication. '.
388
                'This could be due to login form not using Email or Login field for POST data.'
389
            );
390
        }
391
392
        $this->getAuditLogger()->info(sprintf('Failed login attempt using email "%s"', $login));
393
    }
394
395
    /**
396
     * @deprecated 2.1...3.0 Use tractorcow/silverstripe-proxy-db instead
397
     */
398
    public function onBeforeInit()
399
    {
400
        // no-op
401
    }
402
403
    /**
404
     * Log permission failures (where the status is set after init of page).
405
     */
406
    public function onAfterInit()
407
    {
408
        // Suppress errors if dev/build necessary
409
        if (!Security::database_is_ready()) {
410
            return false;
411
        }
412
        $currentMember = Security::getCurrentUser();
413
        if (!$currentMember || !$currentMember->exists()) {
0 ignored issues
show
introduced by
$currentMember is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
414
            return false;
415
        }
416
417
        $statusCode = $this->owner->getResponse()->getStatusCode();
418
419
        if (substr($statusCode, 0, 1) == '4') {
420
            $this->logPermissionDenied($statusCode, $currentMember);
421
        }
422
    }
423
424
    protected function logPermissionDenied($statusCode, $member)
425
    {
426
        $this->getAuditLogger()->info(sprintf(
427
            'HTTP code %s - "%s" (ID: %s) is denied access to %s',
428
            $statusCode,
429
            $member->Email ?: $member->Title,
430
            $member->ID,
431
            $_SERVER['REQUEST_URI']
432
        ));
433
    }
434
435
    /**
436
     * Log successful logout.
437
     */
438
    public function afterMemberLoggedOut()
439
    {
440
        $this->getAuditLogger()->info(sprintf(
441
            '"%s" (ID: %s) successfully logged out',
442
            $this->owner->Email ?: $this->owner->Title,
443
            $this->owner->ID
444
        ));
445
    }
446
}
447