Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
84.31% |
86 / 102 |
|
71.43% |
10 / 14 |
CRAP | |
0.00% |
0 / 1 |
ZObjectAuthorization | |
84.31% |
86 / 102 |
|
71.43% |
10 / 14 |
51.47 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
authorize | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
getRequiredCreateRights | |
87.10% |
27 / 31 |
|
0.00% |
0 / 1 |
16.55 | |||
getRequiredEditRights | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
6 | |||
pathMatches | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
opMatches | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
filterMatches | |
41.18% |
7 / 17 |
|
0.00% |
0 / 1 |
4.83 | |||
getRightsByOp | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
getDiffOps | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
toDiffArray | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
ruleFilePath | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getRulesByType | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getLogger | |
0.00% |
0 / 1 |
|
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 | |
11 | namespace MediaWiki\Extension\WikiLambda\Authorization; |
12 | |
13 | use MediaWiki\Extension\WikiLambda\Diff\ZObjectDiffer; |
14 | use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry; |
15 | use MediaWiki\Extension\WikiLambda\WikiLambdaServices; |
16 | use MediaWiki\Extension\WikiLambda\ZErrorFactory; |
17 | use MediaWiki\Extension\WikiLambda\ZObjectContent; |
18 | use MediaWiki\Extension\WikiLambda\ZObjects\ZType; |
19 | use MediaWiki\Permissions\Authority; |
20 | use MediaWiki\Title\Title; |
21 | use Psr\Log\LoggerAwareInterface; |
22 | use Psr\Log\LoggerInterface; |
23 | use Symfony\Component\Yaml\Yaml; |
24 | |
25 | class 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 | } |