Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.40% covered (success)
90.40%
113 / 125
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ZObjectAuthorization
90.40% covered (success)
90.40%
113 / 125
78.57% covered (warning)
78.57%
11 / 14
53.30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 authorize
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
10
 getRequiredCreateRights
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
18
 getRequiredEditRights
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 pathMatches
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 opMatches
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 filterMatches
41.18% covered (danger)
41.18%
7 / 17
0.00% covered (danger)
0.00%
0 / 1
4.83
 getRightsByOp
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getDiffOps
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 toDiffArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ruleFilePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRulesByType
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * WikiLambda ZObject User Authorization service
4 *
5 * @file
6 * @ingroup Extensions
7 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
8 * @license MIT
9 */
10
11namespace MediaWiki\Extension\WikiLambda\Authorization;
12
13use Exception;
14use MediaWiki\Extension\WikiLambda\Diff\ZObjectDiffer;
15use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
16use MediaWiki\Extension\WikiLambda\WikiLambdaServices;
17use MediaWiki\Extension\WikiLambda\ZErrorFactory;
18use MediaWiki\Extension\WikiLambda\ZObjectContent\ZObjectContent;
19use MediaWiki\Extension\WikiLambda\ZObjects\ZType;
20use MediaWiki\MediaWikiServices;
21use MediaWiki\Permissions\Authority;
22use MediaWiki\Title\Title;
23use Psr\Log\LoggerAwareInterface;
24use Psr\Log\LoggerInterface;
25use Symfony\Component\Yaml\Yaml;
26
27class ZObjectAuthorization implements LoggerAwareInterface {
28
29    /**
30     * @param LoggerInterface $logger
31     */
32    public function __construct( private LoggerInterface $logger ) {
33    }
34
35    /**
36     * Given a ZObject edit or creation, requests the necessary rights and checks
37     * whether the user has them all. Fails if any of the rights are not present
38     * in the user groups.
39     *
40     * @param ZObjectContent|null $oldContent
41     * @param ZObjectContent $newContent
42     * @param Authority $authority
43     * @param Title $title
44     * @return AuthorizationStatus
45     */
46    public function authorize( $oldContent, $newContent, $authority, $title ): AuthorizationStatus {
47        // If oldContent is null, we are creating a new object; else we editing
48        $creating = ( $oldContent === null );
49
50        // We get the list of required rights for the revision
51        $requiredRights = $creating
52            ? $this->getRequiredCreateRights( $newContent, $title )
53            : $this->getRequiredEditRights( $oldContent, $newContent, $title );
54
55        // We check that the user has the necessary rights
56        $status = new AuthorizationStatus();
57
58        foreach ( $requiredRights as $right ) {
59            // TODO (T375065): We can probably replace this with $authority->isAllowedAll( $requiredRights );
60            if ( !$authority->isAllowed( $right ) ) {
61                $flags = $creating ? EDIT_NEW : EDIT_UPDATE;
62                $error = ZErrorFactory::createAuthorizationZError( $right, $flags );
63                $status->setUnauthorized( $right, $error );
64                break;
65            }
66        }
67
68        // Finally, we check if the user is blocked in a way that prohibits editing
69        $block = MediaWikiServices::getInstance()
70                ->getBlockManager()
71                ->getBlock(
72                    $authority->getUser(),
73                    /* request; we don't care about IP blocks */ null,
74                    /* fromReplica; we want the very latest block before allowing an edit */ false
75                );
76
77        if ( $block ) {
78            if (
79                // If the block is site-wide, it prohibits all editing, including editing/creating ZObjects
80                $block->isSitewide() ||
81                // If the block stops actions on the main namespace, that includes all ZObject edits/creations
82                $block->appliesToNamespace( NS_MAIN ) ||
83                // If the block applies to specific pages, check if it's the one being edited/created
84                $block->appliesToPage( $title->getArticleId() )
85            ) {
86                $status->setUnauthorized(
87                    // Fall-back to the simple 'edit' right, as that's always needed regardless
88                    $requiredRights[0] ?? 'edit',
89                    ZErrorFactory::createAuthorizationZError(
90                        $requiredRights[0] ?? 'edit',
91                        $creating ? EDIT_NEW : EDIT_UPDATE
92                    )
93                );
94            }
95        }
96
97        return $status;
98    }
99
100    /**
101     * Given a new ZObject content object, uses its type and zid information to
102     * return the array of user rights that must be present in order to successfully
103     * authorize the creation.
104     *
105     * @param ZObjectContent $content
106     * @param Title $title
107     * @return string[]
108     */
109    public function getRequiredCreateRights( $content, $title ): array {
110        // Default rights necessary for create:
111        $userRights = [ 'edit', 'wikilambda-create' ];
112
113        // 1. Get type of the object
114        $type = $content->getZType();
115
116        // 2. Detect special right for builtin objects
117        $zObjectId = $content->getZid();
118        if ( substr( $zObjectId, 1 ) < 10000 ) {
119            $userRights[] = 'wikilambda-create-predefined';
120        }
121
122        // 3. Detect special rights per type
123        switch ( $type ) {
124            case ZTypeRegistry::Z_TYPE:
125                $userRights[] = 'wikilambda-create-type';
126                break;
127
128            case ZTypeRegistry::Z_FUNCTION:
129                $userRights[] = 'wikilambda-create-function';
130                break;
131
132            case ZTypeRegistry::Z_FUNCTIONCALL:
133                $functionZid = $content->getInnerZObject()->getZValue();
134                if ( $functionZid === ZTypeRegistry::Z_WIKIDATA_ENUM ) {
135                    $userRights[] = 'wikilambda-create-generic-enum';
136                } else {
137                    $userRights[] = 'wikilambda-create-function-call';
138                }
139                break;
140
141            case ZTypeRegistry::Z_LANGUAGE:
142                $userRights[] = 'wikilambda-create-language';
143                break;
144
145            case ZTypeRegistry::Z_PROGRAMMINGLANGUAGE:
146                $userRights[] = 'wikilambda-create-programming';
147                break;
148
149            case ZTypeRegistry::Z_IMPLEMENTATION:
150                $userRights[] = 'wikilambda-create-implementation';
151                break;
152
153            case ZTypeRegistry::Z_TESTER:
154                $userRights[] = 'wikilambda-create-tester';
155                break;
156
157            case ZTypeRegistry::Z_BOOLEAN:
158                $userRights[] = 'wikilambda-create-boolean';
159                break;
160
161            case ZTypeRegistry::Z_UNIT:
162                $userRights[] = 'wikilambda-create-unit';
163                break;
164
165            case ZTypeRegistry::Z_DESERIALISER:
166            case ZTypeRegistry::Z_SERIALISER:
167                $userRights[] = 'wikilambda-create-converter';
168                break;
169
170            default:
171                // Check for enumeration
172                $typeTitle = Title::newFromText( $type, NS_MAIN );
173                $zObjectStore = WikiLambdaServices::getZObjectStore();
174                $typeObject = $zObjectStore->fetchZObjectByTitle( $typeTitle );
175                if ( $typeObject ) {
176                    $typeInnerObject = $typeObject->getInnerZObject();
177                    if ( ( $typeInnerObject instanceof ZType ) && $typeInnerObject->isEnumType() ) {
178                        $userRights[] = 'wikilambda-create-enum-value';
179                    }
180                }
181        }
182
183        return $userRights;
184    }
185
186    /**
187     * Given a ZObject edit, matches with the available authorization rules and
188     * returns the array of user rights that must be present in order to successfully
189     * authorize the edit.
190     *
191     * @param ZObjectContent $fromContent
192     * @param ZObjectContent $toContent
193     * @param Title $title
194     * @return string[]
195     */
196    public function getRequiredEditRights( $fromContent, $toContent, $title ): array {
197        // Default rights necessary for edit:
198        $userRights = [ 'edit' ];
199        // 1. Get type of object
200        $type = $toContent->getZType();
201
202        // 2. Initial filter of the rules by type
203        $rules = $this->getRulesByType( $type );
204
205        // 3. Calculate the diffs
206        $diffs = $this->getDiffOps( $fromContent, $toContent );
207
208        // 4. For each diff op we do:
209        // 4.1. For every rule, we match the path pattern and operation
210        // 4.2. If there's a match, we pass any filter we encounter in the rule
211        // 4.3. If every condition passes, we gather the necessary rights and go to next diff
212        foreach ( $diffs as $diff ) {
213            foreach ( $rules as $rule ) {
214                if ( $this->pathMatches( $diff, $rule ) && $this->opMatches( $diff, $rule ) ) {
215                    if ( $this->filterMatches( $rule, $fromContent, $toContent, $title ) ) {
216                        $theseRights = $this->getRightsByOp( $diff, $rule );
217                        $userRights = array_merge( $userRights, $theseRights );
218                        break;
219                    }
220                }
221            }
222        }
223
224        return array_values( array_unique( $userRights ) );
225    }
226
227    /**
228     * Whether the diff path matches the rule path pattern
229     *
230     * @param array $diff
231     * @param array $rule
232     * @return bool
233     */
234    private function pathMatches( $diff, $rule ): bool {
235        $path = implode( ".", $diff['path'] );
236        $pattern = $rule['path'];
237        return preg_match( "/$pattern/", $path );
238    }
239
240    /**
241     * Whether the diff operation matches any of the operations described in the
242     * rule, which can be the exact rule or the keyword "any".
243     *
244     * @param array $diff
245     * @param array $rule
246     * @return bool
247     */
248    private function opMatches( $diff, $rule ): bool {
249        $op = $diff['op']->getType();
250        $ops = $rule['operations'];
251        return array_key_exists( $op, $ops ) || array_key_exists( 'any', $ops );
252    }
253
254    /**
255     * Whether the objects being edited pass the filter specified in the rule.
256     * The filter must be the class name of an implementation of the interface
257     * ZObjectFilter.
258     *
259     * @param array $rule
260     * @param ZObjectContent $fromContent
261     * @param ZObjectContent $toContent
262     * @param Title $title
263     * @return bool
264     */
265    private function filterMatches( $rule, $fromContent, $toContent, $title ): bool {
266        $pass = true;
267        if ( array_key_exists( 'filter', $rule ) ) {
268            $filterArgs = $rule['filter'];
269            $filterClass = array_shift( $filterArgs );
270            $callableClass = 'MediaWiki\Extension\WikiLambda\Authorization\\' . $filterClass;
271            try {
272                $pass = $callableClass::pass( $fromContent, $toContent, $title, $filterArgs );
273            } catch ( Exception $e ) {
274                $this->getLogger()->warning(
275                    'Filter is specified in the rules but method is not available; returning false',
276                    [
277                        'filterClass' => $filterClass,
278                        'title' => $title,
279                        'exception' => $e
280                    ]
281                );
282                $pass = false;
283            }
284        }
285        return $pass;
286    }
287
288    /**
289     * Given a diff with a particular operation and a matched rule, gather
290     * return the list of rights that correspond to that operation.
291     *
292     * @param array $diff
293     * @param array $rule
294     * @return array
295     */
296    private function getRightsByOp( $diff, $rule ): array {
297        $opType = $diff['op']->getType();
298        $ops = $rule['operations'];
299        $rights = [];
300        if ( array_key_exists( 'any', $ops ) ) {
301            $rights = array_merge( $rights, $ops['any'] );
302        }
303        if ( array_key_exists( $opType, $ops ) ) {
304            $rights = array_merge( $rights, $ops[$opType] );
305        }
306        return $rights;
307    }
308
309    /**
310     * Call the ZObjectDiffer and return the collection of granular
311     * diffs found in an edit.
312     *
313     * @param ZObjectContent $fromContent
314     * @param ZObjectContent $toContent
315     * @return array
316     */
317    private function getDiffOps( $fromContent, $toContent ): array {
318        $differ = new ZObjectDiffer();
319        $diffOps = $differ->doDiff(
320            $this->toDiffArray( $fromContent ),
321            $this->toDiffArray( $toContent )
322        );
323        return ZObjectDiffer::flattenDiff( $diffOps );
324    }
325
326    /**
327     * Helper function to transform the content object before passing
328     * it to the ZObjectDiffer service.
329     *
330     * @param ZObjectContent $content
331     * @return array
332     */
333    private function toDiffArray( ZObjectContent $content ): array {
334        return json_decode( json_encode( $content->getObject() ), true );
335    }
336
337    /**
338     * Returns the path of the authorization rules YAML file
339     *
340     * @return string
341     */
342    private static function ruleFilePath(): string {
343        return dirname( __DIR__, 2 ) . '/authorization-rules.yml';
344    }
345
346    /**
347     * Reads the authorization rules file and returns the list of
348     * rules filtered by type. This is an initial filter pass, so
349     * that the system doesn't try to match rules that are not
350     * applicable for this given type.
351     *
352     * @param string $type
353     * @return array
354     */
355    private function getRulesByType( string $type ): array {
356        $allRules = Yaml::parseFile( self::ruleFilePath() );
357        $filteredRules = array_filter( $allRules, static function ( $rule ) use ( $type ) {
358            return ( !array_key_exists( 'type', $rule ) || ( $rule[ 'type' ] === $type ) );
359        } );
360        return $filteredRules;
361    }
362
363    /**
364     * @inheritDoc
365     */
366    public function setLogger( LoggerInterface $logger ): void {
367        $this->logger = $logger;
368    }
369
370    /**
371     * @inheritDoc
372     */
373    public function getLogger(): LoggerInterface {
374        return $this->logger;
375    }
376}