Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.85% covered (warning)
80.85%
38 / 47
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
TemporaryAccountLogger
80.85% covered (warning)
80.85%
38 / 47
57.14% covered (warning)
57.14%
4 / 7
11.85
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 logViewIPs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logAccessEnabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 logAccessDisabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 debouncedLog
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
3.01
 log
53.85% covered (warning)
53.85%
7 / 13
0.00% covered (danger)
0.00%
0 / 1
3.88
 createManualLogEntry
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\CheckUser\Logging;
4
5use ManualLogEntry;
6use MediaWiki\Title\Title;
7use MediaWiki\User\ActorStore;
8use MediaWiki\User\UserIdentity;
9use Psr\Log\LoggerInterface;
10use Wikimedia\Assert\Assert;
11use Wikimedia\Assert\ParameterAssertionException;
12use Wikimedia\Rdbms\DBError;
13use Wikimedia\Rdbms\IDatabase;
14
15/**
16 * Defines the API for the component responsible for logging the following interactions:
17 *
18 * - A user views IP addresses for a temporary account
19 * - A user enables temporary account IP viewing
20 * - A user disables temporary account IP viewing
21 *
22 * All the above interactions will be logged to the `logging` table with a log type
23 * `checkuser-temporary-account`.
24 */
25class TemporaryAccountLogger {
26    /**
27     * Represents a user (the performer) viewing IP addresses for a temporary account.
28     *
29     * @var string
30     */
31    public const ACTION_VIEW_IPS = 'view-ips';
32
33    /**
34     * Represents a user enabling or disabling their own access to view IPs
35     *
36     * @var string
37     */
38    public const ACTION_CHANGE_ACCESS = 'change-access';
39
40    /**
41     * @var string
42     */
43    public const ACTION_ACCESS_ENABLED = 'enable';
44
45    /**
46     * @var string
47     */
48    public const ACTION_ACCESS_DISABLED = 'disable';
49
50    /**
51     * @var string
52     */
53    public const LOG_TYPE = 'checkuser-temporary-account';
54
55    private ActorStore $actorStore;
56    private LoggerInterface $logger;
57    private IDatabase $dbw;
58
59    private int $delay;
60
61    /**
62     * @param ActorStore $actorStore
63     * @param LoggerInterface $logger
64     * @param IDatabase $dbw
65     * @param int $delay The number of seconds after which a duplicate log entry can be
66     *  created for a debounced log
67     * @throws ParameterAssertionException
68     */
69    public function __construct(
70        ActorStore $actorStore,
71        LoggerInterface $logger,
72        IDatabase $dbw,
73        int $delay
74    ) {
75        Assert::parameter( $delay > 0, 'delay', 'delay must be positive' );
76
77        $this->actorStore = $actorStore;
78        $this->logger = $logger;
79        $this->dbw = $dbw;
80        $this->delay = $delay;
81    }
82
83    /**
84     * Logs the user (the performer) viewing IP addresses for a temporary account.
85     *
86     * @param UserIdentity $performer
87     * @param string $tempUser
88     * @param int $timestamp
89     */
90    public function logViewIPs( UserIdentity $performer, string $tempUser, int $timestamp ): void {
91        $this->debouncedLog( $performer, $tempUser, self::ACTION_VIEW_IPS, $timestamp );
92    }
93
94    /**
95     * Log when the user enables their own access
96     *
97     * @param UserIdentity $performer
98     */
99    public function logAccessEnabled( UserIdentity $performer ): void {
100        $params = [
101            '4::changeType' => self::ACTION_ACCESS_ENABLED,
102        ];
103        $this->log( $performer, $performer->getName(), self::ACTION_CHANGE_ACCESS, $params );
104    }
105
106    /**
107     * Log when the user disables their own access
108     *
109     * @param UserIdentity $performer
110     */
111    public function logAccessDisabled( UserIdentity $performer ): void {
112        $params = [
113            '4::changeType' => self::ACTION_ACCESS_DISABLED,
114        ];
115        $this->log( $performer, $performer->getName(), self::ACTION_CHANGE_ACCESS, $params );
116    }
117
118    /**
119     * @param UserIdentity $performer
120     * @param string $tempUser
121     * @param string $action
122     * @param int $timestamp
123     * @param array|null $params
124     */
125    private function debouncedLog(
126        UserIdentity $performer,
127        string $tempUser,
128        string $action,
129        int $timestamp,
130        ?array $params = []
131    ): void {
132        $timestampMinusDelay = $timestamp - $this->delay;
133        $actorId = $this->actorStore->findActorId( $performer, $this->dbw );
134        if ( !$actorId ) {
135            $this->log( $performer, $tempUser, $action, $params );
136            return;
137        }
138
139        $logline = $this->dbw->newSelectQueryBuilder()
140            ->select( '*' )
141            ->from( 'logging' )
142            ->where( [
143                'log_type' => self::LOG_TYPE,
144                'log_action' => $action,
145                'log_actor' => $actorId,
146                'log_namespace' => NS_USER,
147                'log_title' => $tempUser,
148                $this->dbw->expr( 'log_timestamp', '>', $this->dbw->timestamp( $timestampMinusDelay ) ),
149            ] )
150            ->fetchRow();
151
152        if ( !$logline ) {
153            $this->log( $performer, $tempUser, $action, $params, $timestamp );
154        }
155    }
156
157    /**
158     * @param UserIdentity $performer
159     * @param string $tempUser
160     * @param string $action
161     * @param array $params
162     * @param int|null $timestamp
163     */
164    private function log(
165        UserIdentity $performer,
166        string $tempUser,
167        string $action,
168        array $params,
169        ?int $timestamp = null
170    ): void {
171        $logEntry = $this->createManualLogEntry( $action );
172        $logEntry->setPerformer( $performer );
173        $logEntry->setTarget( Title::makeTitle( NS_USER, $tempUser ) );
174        $logEntry->setParameters( $params );
175
176        if ( $timestamp ) {
177            $logEntry->setTimestamp( wfTimestamp( TS_MW, $timestamp ) );
178        }
179
180        try {
181            $logEntry->insert( $this->dbw );
182        } catch ( DBError $e ) {
183            $this->logger->critical(
184                'CheckUser temporary account log entry was not recorded. ' .
185                'This means checks can occur without being auditable. ' .
186                'Immediate fix required.'
187            );
188        }
189    }
190
191    /**
192     * There is no `LogEntryFactory` (or `Logger::insert()` method) in MediaWiki Core to inject
193     * via the constructor so use this method to isolate the creation of `LogEntry` objects during
194     * testing.
195     *
196     * @private
197     *
198     * @param string $subtype
199     * @return ManualLogEntry
200     */
201    protected function createManualLogEntry( string $subtype ): ManualLogEntry {
202        return new ManualLogEntry( self::LOG_TYPE, $subtype );
203    }
204}