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