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