attackmap-analyzer-omeka-s 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- attackmap_analyzer_omeka_s/__init__.py +3 -0
- attackmap_analyzer_omeka_s/acl.py +468 -0
- attackmap_analyzer_omeka_s/analyzer.py +637 -0
- attackmap_analyzer_omeka_s/phparray.py +451 -0
- attackmap_analyzer_omeka_s/routes.py +627 -0
- attackmap_analyzer_omeka_s-0.2.0.dist-info/METADATA +106 -0
- attackmap_analyzer_omeka_s-0.2.0.dist-info/RECORD +11 -0
- attackmap_analyzer_omeka_s-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_omeka_s-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_omeka_s-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_omeka_s-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""Omeka S ACL rules and per-route access decisions (attackmap-analyzer-omeka-s#2).
|
|
2
|
+
|
|
3
|
+
Omeka decides who reaches a controller with its ACL (`Omeka\\Permissions\\Acl`,
|
|
4
|
+
a Laminas ACL). Controllers are ACL resources, the action is the privilege,
|
|
5
|
+
and `MvcListeners::authorizeUserAgainstController` checks
|
|
6
|
+
`userIsAllowed($controller, $action)` on every dispatch. The anonymous visitor
|
|
7
|
+
has no role, so only rules for the `null` role ("all roles") reach them.
|
|
8
|
+
|
|
9
|
+
This module reads `$acl->allow(<roles>, <resources>, <privileges>[, <assertion>])`
|
|
10
|
+
and `->deny(...)` calls from a module's `Module.php` (`onBootstrap`,
|
|
11
|
+
`attachListeners` or helpers they call) and from `*AclFactory.php` (Omeka core's
|
|
12
|
+
rules), and answers per controller and action with Laminas' lookup order
|
|
13
|
+
(`Laminas\\Permissions\\Acl\\Acl::isAllowed`):
|
|
14
|
+
|
|
15
|
+
1. For each resource level, the specific controller first and then the
|
|
16
|
+
all-resources level (`null`; Omeka's controllers have no parent resources):
|
|
17
|
+
the role's own rules (the privilege, then all privileges), then the
|
|
18
|
+
all-roles rules (the privilege, then all privileges). An all-roles,
|
|
19
|
+
all-privileges *deny* on the specific controller doesn't stop the lookup;
|
|
20
|
+
it continues to the all-resources level, as in Laminas.
|
|
21
|
+
2. For each (resource, role, privilege) slot the last rule written wins.
|
|
22
|
+
3. No rule: denied (Omeka's default).
|
|
23
|
+
|
|
24
|
+
Two public principals are checked: the anonymous visitor (all-roles rules
|
|
25
|
+
only) and `'guest'` (the Guest module's self-registration role, its own rules
|
|
26
|
+
then all-roles rules; treated as public). Role inheritance isn't modelled:
|
|
27
|
+
Omeka core and the Guest module add their roles without parents.
|
|
28
|
+
|
|
29
|
+
- **anonymous**: either principal is allowed.
|
|
30
|
+
- **required**: both are denied (by a rule or by default). Guards name the
|
|
31
|
+
roles that are allowed (`omeka_acl:requires_role(<role>)`) or
|
|
32
|
+
`omeka_acl:default_deny`.
|
|
33
|
+
- **unknown**: a conditional rule (an assertion) decides, a `:action` route's
|
|
34
|
+
actions disagree, or a rule's role or resource couldn't be resolved (an
|
|
35
|
+
unresolved anonymous resource, or a zero-argument `allow()` outside
|
|
36
|
+
`*AclFactory.php`, turns every deny into unknown).
|
|
37
|
+
|
|
38
|
+
Zero-argument `allow()` in `*AclFactory.php` is Omeka's grant-all while it is
|
|
39
|
+
not installed or needs a migration. The installer and migration controllers
|
|
40
|
+
(``INSTALL_CONTROLLERS``) serve exactly then, so they are never reported as
|
|
41
|
+
`required` (`unknown` with a note instead).
|
|
42
|
+
|
|
43
|
+
Decisions are memoised per (controller, action), rules are indexed by
|
|
44
|
+
(principal, resource, privilege), and an ``Acl`` makes at most
|
|
45
|
+
``MAX_DECISIONS`` fresh decisions; past that budget it answers `unknown`.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
from collections.abc import Callable
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
|
|
53
|
+
from .phparray import (
|
|
54
|
+
LPAREN,
|
|
55
|
+
OP,
|
|
56
|
+
WORD,
|
|
57
|
+
NameContext,
|
|
58
|
+
PhpArray,
|
|
59
|
+
PhpExpr,
|
|
60
|
+
PhpString,
|
|
61
|
+
PhpValue,
|
|
62
|
+
Token,
|
|
63
|
+
class_name,
|
|
64
|
+
parse_call_args,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
ANONYMOUS_ROLES = frozenset({"guest"})
|
|
68
|
+
ALL = "*"
|
|
69
|
+
MAX_RULES_PER_FILE = 2_000
|
|
70
|
+
MAX_DECISIONS = 50_000
|
|
71
|
+
MAX_PRIVILEGES_PER_DECISION = 256
|
|
72
|
+
_OTHER_ACTION = "\0any-other-action"
|
|
73
|
+
# Controllers that run while Omeka isn't installed or needs a migration, when
|
|
74
|
+
# AclFactory grants everything (`$acl->allow()`) and MvcListeners redirects there.
|
|
75
|
+
INSTALL_CONTROLLERS = frozenset({"Omeka\\Controller\\Install", "Omeka\\Controller\\Migrate"})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class AclRule:
|
|
80
|
+
allow: bool
|
|
81
|
+
file: str
|
|
82
|
+
line: int
|
|
83
|
+
# Role names as written; `null` for the all-roles role. `Acl::ROLE_*`
|
|
84
|
+
# constants are resolved to their values (`Acl::ROLE_GUEST` → `guest`).
|
|
85
|
+
roles: list[str]
|
|
86
|
+
# The role argument couldn't be resolved (a variable): it might be anonymous.
|
|
87
|
+
role_unresolved: bool
|
|
88
|
+
# Resolved resource names; `ALL` for a `null` resource argument.
|
|
89
|
+
resources: list[str]
|
|
90
|
+
resources_unresolved: bool
|
|
91
|
+
# None = every privilege (action).
|
|
92
|
+
privileges: list[str] | None
|
|
93
|
+
privileges_unresolved: bool
|
|
94
|
+
conditional: bool
|
|
95
|
+
# `$acl->allow()` with no arguments (grant-all).
|
|
96
|
+
grant_all: bool = False
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def anonymous(self) -> bool:
|
|
100
|
+
return any(role == "null" or role.lower() in ANONYMOUS_ROLES for role in self.roles)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def maybe_anonymous(self) -> bool:
|
|
104
|
+
return self.anonymous or self.role_unresolved
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class Decision:
|
|
109
|
+
auth: str # "anonymous" | "required" | "unknown"
|
|
110
|
+
guards: list[str] = field(default_factory=list)
|
|
111
|
+
evidence: str | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _role_names(value: PhpValue | None) -> tuple[bool, list[str]]:
|
|
115
|
+
"""(unresolved, role names) of a role argument; `null` is the all-roles role."""
|
|
116
|
+
if value is None:
|
|
117
|
+
return False, ["null"]
|
|
118
|
+
values = [v for _, v in value.items] if isinstance(value, PhpArray) else [value]
|
|
119
|
+
unresolved = False
|
|
120
|
+
roles: list[str] = []
|
|
121
|
+
for item in values:
|
|
122
|
+
text = item.text.strip() if isinstance(item, PhpExpr) else ""
|
|
123
|
+
if isinstance(item, PhpExpr) and text.lower() == "null":
|
|
124
|
+
roles.append("null")
|
|
125
|
+
elif isinstance(item, PhpString):
|
|
126
|
+
roles.append(item.value.strip())
|
|
127
|
+
elif isinstance(item, PhpExpr) and "::ROLE_" in text and " " not in text:
|
|
128
|
+
# `Acl::ROLE_SITE_ADMIN` → `site_admin`, `Acl::ROLE_GUEST` → `guest`:
|
|
129
|
+
# Omeka's constants are the lower-cased suffix.
|
|
130
|
+
roles.append(text.rsplit("::ROLE_", 1)[1].lower())
|
|
131
|
+
else:
|
|
132
|
+
unresolved = True
|
|
133
|
+
return unresolved, roles
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _resource_names(value: PhpValue | None, names: NameContext) -> tuple[list[str], bool]:
|
|
137
|
+
if value is None or (isinstance(value, PhpExpr) and value.text.strip().lower() == "null"):
|
|
138
|
+
return [ALL], False
|
|
139
|
+
values = [v for _, v in value.items] if isinstance(value, PhpArray) else [value]
|
|
140
|
+
resources: list[str] = []
|
|
141
|
+
unresolved = False
|
|
142
|
+
for item in values:
|
|
143
|
+
if isinstance(item, PhpString) and item.value.strip():
|
|
144
|
+
resources.append(item.value.strip().lstrip("\\"))
|
|
145
|
+
continue
|
|
146
|
+
resolved = class_name(item, names)
|
|
147
|
+
if resolved:
|
|
148
|
+
resources.append(resolved)
|
|
149
|
+
else:
|
|
150
|
+
unresolved = True
|
|
151
|
+
return resources, unresolved
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _privileges(value: PhpValue | None) -> tuple[list[str] | None, bool]:
|
|
155
|
+
if value is None or (isinstance(value, PhpExpr) and value.text.strip().lower() == "null"):
|
|
156
|
+
return None, False
|
|
157
|
+
values = [v for _, v in value.items] if isinstance(value, PhpArray) else [value]
|
|
158
|
+
privileges = [v.value for v in values if isinstance(v, PhpString)]
|
|
159
|
+
return privileges, len(privileges) != len(values)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def acl_rules(tokens: list[Token], names: NameContext, file: str, line_of: Callable[[int], int]) -> list[AclRule]:
|
|
163
|
+
"""Every `->allow(...)` / `->deny(...)` call. ``line_of`` maps a source
|
|
164
|
+
offset to its 1-based line."""
|
|
165
|
+
rules: list[AclRule] = []
|
|
166
|
+
n = len(tokens)
|
|
167
|
+
i = 0
|
|
168
|
+
while i < n - 3 and len(rules) < MAX_RULES_PER_FILE:
|
|
169
|
+
if not (
|
|
170
|
+
tokens[i].kind == OP
|
|
171
|
+
and tokens[i].text == "-"
|
|
172
|
+
and tokens[i + 1].kind == OP
|
|
173
|
+
and tokens[i + 1].text == ">"
|
|
174
|
+
and tokens[i + 2].kind == WORD
|
|
175
|
+
and tokens[i + 2].text in ("allow", "deny")
|
|
176
|
+
and tokens[i + 3].kind == LPAREN
|
|
177
|
+
):
|
|
178
|
+
i += 1
|
|
179
|
+
continue
|
|
180
|
+
verb = tokens[i + 2].text
|
|
181
|
+
offset = tokens[i + 2].offset
|
|
182
|
+
args, end = parse_call_args(tokens, i + 3)
|
|
183
|
+
i = max(end, i + 4)
|
|
184
|
+
if not args:
|
|
185
|
+
rules.append(
|
|
186
|
+
AclRule(
|
|
187
|
+
allow=verb == "allow",
|
|
188
|
+
file=file,
|
|
189
|
+
line=line_of(offset),
|
|
190
|
+
roles=["null"],
|
|
191
|
+
role_unresolved=False,
|
|
192
|
+
resources=[ALL],
|
|
193
|
+
resources_unresolved=False,
|
|
194
|
+
privileges=None,
|
|
195
|
+
privileges_unresolved=False,
|
|
196
|
+
conditional=False,
|
|
197
|
+
grant_all=True,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
continue
|
|
201
|
+
role_unresolved, roles = _role_names(args[0])
|
|
202
|
+
resources, resources_unresolved = _resource_names(args[1] if len(args) > 1 else None, names)
|
|
203
|
+
privileges, privileges_unresolved = _privileges(args[2] if len(args) > 2 else None)
|
|
204
|
+
conditional = len(args) > 3 and not (isinstance(args[3], PhpExpr) and args[3].text.strip().lower() == "null")
|
|
205
|
+
rules.append(
|
|
206
|
+
AclRule(
|
|
207
|
+
allow=verb == "allow",
|
|
208
|
+
file=file,
|
|
209
|
+
line=line_of(offset),
|
|
210
|
+
roles=roles,
|
|
211
|
+
role_unresolved=role_unresolved,
|
|
212
|
+
resources=resources,
|
|
213
|
+
resources_unresolved=resources_unresolved,
|
|
214
|
+
privileges=privileges,
|
|
215
|
+
privileges_unresolved=privileges_unresolved,
|
|
216
|
+
conditional=conditional,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
return rules
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _describe(rule: AclRule, resource: str) -> str:
|
|
223
|
+
"""The rule as written, narrowed to the resource that matched."""
|
|
224
|
+
roles = [role if role == "null" else f"'{role}'" for role in rule.roles]
|
|
225
|
+
if rule.role_unresolved:
|
|
226
|
+
roles.append("<unresolved>")
|
|
227
|
+
role = roles[0] if len(roles) == 1 else "[" + ", ".join(roles) + "]"
|
|
228
|
+
shown = "null" if resource == ALL else f"'{resource}'"
|
|
229
|
+
if len(rule.resources) > 1:
|
|
230
|
+
shown += f" (1 of {len(rule.resources)} resources)"
|
|
231
|
+
args = [role, shown]
|
|
232
|
+
if rule.privileges is not None:
|
|
233
|
+
args.append("[" + ", ".join(f"'{p}'" for p in rule.privileges) + "]")
|
|
234
|
+
if rule.conditional:
|
|
235
|
+
args.append("<assertion>")
|
|
236
|
+
verb = "allow" if rule.allow else "deny"
|
|
237
|
+
return f"$acl->{verb}({', '.join(args)}) at {rule.file}:{rule.line}"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# A lookup result: True/False (allowed/denied by a rule), None (a conditional
|
|
241
|
+
# rule decides: unknown), or "default" (no rule: Omeka's default deny).
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class Acl:
|
|
245
|
+
"""The repo's ACL rules, indexed by principal and (resource, privilege)."""
|
|
246
|
+
|
|
247
|
+
def __init__(
|
|
248
|
+
self,
|
|
249
|
+
rules: list[AclRule],
|
|
250
|
+
registered: set[str] | None = None,
|
|
251
|
+
aliases: set[str] | None = None,
|
|
252
|
+
) -> None:
|
|
253
|
+
self.rules = rules
|
|
254
|
+
# None: registrations unknown (unit use); otherwise the ACL resources
|
|
255
|
+
# Omeka adds (controllers.invokables / factories keys).
|
|
256
|
+
self.registered = registered
|
|
257
|
+
self.aliases = aliases or set()
|
|
258
|
+
# Something we couldn't resolve might allow the visitor anything.
|
|
259
|
+
self.blocked = any(
|
|
260
|
+
(r.maybe_anonymous and r.resources_unresolved)
|
|
261
|
+
or (r.grant_all and not r.file.endswith("AclFactory.php"))
|
|
262
|
+
for r in rules
|
|
263
|
+
)
|
|
264
|
+
# Per principal: resource → the rules naming it, in program order
|
|
265
|
+
# (each rule once per resource it names, never expanded per
|
|
266
|
+
# privilege), so the index stays linear in the source.
|
|
267
|
+
self.all_roles: dict[str, list[tuple[int, AclRule]]] = {}
|
|
268
|
+
self.guest: dict[str, list[tuple[int, AclRule]]] = {}
|
|
269
|
+
self.unresolved_role_resources: set[str] = set()
|
|
270
|
+
self.allowed_roles: dict[str, set[str]] = {}
|
|
271
|
+
self.unresolved_privileges: set[str] = set()
|
|
272
|
+
self._privilege_sets: dict[int, frozenset[str]] = {}
|
|
273
|
+
for seq, rule in enumerate(rules):
|
|
274
|
+
if rule.grant_all:
|
|
275
|
+
continue # install-time grant (AclFactory) or a blocker (above)
|
|
276
|
+
if rule.privileges is not None:
|
|
277
|
+
self._privilege_sets[id(rule)] = frozenset(rule.privileges)
|
|
278
|
+
tables = []
|
|
279
|
+
for role in rule.roles:
|
|
280
|
+
if role == "null":
|
|
281
|
+
tables.append(self.all_roles)
|
|
282
|
+
elif role.lower() in ANONYMOUS_ROLES:
|
|
283
|
+
tables.append(self.guest)
|
|
284
|
+
for resource in rule.resources:
|
|
285
|
+
if rule.privileges_unresolved:
|
|
286
|
+
self.unresolved_privileges.add(resource)
|
|
287
|
+
if rule.role_unresolved:
|
|
288
|
+
self.unresolved_role_resources.add(resource)
|
|
289
|
+
for table in tables:
|
|
290
|
+
table.setdefault(resource, []).append((seq, rule))
|
|
291
|
+
if rule.allow:
|
|
292
|
+
for role in rule.roles:
|
|
293
|
+
if role != "null" and role.lower() not in ANONYMOUS_ROLES:
|
|
294
|
+
self.allowed_roles.setdefault(resource, set()).add(role)
|
|
295
|
+
self._slot_memo: dict[tuple[int, str, str | None], tuple[int, AclRule] | None] = {}
|
|
296
|
+
self._memo: dict[tuple[str, str | None], Decision] = {}
|
|
297
|
+
self.fresh_decisions = 0
|
|
298
|
+
|
|
299
|
+
# -- Laminas lookup -------------------------------------------------
|
|
300
|
+
|
|
301
|
+
def _written(self, table: dict[str, list[tuple[int, AclRule]]], resource: str, privilege: str | None):
|
|
302
|
+
"""The last rule written to the (resource, privilege) slot directly:
|
|
303
|
+
``(seq, rule)`` or None. ``privilege`` None is the all-privileges slot."""
|
|
304
|
+
key = (id(table), resource, privilege)
|
|
305
|
+
if key in self._slot_memo:
|
|
306
|
+
return self._slot_memo[key]
|
|
307
|
+
found = None
|
|
308
|
+
for seq, rule in reversed(table.get(resource, ())):
|
|
309
|
+
if privilege is None:
|
|
310
|
+
if rule.privileges is None:
|
|
311
|
+
found = (seq, rule)
|
|
312
|
+
break
|
|
313
|
+
elif rule.privileges is not None and privilege in self._privilege_sets[id(rule)]:
|
|
314
|
+
found = (seq, rule)
|
|
315
|
+
break
|
|
316
|
+
self._slot_memo[key] = found
|
|
317
|
+
return found
|
|
318
|
+
|
|
319
|
+
def _slot(self, table: dict[str, list[tuple[int, AclRule]]], level: str, privilege: str | None):
|
|
320
|
+
"""The rule in a slot. Laminas' setRule applies a `null`-resource rule
|
|
321
|
+
to every resource registered so far, overwriting their slots; Omeka
|
|
322
|
+
registers every controller before adding rules, so at a specific
|
|
323
|
+
level the later of the specific rule and the `null`-resource rule wins."""
|
|
324
|
+
own = self._written(table, level, privilege)
|
|
325
|
+
if level == ALL:
|
|
326
|
+
return own[1] if own else None
|
|
327
|
+
spread = self._written(table, ALL, privilege)
|
|
328
|
+
if own is None or (spread is not None and spread[0] > own[0]):
|
|
329
|
+
return spread[1] if spread else None
|
|
330
|
+
return own[1]
|
|
331
|
+
|
|
332
|
+
def _lookup(self, tables: list[tuple[str, dict[str, list[tuple[int, AclRule]]]]], resource: str, privilege: str):
|
|
333
|
+
"""(result, rule, resource level) for one principal; see the module docstring."""
|
|
334
|
+
for level in (resource, ALL) if resource != ALL else (ALL,):
|
|
335
|
+
for kind, table in tables:
|
|
336
|
+
rule = self._slot(table, level, privilege)
|
|
337
|
+
if rule is not None:
|
|
338
|
+
return (None if rule.conditional else rule.allow), rule, level
|
|
339
|
+
rule = self._slot(table, level, None)
|
|
340
|
+
if rule is not None:
|
|
341
|
+
if rule.conditional:
|
|
342
|
+
return None, rule, level
|
|
343
|
+
if kind == "role" or rule.allow or level == ALL:
|
|
344
|
+
return rule.allow, rule, level
|
|
345
|
+
# All-roles, all-privileges deny on the specific
|
|
346
|
+
# resource: Laminas continues to the parent level.
|
|
347
|
+
return "default", None, None
|
|
348
|
+
|
|
349
|
+
def _privileges_for(self, resource: str) -> set[str] | None:
|
|
350
|
+
"""Privileges public rules name for ``resource`` or all resources;
|
|
351
|
+
None past ``MAX_PRIVILEGES_PER_DECISION``."""
|
|
352
|
+
found: set[str] = set()
|
|
353
|
+
for table in (self.all_roles, self.guest):
|
|
354
|
+
for level in (resource, ALL):
|
|
355
|
+
for _seq, rule in table.get(level, ()):
|
|
356
|
+
if rule.privileges is not None:
|
|
357
|
+
found.update(rule.privileges)
|
|
358
|
+
if len(found) > MAX_PRIVILEGES_PER_DECISION:
|
|
359
|
+
return None
|
|
360
|
+
return found
|
|
361
|
+
|
|
362
|
+
def _public(self, resource: str, privilege: str):
|
|
363
|
+
"""Is the visitor or a guest allowed? Returns (result, rule, level)."""
|
|
364
|
+
visitor = self._lookup([("all", self.all_roles)], resource, privilege)
|
|
365
|
+
if visitor[0] is True:
|
|
366
|
+
return visitor
|
|
367
|
+
guest = self._lookup([("role", self.guest), ("all", self.all_roles)], resource, privilege)
|
|
368
|
+
if guest[0] is True:
|
|
369
|
+
return guest
|
|
370
|
+
if visitor[0] is None or guest[0] is None:
|
|
371
|
+
return None, visitor[1] or guest[1], visitor[2] or guest[2]
|
|
372
|
+
# Both denied: report the visitor's reason.
|
|
373
|
+
return visitor
|
|
374
|
+
|
|
375
|
+
# -- decisions --------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
def decide(self, controller: str, action: str | None) -> Decision:
|
|
378
|
+
"""Access to ``controller``/``action`` (None = any action, from `:action`)."""
|
|
379
|
+
key = (controller, action)
|
|
380
|
+
cached = self._memo.get(key)
|
|
381
|
+
if cached is not None:
|
|
382
|
+
return cached
|
|
383
|
+
if self.fresh_decisions >= MAX_DECISIONS:
|
|
384
|
+
return Decision("unknown") # per-repo budget spent
|
|
385
|
+
self.fresh_decisions += 1
|
|
386
|
+
decision = self._decide(controller, action)
|
|
387
|
+
if decision.auth == "required" and controller in INSTALL_CONTROLLERS:
|
|
388
|
+
decision = Decision(
|
|
389
|
+
"unknown",
|
|
390
|
+
[],
|
|
391
|
+
f"{controller} serves while Omeka isn't installed or needs a migration, when AclFactory grants "
|
|
392
|
+
"every role everything ($acl->allow()) and MvcListeners redirects there; after that: "
|
|
393
|
+
+ (decision.evidence or ""),
|
|
394
|
+
)
|
|
395
|
+
self._memo[key] = decision
|
|
396
|
+
return decision
|
|
397
|
+
|
|
398
|
+
def _decide(self, controller: str, action: str | None) -> Decision:
|
|
399
|
+
if self.registered is not None and controller not in self.registered:
|
|
400
|
+
if controller in self.aliases:
|
|
401
|
+
return Decision(
|
|
402
|
+
"anonymous",
|
|
403
|
+
[],
|
|
404
|
+
f"'{controller}' is a controller alias, not an ACL resource (Omeka adds only "
|
|
405
|
+
"controllers.invokables/factories keys), and authorizeUserAgainstController allows a "
|
|
406
|
+
"controller that isn't a resource",
|
|
407
|
+
)
|
|
408
|
+
return Decision("unknown") # registered somewhere we didn't see, or not at all
|
|
409
|
+
if controller in self.unresolved_role_resources or ALL in self.unresolved_role_resources:
|
|
410
|
+
return Decision("unknown")
|
|
411
|
+
# A privilege list we couldn't read (a variable or constant) might
|
|
412
|
+
# name any action, fixed or not.
|
|
413
|
+
if controller in self.unresolved_privileges or ALL in self.unresolved_privileges:
|
|
414
|
+
return Decision("unknown")
|
|
415
|
+
if action is not None:
|
|
416
|
+
result, rule, level = self._public(controller, action)
|
|
417
|
+
return self._from_result(controller, result, rule, level, action)
|
|
418
|
+
privileges = self._privileges_for(controller)
|
|
419
|
+
if privileges is None:
|
|
420
|
+
return Decision("unknown")
|
|
421
|
+
outcomes = [self._public(controller, p) for p in sorted(privileges)]
|
|
422
|
+
other = self._public(controller, _OTHER_ACTION)
|
|
423
|
+
results = {o[0] for o in outcomes} | {other[0]}
|
|
424
|
+
if results == {True}:
|
|
425
|
+
return self._from_result(controller, True, other[1], other[2], None)
|
|
426
|
+
if results <= {False, "default"}:
|
|
427
|
+
return self._from_result(controller, other[0], other[1], other[2], None)
|
|
428
|
+
return Decision("unknown") # some actions public, some not, or conditional
|
|
429
|
+
|
|
430
|
+
def _from_result(self, controller: str, result, rule: AclRule | None, level: str | None, action: str | None):
|
|
431
|
+
if result is True and rule is not None and level is not None:
|
|
432
|
+
return Decision("anonymous", [], _describe(rule, level))
|
|
433
|
+
if result is None:
|
|
434
|
+
return Decision("unknown")
|
|
435
|
+
if self.blocked:
|
|
436
|
+
return Decision("unknown")
|
|
437
|
+
roles = sorted(self.allowed_roles.get(controller, set()) | self.allowed_roles.get(ALL, set()))
|
|
438
|
+
if result is False and rule is not None and level is not None:
|
|
439
|
+
guards = ["omeka_acl:deny_anonymous", *(f"omeka_acl:requires_role({r})" for r in roles)]
|
|
440
|
+
return Decision("required", guards, _describe(rule, level))
|
|
441
|
+
guards = [f"omeka_acl:requires_role({role})" for role in roles] or ["omeka_acl:default_deny"]
|
|
442
|
+
if roles:
|
|
443
|
+
basis = f"requires_role: {', '.join(roles)}"
|
|
444
|
+
else:
|
|
445
|
+
basis = "requires_role: only roles allowed every resource, site_admin and global_admin in stock Omeka"
|
|
446
|
+
what = f"{controller} action '{action}'" if action is not None else controller
|
|
447
|
+
return Decision(
|
|
448
|
+
"required",
|
|
449
|
+
guards,
|
|
450
|
+
f"Omeka ACL: no rule allows the anonymous role {what}; Omeka denies by default and checks "
|
|
451
|
+
f"the role on every dispatch ({basis})",
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def combine(decisions: list[Decision], namespace: str) -> Decision:
|
|
456
|
+
"""One decision for a `:controller` route, which reaches every controller
|
|
457
|
+
registered under ``namespace``: anonymous or required only if all agree."""
|
|
458
|
+
if not decisions:
|
|
459
|
+
return Decision("unknown")
|
|
460
|
+
kinds = {d.auth for d in decisions}
|
|
461
|
+
if len(kinds) != 1 or kinds == {"unknown"}:
|
|
462
|
+
return Decision("unknown")
|
|
463
|
+
auth = kinds.pop()
|
|
464
|
+
evidence = sorted({d.evidence or "" for d in decisions})
|
|
465
|
+
shown = "; ".join(evidence[:3]) + (f"; +{len(evidence) - 3} more" if len(evidence) > 3 else "")
|
|
466
|
+
summary = f"all {len(decisions)} controllers under {namespace}: {shown}"
|
|
467
|
+
guards = sorted({g for d in decisions for g in d.guards})
|
|
468
|
+
return Decision(auth, guards, summary)
|