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