Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
78.95% |
75 / 95 |
|
71.43% |
10 / 14 |
CRAP | |
0.00% |
0 / 1 |
ZObjectAuthorization | |
78.95% |
75 / 95 |
|
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 | |
63.64% |
14 / 22 |
|
0.00% |
0 / 1 |
14.81 | |||
getRequiredEditRights | |
100.00% |
14 / 14 |
|
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\ZErrorFactory; |
16 | use MediaWiki\Extension\WikiLambda\ZObjectContent; |
17 | use MediaWiki\Permissions\Authority; |
18 | use MediaWiki\Title\Title; |
19 | use Psr\Log\LoggerAwareInterface; |
20 | use Psr\Log\LoggerInterface; |
21 | use Symfony\Component\Yaml\Yaml; |
22 | |
23 | class 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 | } |