Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.67% covered (warning)
87.67%
64 / 73
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProtectedVarsAccessLogger
87.67% covered (warning)
87.67%
64 / 73
66.67% covered (warning)
66.67%
4 / 6
14.37
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
 logAccessEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logAccessDisabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logViewProtectedVariableValue
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
2.00
 log
84.62% covered (warning)
84.62%
44 / 52
0.00% covered (danger)
0.00%
0 / 1
8.23
 createManualLogEntry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter;
4
5use ManualLogEntry;
6use MediaWiki\Deferred\DeferredUpdates;
7use MediaWiki\MediaWikiServices;
8use MediaWiki\Title\Title;
9use MediaWiki\User\ActorStore;
10use MediaWiki\User\UserIdentity;
11use Profiler;
12use Psr\Log\LoggerInterface;
13use Wikimedia\Assert\Assert;
14use Wikimedia\Rdbms\DBError;
15use Wikimedia\Rdbms\IConnectionProvider;
16
17/**
18 * Defines the API for the component responsible for logging the following interactions:
19 *
20 * - A user enables protected variable viewing
21 * - A user disables protected variable viewing
22 */
23class ProtectedVarsAccessLogger {
24    /**
25     * Represents a user enabling their own access to view protected variables
26     *
27     * @var string
28     */
29    public const ACTION_CHANGE_ACCESS_ENABLED = 'change-access-enable';
30
31    /**
32     * Represents a user disabling their own access to view protected variables
33     *
34     * @var string
35     */
36    public const ACTION_CHANGE_ACCESS_DISABLED = 'change-access-disable';
37
38    /**
39     * Represents a user viewing the value of a protected variable
40     *
41     * @var string
42     */
43    public const ACTION_VIEW_PROTECTED_VARIABLE_VALUE = 'view-protected-var-value';
44
45    /**
46     * @var string
47     */
48    public const LOG_TYPE = 'abusefilter-protected-vars';
49
50    private LoggerInterface $logger;
51    private IConnectionProvider $lbFactory;
52    private ActorStore $actorStore;
53    private int $delay;
54
55    /**
56     * @param LoggerInterface $logger
57     * @param IConnectionProvider $lbFactory
58     * @param ActorStore $actorStore
59     * @param int $delay The number of seconds after which a duplicate log entry can be
60     *  created for a debounced log
61     */
62    public function __construct(
63        LoggerInterface $logger,
64        IConnectionProvider $lbFactory,
65        ActorStore $actorStore,
66        int $delay
67    ) {
68        Assert::parameter( $delay > 0, 'delay', 'delay must be positive' );
69
70        $this->logger = $logger;
71        $this->lbFactory = $lbFactory;
72        $this->actorStore = $actorStore;
73        $this->delay = $delay;
74    }
75
76    /**
77     * Log when the user enables their own access
78     *
79     * @param UserIdentity $performer
80     */
81    public function logAccessEnabled( UserIdentity $performer ): void {
82        $this->log( $performer, $performer->getName(), self::ACTION_CHANGE_ACCESS_ENABLED, false );
83    }
84
85    /**
86     * Log when the user disables their own access
87     *
88     * @param UserIdentity $performer
89     */
90    public function logAccessDisabled( UserIdentity $performer ): void {
91        $this->log( $performer, $performer->getName(), self::ACTION_CHANGE_ACCESS_DISABLED, false );
92    }
93
94    /**
95     * Log when the user views the values of protected variables
96     *
97     * @param UserIdentity $performer
98     * @param string $target
99     * @param int|null $timestamp
100     */
101    public function logViewProtectedVariableValue(
102        UserIdentity $performer,
103        string $target,
104        ?int $timestamp = null
105    ): void {
106        if ( !$timestamp ) {
107            $timestamp = (int)wfTimestamp();
108        }
109        // Create the log on POSTSEND, as this can be called in a context of a GET request through the
110        // QueryAbuseLog API (T379083).
111        DeferredUpdates::addCallableUpdate( function () use ( $performer, $target, $timestamp ) {
112            // We need to create a log entry and PostSend-GET expects no writes are performed, so we need to
113            // silence the warnings created by this.
114            $trxProfiler = Profiler::instance()->getTransactionProfiler();
115            $scope = $trxProfiler->silenceForScope( $trxProfiler::EXPECTATION_REPLICAS_ONLY );
116            $this->log(
117                $performer,
118                $target,
119                self::ACTION_VIEW_PROTECTED_VARIABLE_VALUE,
120                true,
121                $timestamp
122            );
123        } );
124    }
125
126    /**
127     * @param UserIdentity $performer
128     * @param string $target
129     * @param string $action
130     * @param bool $shouldDebounce
131     * @param int|null $timestamp
132     * @param array|null $params
133     */
134    private function log(
135        UserIdentity $performer,
136        string $target,
137        string $action,
138        bool $shouldDebounce,
139        ?int $timestamp = null,
140        ?array $params = []
141    ): void {
142        if ( !$timestamp ) {
143            $timestamp = (int)wfTimestamp();
144        }
145
146        // Log to CheckUser's temporary accounts log if CU is installed
147        if ( MediaWikiServices::getInstance()->getExtensionRegistry()->isLoaded( 'CheckUser' ) ) {
148            // Add the extension name to the action so that CheckUser has a clearer
149            // reference to the source in the message key
150            $action = 'af-' . $action;
151
152            $logger = MediaWikiServices::getInstance()
153                ->getService( 'CheckUserTemporaryAccountLoggerFactory' )
154                ->getLogger();
155            $logger->logFromExternal(
156                $performer,
157                $target,
158                $action,
159                $params,
160                $shouldDebounce,
161                $timestamp
162            );
163        } else {
164            $dbw = $this->lbFactory->getPrimaryDatabase();
165            $shouldLog = false;
166
167            // If the log is debounced, check against the logging table before logging
168            if ( $shouldDebounce ) {
169                $timestampMinusDelay = $timestamp - $this->delay;
170                $actorId = $this->actorStore->findActorId( $performer, $dbw );
171                if ( !$actorId ) {
172                    $shouldLog = true;
173                } else {
174                    $logline = $dbw->newSelectQueryBuilder()
175                        ->select( '*' )
176                        ->from( 'logging' )
177                        ->where( [
178                            'log_type' => self::LOG_TYPE,
179                            'log_action' => $action,
180                            'log_actor' => $actorId,
181                            'log_namespace' => NS_USER,
182                            'log_title' => $target,
183                            $dbw->expr( 'log_timestamp', '>', $dbw->timestamp( $timestampMinusDelay ) ),
184                        ] )
185                        ->caller( __METHOD__ )
186                        ->fetchRow();
187
188                    if ( !$logline ) {
189                        $shouldLog = true;
190                    }
191                }
192            } else {
193                // If the log isn't debounced then it should always be logged
194                $shouldLog = true;
195            }
196
197            // Actually write to logging table
198            if ( $shouldLog ) {
199                $logEntry = $this->createManualLogEntry( $action );
200                $logEntry->setPerformer( $performer );
201                $logEntry->setTarget( Title::makeTitle( NS_USER, $target ) );
202                $logEntry->setParameters( $params );
203                $logEntry->setTimestamp( wfTimestamp( TS_MW, $timestamp ) );
204
205                try {
206                    $logEntry->insert( $dbw );
207                } catch ( DBError $e ) {
208                    $this->logger->critical(
209                        'AbuseFilter proctected variable log entry was not recorded. ' .
210                        'This means access to IPs can occur without being auditable. ' .
211                        'Immediate fix required.'
212                    );
213
214                    throw $e;
215                }
216            }
217        }
218    }
219
220    /**
221     * @internal
222     *
223     * @param string $subtype
224     * @return ManualLogEntry
225     */
226    protected function createManualLogEntry( string $subtype ): ManualLogEntry {
227        return new ManualLogEntry( self::LOG_TYPE, $subtype );
228    }
229}