ocmo-api 0.8.19__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.
Files changed (203) hide show
  1. LICENSE +201 -0
  2. NOTICE +16 -0
  3. core/__init__.py +0 -0
  4. core/api/__init__.py +201 -0
  5. core/api/_common.py +37 -0
  6. core/api/audit.py +244 -0
  7. core/api/auth.py +50 -0
  8. core/api/config.py +96 -0
  9. core/api/errors.py +65 -0
  10. core/api/global_permissions.py +107 -0
  11. core/api/lock.py +99 -0
  12. core/api/namespace.py +86 -0
  13. core/api/propagation.py +29 -0
  14. core/api/resolve.py +229 -0
  15. core/api/resolver.py +96 -0
  16. core/api/secret.py +53 -0
  17. core/api/system.py +42 -0
  18. core/api/template.py +53 -0
  19. core/api/tree.py +274 -0
  20. core/apps.py +18 -0
  21. core/audit_operations.py +0 -0
  22. core/constants/__init__.py +5 -0
  23. core/constants/audit_operations.py +35 -0
  24. core/constants/permission_actions.py +59 -0
  25. core/constants/permissions.py +41 -0
  26. core/constants/resolve.py +3 -0
  27. core/constants/transformers.py +22 -0
  28. core/constants/webhook_events.py +31 -0
  29. core/data/builtin_schemas/_git_sync.schema.yaml +24 -0
  30. core/data/builtin_schemas/_permissions.schema.yaml +211 -0
  31. core/data/builtin_schemas/_webhooks.schema.yaml +145 -0
  32. core/decorators/__init__.py +14 -0
  33. core/decorators/audit.py +105 -0
  34. core/decorators/binding.py +49 -0
  35. core/decorators/permissions.py +129 -0
  36. core/decorators/webhook.py +87 -0
  37. core/exceptions.py +165 -0
  38. core/management/__init__.py +0 -0
  39. core/management/commands/__init__.py +0 -0
  40. core/management/commands/dump_openapi.py +40 -0
  41. core/managers/artifacts.py +346 -0
  42. core/managers/audit/__init__.py +1172 -0
  43. core/managers/audit/timeline.py +365 -0
  44. core/managers/auth.py +276 -0
  45. core/managers/cast.py +506 -0
  46. core/managers/config_validation.py +216 -0
  47. core/managers/crypto.py +78 -0
  48. core/managers/global_permissions.py +183 -0
  49. core/managers/health.py +56 -0
  50. core/managers/lock.py +229 -0
  51. core/managers/namespace.py +132 -0
  52. core/managers/permissions.py +168 -0
  53. core/managers/propagation.py +244 -0
  54. core/managers/resolution.py +938 -0
  55. core/managers/resolve_cache.py +266 -0
  56. core/managers/resolve_parameters.py +503 -0
  57. core/managers/resolver.py +56 -0
  58. core/managers/resolver_tokens.py +114 -0
  59. core/managers/resolving.py +673 -0
  60. core/managers/secret.py +147 -0
  61. core/managers/system.py +44 -0
  62. core/managers/tree/__init__.py +85 -0
  63. core/managers/tree/_common.py +85 -0
  64. core/managers/tree/base.py +203 -0
  65. core/managers/tree/config_ops.py +167 -0
  66. core/managers/tree/constants.py +11 -0
  67. core/managers/tree/create.py +75 -0
  68. core/managers/tree/diff.py +270 -0
  69. core/managers/tree/mutate.py +433 -0
  70. core/managers/tree/paths.py +49 -0
  71. core/managers/tree/propagation.py +200 -0
  72. core/managers/tree/read.py +263 -0
  73. core/managers/tree/resolver_ops.py +21 -0
  74. core/managers/tree/tags.py +167 -0
  75. core/managers/tree/update.py +137 -0
  76. core/managers/tree/versions.py +87 -0
  77. core/managers/tree_capabilities.py +189 -0
  78. core/managers/webhook/__init__.py +3 -0
  79. core/managers/webhook/cache.py +68 -0
  80. core/managers/webhook/main.py +357 -0
  81. core/middleware.py +32 -0
  82. core/migrations/0001_initial.py +157 -0
  83. core/migrations/0002_folder.py +33 -0
  84. core/migrations/0003_alter_namespace_name.py +17 -0
  85. core/migrations/0004_alter_namespace_name_and_more.py +25 -0
  86. core/migrations/0005_configversion_templateversion.py +85 -0
  87. core/migrations/0006_alter_templateversion_options_and_more.py +47 -0
  88. core/migrations/0007_configversion_deleted_at_templateversion_deleted_at.py +22 -0
  89. core/migrations/0008_namespace_tags_dek_secret_global_permissions.py +139 -0
  90. core/migrations/0009_tree_lock.py +58 -0
  91. core/migrations/0010_resolver_token_encryption.py +75 -0
  92. core/migrations/0011_audit_event.py +134 -0
  93. core/migrations/0012_audit_event_namespace_name.py +24 -0
  94. core/migrations/0013_audit_event_object_id.py +28 -0
  95. core/migrations/0014_sync_permissions_webhooks_builtin_schemas.py +26 -0
  96. core/migrations/0015_audit_event_operation_subresource.py +27 -0
  97. core/migrations/0016_sync_permissions_schema_audit_actions.py +26 -0
  98. core/migrations/0017_sync_permissions_schema_valid_actions.py +26 -0
  99. core/migrations/__init__.py +0 -0
  100. core/models/__init__.py +9 -0
  101. core/models/audit.py +97 -0
  102. core/models/config.py +132 -0
  103. core/models/folder.py +12 -0
  104. core/models/global_permissions.py +27 -0
  105. core/models/lock.py +35 -0
  106. core/models/namespace.py +45 -0
  107. core/models/resolver.py +25 -0
  108. core/models/secret.py +58 -0
  109. core/models/tree.py +29 -0
  110. core/notice.py +18 -0
  111. core/operation_ids.py +153 -0
  112. core/schemas/__init__.py +2 -0
  113. core/schemas/audit.py +75 -0
  114. core/schemas/cast_json_schema.py +57 -0
  115. core/schemas/cast_options.py +508 -0
  116. core/schemas/generic.py +233 -0
  117. core/schemas/metadata_json_schema.py +21 -0
  118. core/schemas/propagation.py +88 -0
  119. core/schemas/requests.py +773 -0
  120. core/schemas/resolver_json_schema.py +65 -0
  121. core/schemas/responses.py +690 -0
  122. core/schemas/webhooks.py +40 -0
  123. core/shortcuts.py +415 -0
  124. core/tests/__init__.py +0 -0
  125. core/tests/auth_helpers.py +23 -0
  126. core/tests/namespace_helpers.py +29 -0
  127. core/tests/test_api_cli.py +43 -0
  128. core/tests/test_api_errors.py +151 -0
  129. core/tests/test_artifacts.py +123 -0
  130. core/tests/test_audit.py +973 -0
  131. core/tests/test_audit_event_id.py +90 -0
  132. core/tests/test_audit_operations.py +222 -0
  133. core/tests/test_builtin_config_schemas.py +174 -0
  134. core/tests/test_can_i.py +510 -0
  135. core/tests/test_cast_format_options.py +57 -0
  136. core/tests/test_cast_options_schema.py +54 -0
  137. core/tests/test_cast_yaml.py +26 -0
  138. core/tests/test_config_schema_api.py +173 -0
  139. core/tests/test_config_validation.py +378 -0
  140. core/tests/test_document_parser.py +122 -0
  141. core/tests/test_dump_openapi.py +46 -0
  142. core/tests/test_extend_key.py +267 -0
  143. core/tests/test_folder_get_item.py +23 -0
  144. core/tests/test_folder_resolve_missing_tags.py +73 -0
  145. core/tests/test_get_item_version.py +31 -0
  146. core/tests/test_global_permission_create.py +171 -0
  147. core/tests/test_list_pagination.py +262 -0
  148. core/tests/test_locks.py +209 -0
  149. core/tests/test_multiline_transformer.py +132 -0
  150. core/tests/test_namespace_create.py +11 -0
  151. core/tests/test_ocmo_metadata.py +127 -0
  152. core/tests/test_oidc_jwt_audiences.py +64 -0
  153. core/tests/test_omit_transformer.py +78 -0
  154. core/tests/test_operation_ids.py +47 -0
  155. core/tests/test_permissions.py +1148 -0
  156. core/tests/test_propagation.py +441 -0
  157. core/tests/test_public_base_url.py +20 -0
  158. core/tests/test_resolve_draft.py +176 -0
  159. core/tests/test_resolve_parameters_version.py +181 -0
  160. core/tests/test_resolve_permissions.py +492 -0
  161. core/tests/test_resolve_resolver_response.py +107 -0
  162. core/tests/test_resolver_auth.py +33 -0
  163. core/tests/test_resolver_folder_filters.py +95 -0
  164. core/tests/test_resolver_get_item.py +23 -0
  165. core/tests/test_resolver_navigate_search_scope.py +98 -0
  166. core/tests/test_resolver_schema_api.py +91 -0
  167. core/tests/test_security_hardening.py +324 -0
  168. core/tests/test_selectors.py +65 -0
  169. core/tests/test_special_namespace_paths.py +409 -0
  170. core/tests/test_stable_promotion.py +124 -0
  171. core/tests/test_system.py +140 -0
  172. core/tests/test_tree_manager_for_item.py +192 -0
  173. core/tests/test_two_layer_cache.py +334 -0
  174. core/tests/test_url_trailing_slashes.py +53 -0
  175. core/tests/test_validation_error_responses.py +43 -0
  176. core/tests/test_webhooks.py +985 -0
  177. core/utils/__init__.py +23 -0
  178. core/utils/deep_merge.py +91 -0
  179. core/utils/namespace_special_configs.py +105 -0
  180. core/utils/permissions_compiler.py +627 -0
  181. core/utils/permissions_schema_document.py +52 -0
  182. core/validation_errors.py +105 -0
  183. core/version.py +27 -0
  184. core/views.py +1 -0
  185. ocmo_api-0.8.19.dist-info/METADATA +252 -0
  186. ocmo_api-0.8.19.dist-info/RECORD +203 -0
  187. ocmo_api-0.8.19.dist-info/WHEEL +4 -0
  188. ocmo_api-0.8.19.dist-info/entry_points.txt +2 -0
  189. ocmo_api-0.8.19.dist-info/licenses/LICENSE +201 -0
  190. ocmo_api-0.8.19.dist-info/licenses/NOTICE +16 -0
  191. ocmoapi/__init__.py +1 -0
  192. ocmoapi/__main__.py +6 -0
  193. ocmoapi/asgi.py +16 -0
  194. ocmoapi/auth.py +163 -0
  195. ocmoapi/cli.py +85 -0
  196. ocmoapi/oauth2_provider.py +23 -0
  197. ocmoapi/parser.py +120 -0
  198. ocmoapi/settings.py +275 -0
  199. ocmoapi/templates/ninja/oauth2-redirect.html +81 -0
  200. ocmoapi/templates/ninja/swagger.html +55 -0
  201. ocmoapi/testing_auth.py +64 -0
  202. ocmoapi/urls.py +40 -0
  203. ocmoapi/wsgi.py +16 -0
@@ -0,0 +1,1148 @@
1
+ """Permissions system tests.
2
+
3
+ Covers:
4
+ - Compile-time: glob→regex, claim sanitization, policy set / global rules compilation.
5
+ - Eval-time: deny-over-allow, default deny, resolver implicit scope, {!user.x}
6
+ interpolation, conditions, global first-match / write-implies-read.
7
+ - LRU: version-keyed cache, global revision key.
8
+ - AuthManager: user/resolver/anonymous properties, is_global_admin.
9
+ - Integration (DB): namespace endpoints + tree get_item via manager calls.
10
+ """
11
+
12
+ from unittest.mock import patch
13
+
14
+ from django.test import Client, TestCase, override_settings
15
+
16
+ from core.decorators import PermCheck, require_permissions
17
+ from core.exceptions import NotFound, PermissionDenied
18
+ from core.managers.auth import AuthManager
19
+ from core.managers.permissions import PermissionsManager
20
+ from core.models import GlobalPermissionRule, Namespace
21
+ from core.tests.namespace_helpers import create_test_namespace
22
+ from core.utils.permissions_compiler import (
23
+ CompiledGlobalRules,
24
+ PermissionsCompiler,
25
+ )
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Unit: glob → regex
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ class TestGlobToRegex(TestCase):
33
+ def _match(self, glob, path):
34
+ import re
35
+
36
+ regex_str, _ = PermissionsCompiler.glob_to_regex(glob)
37
+ return re.compile(regex_str).fullmatch(path) is not None
38
+
39
+ def test_literal_matches_exactly(self):
40
+ self.assertTrue(self._match("project/env/app", "project/env/app"))
41
+ self.assertFalse(self._match("project/env/app", "project/env/other"))
42
+
43
+ def test_single_star_matches_one_segment(self):
44
+ self.assertTrue(self._match("project/*/app", "project/dev/app"))
45
+ self.assertTrue(self._match("project/*/app", "project/prod/app"))
46
+ self.assertFalse(self._match("project/*/app", "project/dev/inner/app"))
47
+
48
+ def test_double_star_matches_multiple_segments(self):
49
+ self.assertTrue(self._match("project/**", "project/a/b/c"))
50
+ self.assertTrue(self._match("project/**", "project/a"))
51
+ self.assertTrue(self._match("**", "any/path/at/all"))
52
+
53
+ def test_double_star_in_middle_zero_segments(self):
54
+ """project/**/secrets/* matches project/secrets/key (zero intermediate segments)."""
55
+ self.assertTrue(self._match("project/**/secrets/*", "project/secrets/key"))
56
+
57
+ def test_double_star_in_middle_one_segment(self):
58
+ self.assertTrue(self._match("project/**/secrets/*", "project/a/secrets/key"))
59
+
60
+ def test_double_star_in_middle_two_segments(self):
61
+ self.assertTrue(self._match("project/**/secrets/*", "project/a/b/secrets/key"))
62
+
63
+ def test_double_star_in_middle_no_match_wrong_suffix(self):
64
+ self.assertFalse(self._match("project/**/secrets/*", "project/a/other/key"))
65
+
66
+ def test_question_mark_matches_single_char(self):
67
+ self.assertTrue(self._match("app?conf", "appXconf"))
68
+ self.assertFalse(self._match("app?conf", "app/conf"))
69
+
70
+ def test_interpolation_slot_extracted(self):
71
+ import re
72
+
73
+ regex_str, slots = PermissionsCompiler.glob_to_regex("personal/{!user.email}/**")
74
+ self.assertEqual(len(slots), 1)
75
+ group, (kind, attr) = list(slots.items())[0]
76
+ self.assertEqual(kind, "user")
77
+ self.assertEqual(attr, "email")
78
+ pattern = re.compile(regex_str)
79
+ m = pattern.fullmatch("personal/bob-example-com/configs")
80
+ self.assertIsNotNone(m)
81
+
82
+ def test_resolver_name_slot(self):
83
+ regex_str, slots = PermissionsCompiler.glob_to_regex("resolvers/{!resolver.name}/data")
84
+ self.assertEqual(len(slots), 1)
85
+ _, (kind, attr) = list(slots.items())[0]
86
+ self.assertEqual(kind, "resolver")
87
+ self.assertEqual(attr, "name")
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Unit: claim sanitization
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ class TestSanitizeClaimForPath(TestCase):
96
+ def test_lowercase(self):
97
+ self.assertEqual(PermissionsCompiler.sanitize_claim_for_path("BOB"), "bob")
98
+
99
+ def test_strips_whitespace(self):
100
+ self.assertEqual(PermissionsCompiler.sanitize_claim_for_path(" alice "), "alice")
101
+
102
+ def test_first_line_only(self):
103
+ self.assertEqual(PermissionsCompiler.sanitize_claim_for_path("line1\nline2"), "line1")
104
+
105
+ def test_special_chars_replaced(self):
106
+ self.assertEqual(PermissionsCompiler.sanitize_claim_for_path("bob@example.com"), "bob-example-com")
107
+
108
+ def test_allowed_chars_preserved(self):
109
+ self.assertEqual(PermissionsCompiler.sanitize_claim_for_path("my_user-123"), "my_user-123")
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Unit: AuthManager
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ class TestAuthManager(TestCase):
118
+ def _user_auth(self, **claims):
119
+ return AuthManager({"_type": "user", **claims})
120
+
121
+ def _resolver_auth(self, namespace_id=1, name="my-resolver", access_scope="proj/env"):
122
+ return AuthManager(
123
+ {
124
+ "_type": "resolver",
125
+ "namespace": namespace_id,
126
+ "name": name,
127
+ "access_scope": access_scope,
128
+ "token_number": 1,
129
+ }
130
+ )
131
+
132
+ def test_user_type_detection(self):
133
+ auth = self._user_auth(email="bob@example.com")
134
+ self.assertTrue(auth.is_user)
135
+ self.assertFalse(auth.is_resolver)
136
+ self.assertTrue(auth.is_authorized)
137
+
138
+ def test_resolver_type_detection(self):
139
+ auth = self._resolver_auth()
140
+ self.assertFalse(auth.is_user)
141
+ self.assertTrue(auth.is_resolver)
142
+
143
+ def test_anonymous_from_none_raises(self):
144
+ """from_request(None) raises Unauthenticated."""
145
+ from core.exceptions import Unauthenticated
146
+
147
+ with self.assertRaises(Unauthenticated):
148
+ AuthManager.from_request(None)
149
+
150
+ def test_test_user_is_global_admin(self):
151
+ from ocmoapi.testing_auth import default_test_user_claims
152
+
153
+ auth = AuthManager(default_test_user_claims())
154
+ self.assertTrue(auth.is_user)
155
+ self.assertTrue(auth.is_global_admin)
156
+
157
+ def test_get_claim(self):
158
+ auth = self._user_auth(email="bob@example.com", groups=["admins"])
159
+ self.assertEqual(auth.get_claim("email"), "bob@example.com")
160
+ self.assertIsNone(auth.get_claim("nonexistent"))
161
+
162
+ def test_claims_excludes_internal_keys(self):
163
+ auth = self._user_auth(email="bob@example.com")
164
+ self.assertNotIn("_type", auth.claims)
165
+ self.assertIn("email", auth.claims)
166
+
167
+ def test_is_global_admin_true(self):
168
+ auth = self._user_auth(email="admin@example.com")
169
+ self.assertTrue(auth.is_global_admin)
170
+
171
+ def test_is_global_admin_false(self):
172
+ auth = self._user_auth(email="other@example.com")
173
+ self.assertFalse(auth.is_global_admin)
174
+
175
+ @override_settings(
176
+ OIDC_GLOBAL_ADMIN_CLAIM="groups",
177
+ OIDC_GLOBAL_ADMIN_VALUE="ocmo-global-admins",
178
+ )
179
+ def test_is_global_admin_list_claim(self):
180
+ auth = self._user_auth(groups=["ocmo-global-admins", "devs"])
181
+ self.assertTrue(auth.is_global_admin)
182
+
183
+ def test_resolver_path(self):
184
+ auth = self._resolver_auth(name="svc", access_scope="proj/env")
185
+ self.assertEqual(auth.resolver_path, "proj/env/svc")
186
+
187
+ def test_resolver_path_no_scope(self):
188
+ auth = AuthManager(
189
+ {
190
+ "_type": "resolver",
191
+ "namespace": 1,
192
+ "name": "root-svc",
193
+ "access_scope": "",
194
+ "token_number": 1,
195
+ }
196
+ )
197
+ self.assertEqual(auth.resolver_path, "root-svc")
198
+
199
+ def test_actor_identity_user_uses_display_name(self):
200
+ auth = self._user_auth(sub="user-123", email="alice@example.com", name="Alice")
201
+ self.assertEqual(auth.actor_identity, "Alice")
202
+
203
+ def test_actor_identity_resolver_uses_display_name(self):
204
+ auth = self._resolver_auth(name="svc", access_scope="proj/env")
205
+ self.assertEqual(auth.actor_identity, "Resolver (proj/env/svc)")
206
+
207
+ def test_resolve_actor_identity_defaults_when_auth_is_none(self):
208
+ self.assertEqual(AuthManager.resolve_actor_identity(None), "Ocmo")
209
+
210
+ def test_decision_memo(self):
211
+ auth = self._user_auth()
212
+ key = ("global", "my-ns", "read")
213
+ self.assertIsNone(auth._get_memo(key))
214
+ auth._set_memo(key, True)
215
+ self.assertTrue(auth._get_memo(key))
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Helpers: minimal namespace stub for unit tests
220
+ # ---------------------------------------------------------------------------
221
+
222
+
223
+ def _ns_stub(name="test", ns_id=1):
224
+ """Return a minimal namespace-like object for unit tests (no DB)."""
225
+ return type("NS", (), {"id": ns_id, "name": name, "permissions_tag": "latest"})()
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Unit: PermissionsCompiler.compile_policy_set + evaluation (patching permissions_compile module)
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ class TestCompilePolicySet(TestCase):
234
+ def _user_auth(self, **claims):
235
+ return AuthManager({"_type": "user", **claims})
236
+
237
+ def _resolver_auth(self, name="svc", scope="proj/env"):
238
+ return AuthManager(
239
+ {
240
+ "_type": "resolver",
241
+ "namespace": 1,
242
+ "name": name,
243
+ "access_scope": scope,
244
+ "token_number": 1,
245
+ }
246
+ )
247
+
248
+ def _allow_user(self, claims, actions, resources, conditions=None):
249
+ p = {
250
+ "effect": "Allow",
251
+ "actors": [{"kind": "User", "claims": claims}],
252
+ "actions": actions,
253
+ "resources": resources,
254
+ }
255
+ if conditions:
256
+ p["conditions"] = conditions
257
+ return p
258
+
259
+ def _deny_user(self, claims, actions, resources):
260
+ return {
261
+ "effect": "Deny",
262
+ "actors": [{"kind": "User", "claims": claims}],
263
+ "actions": actions,
264
+ "resources": resources,
265
+ }
266
+
267
+ def _pm(self, auth, ns=None):
268
+ pm = PermissionsManager(auth, ns or _ns_stub())
269
+ return pm
270
+
271
+ def test_default_deny(self):
272
+ ps = PermissionsCompiler.compile_policy_set({"policies": []})
273
+ auth = self._user_auth(email="bob@example.com")
274
+ pm = self._pm(auth)
275
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
276
+ self.assertFalse(pm.check_tree("config:read", "project/app"))
277
+
278
+ def test_allow_policy_grants_access(self):
279
+ ps = PermissionsCompiler.compile_policy_set(
280
+ {"policies": [self._allow_user({"email": "bob@example.com"}, ["config:read"], ["**"])]}
281
+ )
282
+ auth = self._user_auth(email="bob@example.com")
283
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
284
+ self.assertTrue(self._pm(auth).check_tree("config:read", "project/app"))
285
+
286
+ def test_deny_overrides_allow(self):
287
+ ps = PermissionsCompiler.compile_policy_set(
288
+ {
289
+ "policies": [
290
+ self._allow_user({"groups": "*"}, ["config:read"], ["**"]),
291
+ self._deny_user({"email": "bob@example.com"}, ["config:read"], ["sensitive/**"]),
292
+ ]
293
+ }
294
+ )
295
+ auth = self._user_auth(email="bob@example.com", groups="devs")
296
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
297
+ pm = self._pm(auth)
298
+ self.assertTrue(pm.check_tree("config:read", "project/app"))
299
+ self.assertFalse(pm.check_tree("config:read", "sensitive/secrets"))
300
+
301
+ def test_wildcard_action_type(self):
302
+ """config:* bucket matches config:read and config:write."""
303
+ ps = PermissionsCompiler.compile_policy_set(
304
+ {"policies": [self._allow_user({"email": "*"}, ["config:*"], ["**"])]}
305
+ )
306
+ auth = self._user_auth(email="bob@example.com")
307
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
308
+ pm = self._pm(auth)
309
+ self.assertTrue(pm.check_tree("config:read", "anything"))
310
+ self.assertTrue(pm.check_tree("config:write", "anything"))
311
+
312
+ def test_star_star_action(self):
313
+ """*:* matches any action."""
314
+ ps = PermissionsCompiler.compile_policy_set({"policies": [self._allow_user({"email": "*"}, ["*:*"], ["**"])]})
315
+ auth = self._user_auth(email="anyone")
316
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
317
+ pm = self._pm(auth)
318
+ self.assertTrue(pm.check_tree("secret:read", "any/path"))
319
+ self.assertTrue(pm.check_tree("resolver:delete", "other"))
320
+
321
+ def test_wildcard_audit_action(self):
322
+ """*:audit matches typed audit checks such as config:audit and resolver:audit."""
323
+ ps = PermissionsCompiler.compile_policy_set(
324
+ {"policies": [self._allow_user({"email": "auditor@example.com"}, ["*:audit"], ["**"])]}
325
+ )
326
+ auth = self._user_auth(email="auditor@example.com")
327
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
328
+ pm = self._pm(auth)
329
+ self.assertTrue(pm.check_tree("config:audit", "project/app"))
330
+ self.assertTrue(pm.check_tree("resolver:audit", "deploy/svc"))
331
+ self.assertFalse(pm.check_tree("config:read", "project/app"))
332
+
333
+ def test_user_interpolation_in_resource(self):
334
+ ps = PermissionsCompiler.compile_policy_set(
335
+ {"policies": [self._allow_user({"email": "*"}, ["config:read"], ["personal/{!user.email}/**"])]}
336
+ )
337
+ auth_bob = self._user_auth(email="bob@example.com")
338
+ auth_alice = self._user_auth(email="alice@example.com")
339
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
340
+ self.assertTrue(self._pm(auth_bob).check_tree("config:read", "personal/bob-example-com/cfg"))
341
+ self.assertFalse(self._pm(auth_alice).check_tree("config:read", "personal/bob-example-com/cfg"))
342
+
343
+ def test_resolver_actor_match(self):
344
+ ps = PermissionsCompiler.compile_policy_set(
345
+ {
346
+ "policies": [
347
+ {
348
+ "effect": "Allow",
349
+ "actors": [{"kind": "Resolver", "path": "proj/env/svc"}],
350
+ "actions": ["config:read"],
351
+ "resources": ["**"],
352
+ }
353
+ ]
354
+ }
355
+ )
356
+ auth = self._resolver_auth(name="svc", scope="proj/env")
357
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
358
+ # config:read is NOT the implicit-scope action, so policy scan happens
359
+ self.assertTrue(self._pm(auth).check_tree("config:read", "proj/env/app"))
360
+
361
+ def test_resolver_implicit_scope_shortcut(self):
362
+ """Resolver gets config:resolve within its scope without any matching policy."""
363
+ ps = PermissionsCompiler.compile_policy_set({"policies": []})
364
+ auth = self._resolver_auth(name="svc", scope="proj/env")
365
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
366
+ pm = self._pm(auth)
367
+ self.assertTrue(pm.check_tree("config:resolve", "proj/env/app"))
368
+ self.assertTrue(pm.check_tree("config:resolve", "proj/env/nested/path"))
369
+ self.assertTrue(pm.check_tree("config:resolve", "proj/env"))
370
+ self.assertFalse(pm.check_tree("config:resolve", "other/path"))
371
+
372
+ def test_resolver_implicit_secret_resolve_scope_shortcut(self):
373
+ """Resolver gets secret:resolve within its scope without any matching policy."""
374
+ ps = PermissionsCompiler.compile_policy_set({"policies": []})
375
+ auth = self._resolver_auth(name="svc", scope="proj/env")
376
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
377
+ pm = self._pm(auth)
378
+ self.assertTrue(pm.check_tree("secret:resolve", "proj/env/app/secret"))
379
+ self.assertFalse(pm.check_tree("secret:resolve", "other/secret"))
380
+
381
+ def test_time_of_day_condition_pass(self):
382
+ import datetime as dt
383
+
384
+ ps = PermissionsCompiler.compile_policy_set(
385
+ {
386
+ "policies": [
387
+ self._allow_user(
388
+ {"email": "*"}, ["config:read"], ["**"], conditions={"time_of_day": ["00:00-23:59"]}
389
+ )
390
+ ]
391
+ }
392
+ )
393
+ ctx = {"time": dt.datetime(2024, 1, 1, 12, 0, tzinfo=dt.UTC)}
394
+ auth = self._user_auth(email="bob@example.com")
395
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
396
+ self.assertTrue(self._pm(auth).check_tree("config:read", "app", request_ctx=ctx))
397
+
398
+ def test_time_of_day_condition_fail(self):
399
+ import datetime as dt
400
+
401
+ ps = PermissionsCompiler.compile_policy_set(
402
+ {
403
+ "policies": [
404
+ self._allow_user(
405
+ {"email": "*"}, ["config:read"], ["**"], conditions={"time_of_day": ["09:00-17:00"]}
406
+ )
407
+ ]
408
+ }
409
+ )
410
+ ctx = {"time": dt.datetime(2024, 1, 1, 3, 0, tzinfo=dt.UTC)}
411
+ auth = self._user_auth(email="bob@example.com")
412
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
413
+ self.assertFalse(self._pm(auth).check_tree("config:read", "app", request_ctx=ctx))
414
+
415
+ def test_decision_memoised_on_auth_manager(self):
416
+ ps = PermissionsCompiler.compile_policy_set(
417
+ {"policies": [self._allow_user({"email": "*"}, ["config:read"], ["**"])]}
418
+ )
419
+ auth = self._user_auth(email="bob@example.com")
420
+ pm = self._pm(auth)
421
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
422
+ pm.check_tree("config:read", "app")
423
+ # Memo is now set — verify it directly
424
+ ns_id = pm.namespace.id
425
+ self.assertEqual(auth._get_memo(("tree", ns_id, "config:read", "app")), True)
426
+ # Second call uses memo (not policy scan)
427
+ pm.check_tree("config:read", "app")
428
+
429
+
430
+ # ---------------------------------------------------------------------------
431
+ # Unit: PermissionsCompiler.compile_global_rules + evaluation
432
+ # ---------------------------------------------------------------------------
433
+
434
+
435
+ class TestCompileGlobalRules(TestCase):
436
+ def _user_auth(self, **claims):
437
+ return AuthManager({"_type": "user", **claims})
438
+
439
+ def _pm(self, auth):
440
+ return PermissionsManager(auth)
441
+
442
+ def test_empty_rules_deny_all(self):
443
+ compiled = PermissionsCompiler.compile_global_rules([])
444
+ auth = self._user_auth(email="bob@example.com")
445
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_global_rules", return_value=compiled):
446
+ self.assertFalse(self._pm(auth).check_namespace_object("any-ns", "read"))
447
+
448
+ def test_matching_rule_allows(self):
449
+ compiled = PermissionsCompiler.compile_global_rules(
450
+ [
451
+ {
452
+ "namespace": "team-*",
453
+ "read": {"actors": [{"kind": "User", "claims": {"groups": "team-devs"}}]},
454
+ }
455
+ ]
456
+ )
457
+ auth = self._user_auth(groups="team-devs")
458
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_global_rules", return_value=compiled):
459
+ pm = self._pm(auth)
460
+ self.assertTrue(pm.check_namespace_object("team-alpha", "read"))
461
+ self.assertFalse(pm.check_namespace_object("team-alpha", "write"))
462
+
463
+ def test_write_implies_read(self):
464
+ compiled = PermissionsCompiler.compile_global_rules(
465
+ [
466
+ {
467
+ "namespace": "myns",
468
+ "write": {"actors": [{"kind": "User", "claims": {"email": "admin@example.com"}}]},
469
+ }
470
+ ]
471
+ )
472
+ auth = self._user_auth(email="admin@example.com")
473
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_global_rules", return_value=compiled):
474
+ pm = self._pm(auth)
475
+ self.assertTrue(pm.check_namespace_object("myns", "read"))
476
+ self.assertTrue(pm.check_namespace_object("myns", "write"))
477
+
478
+ def test_delete_does_not_imply_read(self):
479
+ compiled = PermissionsCompiler.compile_global_rules(
480
+ [
481
+ {
482
+ "namespace": "myns",
483
+ "delete": {"actors": [{"kind": "User", "claims": {"email": "ops@example.com"}}]},
484
+ }
485
+ ]
486
+ )
487
+ auth = self._user_auth(email="ops@example.com")
488
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_global_rules", return_value=compiled):
489
+ pm = self._pm(auth)
490
+ self.assertFalse(pm.check_namespace_object("myns", "read"))
491
+ self.assertTrue(pm.check_namespace_object("myns", "delete"))
492
+
493
+ def test_first_match_wins(self):
494
+ compiled = PermissionsCompiler.compile_global_rules(
495
+ [
496
+ {
497
+ "namespace": "shared-*",
498
+ "read": {"actors": [{"kind": "User", "claims": {"groups": "team-a"}}]},
499
+ },
500
+ {
501
+ "namespace": "**",
502
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
503
+ },
504
+ ]
505
+ )
506
+ auth = self._user_auth(email="bob@example.com", groups="other")
507
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_global_rules", return_value=compiled):
508
+ pm = self._pm(auth)
509
+ # "shared-alpha" hits rule 1; bob lacks groups=team-a → deny (rule 2 never checked)
510
+ self.assertFalse(pm.check_namespace_object("shared-alpha", "read"))
511
+ # "other-ns" hits rule 2 → allow (email=*)
512
+ self.assertTrue(pm.check_namespace_object("other-ns", "read"))
513
+
514
+ def test_resolver_always_denied_global(self):
515
+ compiled = PermissionsCompiler.compile_global_rules(
516
+ [
517
+ {
518
+ "namespace": "**",
519
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
520
+ }
521
+ ]
522
+ )
523
+ auth = AuthManager(
524
+ {
525
+ "_type": "resolver",
526
+ "namespace": 1,
527
+ "name": "svc",
528
+ "access_scope": "proj/env",
529
+ "token_number": 1,
530
+ }
531
+ )
532
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_global_rules", return_value=compiled):
533
+ with patch("core.managers.permissions.Namespace.objects.get") as mock_get:
534
+ mock_get.return_value = type("NS", (), {"name": "my-ns"})()
535
+ self.assertFalse(self._pm(auth).check_namespace_object("any-ns", "read"))
536
+
537
+ def test_resolver_read_own_namespace_only(self):
538
+ auth = AuthManager(
539
+ {
540
+ "_type": "resolver",
541
+ "namespace": 1,
542
+ "name": "svc",
543
+ "access_scope": "proj/env",
544
+ "token_number": 1,
545
+ }
546
+ )
547
+ with patch("core.managers.permissions.Namespace.objects.get") as mock_get:
548
+ mock_get.return_value = type("NS", (), {"name": "my-ns"})()
549
+ pm = self._pm(auth)
550
+ self.assertTrue(pm.check_namespace_object("my-ns", "read"))
551
+ self.assertFalse(pm.check_namespace_object("my-ns", "write"))
552
+ self.assertFalse(pm.check_namespace_object("my-ns", "delete"))
553
+
554
+ def test_global_admin_bypasses_rules(self):
555
+ """Global admin has namespace read regardless of rules (write/delete still policy-bound)."""
556
+ compiled = PermissionsCompiler.compile_global_rules([]) # no rules
557
+ auth = AuthManager({"_type": "user", "email": "admin@example.com"})
558
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_global_rules", return_value=compiled):
559
+ pm = self._pm(auth)
560
+ self.assertTrue(pm.check_namespace_object("any-ns", "read"))
561
+ self.assertFalse(pm.check_namespace_object("any-ns", "delete"))
562
+
563
+ def test_global_admin_detection(self):
564
+ auth = AuthManager({"_type": "user", "email": "admin@example.com"})
565
+ self.assertTrue(auth.is_global_admin)
566
+ auth2 = AuthManager({"_type": "user", "email": "other@example.com"})
567
+ self.assertFalse(auth2.is_global_admin)
568
+
569
+ def test_is_catch_all_namespace_pattern(self):
570
+ self.assertTrue(PermissionsCompiler.is_catch_all_namespace_pattern("*"))
571
+ self.assertTrue(PermissionsCompiler.is_catch_all_namespace_pattern("**"))
572
+ self.assertFalse(PermissionsCompiler.is_catch_all_namespace_pattern("team-*"))
573
+
574
+ def test_catch_all_must_be_last_on_compile(self):
575
+ with self.assertRaises(ValueError):
576
+ PermissionsCompiler.compile_global_rules(
577
+ [
578
+ {
579
+ "namespace": "*",
580
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
581
+ },
582
+ {
583
+ "namespace": "team-*",
584
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
585
+ },
586
+ ]
587
+ )
588
+
589
+ def test_validate_global_rules_order_allows_catch_all_last(self):
590
+ PermissionsCompiler.validate_global_rules_order(
591
+ [
592
+ {
593
+ "namespace": "team-*",
594
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
595
+ },
596
+ {
597
+ "namespace": "**",
598
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
599
+ },
600
+ ]
601
+ )
602
+
603
+ def test_resolver_placeholder_rejected_in_namespace_glob(self):
604
+ with self.assertRaises(ValueError):
605
+ PermissionsCompiler.compile_global_rules(
606
+ [
607
+ {
608
+ "namespace": "ns-{!resolver.name}",
609
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
610
+ }
611
+ ]
612
+ )
613
+
614
+ def test_placeholder_namespace_matches_own_user(self):
615
+ compiled = PermissionsCompiler.compile_global_rules(
616
+ [
617
+ {
618
+ "namespace": "personal-{!user.email}",
619
+ "read": {
620
+ "actors": [{"kind": "User", "claims": {"email": "{!user.email}"}}],
621
+ },
622
+ }
623
+ ]
624
+ )
625
+ bob = self._user_auth(email="bob@example.com")
626
+ alice = self._user_auth(email="alice@example.com")
627
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_global_rules", return_value=compiled):
628
+ bob_pm = self._pm(bob)
629
+ alice_pm = self._pm(alice)
630
+ self.assertTrue(bob_pm.check_namespace_object("personal-bob-example-com", "read"))
631
+ self.assertFalse(bob_pm.check_namespace_object("personal-alice-example-com", "read"))
632
+ self.assertFalse(alice_pm.check_namespace_object("personal-bob-example-com", "read"))
633
+
634
+ def test_actor_claim_placeholder_requires_claim(self):
635
+ compiled = PermissionsCompiler.compile_global_rules(
636
+ [
637
+ {
638
+ "namespace": "personal-{!user.email}",
639
+ "read": {
640
+ "actors": [{"kind": "User", "claims": {"email": "{!user.email}"}}],
641
+ },
642
+ }
643
+ ]
644
+ )
645
+ auth = self._user_auth(sub="no-email-user")
646
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_global_rules", return_value=compiled):
647
+ self.assertFalse(self._pm(auth).check_namespace_object("personal-anything", "read"))
648
+
649
+ def test_actor_claim_wildcard_without_email_still_matches_namespace(self):
650
+ compiled = PermissionsCompiler.compile_global_rules(
651
+ [
652
+ {
653
+ "namespace": "personal-{!user.email}",
654
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
655
+ }
656
+ ]
657
+ )
658
+ auth = self._user_auth(sub="no-email-user")
659
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_global_rules", return_value=compiled):
660
+ # Actor passes (wildcard) but namespace slot needs email claim
661
+ self.assertFalse(self._pm(auth).check_namespace_object("personal-anything", "read"))
662
+
663
+ def test_resolver_placeholder_rejected_in_actor_claims(self):
664
+ with self.assertRaises(ValueError):
665
+ PermissionsCompiler.compile_global_rules(
666
+ [
667
+ {
668
+ "namespace": "team-*",
669
+ "read": {
670
+ "actors": [{"kind": "User", "claims": {"email": "{!resolver.name}"}}],
671
+ },
672
+ }
673
+ ]
674
+ )
675
+
676
+ def test_placeholder_missing_claim_does_not_match(self):
677
+ compiled = PermissionsCompiler.compile_global_rules(
678
+ [
679
+ {
680
+ "namespace": "personal-{!user.email}",
681
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
682
+ }
683
+ ]
684
+ )
685
+ auth = self._user_auth(sub="user-without-email")
686
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_global_rules", return_value=compiled):
687
+ self.assertFalse(self._pm(auth).check_namespace_object("personal-anything", "read"))
688
+
689
+ def test_expand_namespace_pattern(self):
690
+ auth = self._user_auth(email="bob@example.com")
691
+ self.assertEqual(
692
+ PermissionsCompiler.expand_namespace_pattern("personal-{!user.email}", auth),
693
+ "personal-bob-example-com",
694
+ )
695
+ self.assertIsNone(
696
+ PermissionsCompiler.expand_namespace_pattern("personal-{!user.email}", self._user_auth(sub="x"))
697
+ )
698
+
699
+
700
+ # ---------------------------------------------------------------------------
701
+ # Unit: LRU cache keying
702
+ # ---------------------------------------------------------------------------
703
+
704
+
705
+ class TestLRUCacheKeying(TestCase):
706
+ def setUp(self):
707
+ PermissionsCompiler._policy_cache.clear()
708
+ PermissionsCompiler._global_cache.clear()
709
+
710
+ def tearDown(self):
711
+ PermissionsCompiler._policy_cache.clear()
712
+ PermissionsCompiler._global_cache.clear()
713
+
714
+ def test_policy_cache_keyed_by_version(self):
715
+ v1 = PermissionsCompiler.compile_policy_set({"policies": []})
716
+ v2 = PermissionsCompiler.compile_policy_set(
717
+ {
718
+ "policies": [
719
+ {
720
+ "effect": "Allow",
721
+ "actors": [{"kind": "User", "claims": {"email": "*"}}],
722
+ "actions": ["config:read"],
723
+ "resources": ["**"],
724
+ }
725
+ ]
726
+ }
727
+ )
728
+
729
+ PermissionsCompiler._policy_cache.put((42, 1), v1)
730
+ PermissionsCompiler._policy_cache.put((42, 2), v2)
731
+
732
+ self.assertEqual(len(PermissionsCompiler._policy_cache.get((42, 1)).buckets), 0)
733
+ self.assertIn("config", PermissionsCompiler._policy_cache.get((42, 2)).buckets)
734
+
735
+ def test_global_cache_keyed_by_revision(self):
736
+ r1 = PermissionsCompiler.compile_global_rules([])
737
+ r2 = PermissionsCompiler.compile_global_rules(
738
+ [
739
+ {
740
+ "namespace": "test",
741
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
742
+ }
743
+ ]
744
+ )
745
+
746
+ PermissionsCompiler._global_cache.put((0, 0), r1)
747
+ PermissionsCompiler._global_cache.put((1, 12345), r2)
748
+
749
+ self.assertEqual(len(PermissionsCompiler._global_cache.get((0, 0)).rules), 0)
750
+ self.assertEqual(len(PermissionsCompiler._global_cache.get((1, 12345)).rules), 1)
751
+
752
+ @override_settings(OCMO_PERMISSIONS_CACHE_SIZE=2)
753
+ def test_lru_eviction(self):
754
+ for i in range(3):
755
+ PermissionsCompiler._policy_cache.put((i, 1), PermissionsCompiler.compile_policy_set({"policies": []}))
756
+ # First entry (0, 1) evicted; last two retained
757
+ self.assertIsNone(PermissionsCompiler._policy_cache.get((0, 1)))
758
+ self.assertIsNotNone(PermissionsCompiler._policy_cache.get((1, 1)))
759
+ self.assertIsNotNone(PermissionsCompiler._policy_cache.get((2, 1)))
760
+
761
+
762
+ # ---------------------------------------------------------------------------
763
+ # Unit: require_permissions decorator
764
+ # ---------------------------------------------------------------------------
765
+
766
+
767
+ class TestRequirePermissionsDecorator(TestCase):
768
+ def test_auth_none_bypasses(self):
769
+ """auth=None (internal call) — decorator passes through."""
770
+
771
+ class FakeManager:
772
+ auth = None
773
+ path = "some/path"
774
+
775
+ @require_permissions("config:read")
776
+ def do_thing(self):
777
+ return "ok"
778
+
779
+ self.assertEqual(FakeManager().do_thing(), "ok")
780
+
781
+ def test_global_admin_action_passes(self):
782
+ class FakeManager:
783
+ auth = AuthManager({"_type": "user", "email": "admin@example.com"})
784
+
785
+ @require_permissions("global:admin")
786
+ def do_admin(self):
787
+ return "ok"
788
+
789
+ self.assertEqual(FakeManager().do_admin(), "ok")
790
+
791
+ def test_global_admin_action_fails(self):
792
+ class FakeManager:
793
+ auth = AuthManager({"_type": "user", "groups": "other"})
794
+
795
+ @require_permissions("global:admin")
796
+ def do_admin(self):
797
+ return "ok"
798
+
799
+ with self.assertRaises(PermissionDenied):
800
+ FakeManager().do_admin()
801
+
802
+ def test_namespace_action_routing(self):
803
+ """namespace:read with resource= routes to check_namespace_object."""
804
+
805
+ class FakeManager:
806
+ auth = AuthManager({"_type": "user", "email": "bob"})
807
+ ns_name = "testns"
808
+
809
+ compiled = PermissionsCompiler.compile_global_rules(
810
+ [
811
+ {
812
+ "namespace": "testns",
813
+ "read": {"actors": [{"kind": "User", "claims": {"email": "bob"}}]},
814
+ }
815
+ ]
816
+ )
817
+
818
+ @require_permissions(PermCheck("namespace:read", resource="ns_name"))
819
+ def method(self):
820
+ return "ok"
821
+
822
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_global_rules", return_value=compiled):
823
+ self.assertEqual(method(FakeManager()), "ok")
824
+
825
+ def test_none_perm_callable_skipped(self):
826
+ """Callable perm returning None means no check for that perm."""
827
+
828
+ class FakeManager:
829
+ auth = AuthManager({"_type": "user", "email": "bob"})
830
+ namespace = None
831
+ path = "some/path"
832
+
833
+ @require_permissions(lambda self: None)
834
+ def do_thing(self):
835
+ return "ok"
836
+
837
+ self.assertEqual(FakeManager().do_thing(), "ok")
838
+
839
+ def test_callable_perm_evaluated_at_call_time(self):
840
+ """Callable perm string is derived from self at call time."""
841
+ calls = []
842
+
843
+ class FakeManager:
844
+ auth = AuthManager({"_type": "user", "email": "bob"})
845
+ namespace = _ns_stub()
846
+ path = "some/path"
847
+
848
+ @require_permissions(lambda self: calls.append("checked") or None)
849
+ def do_thing(self):
850
+ return "ok"
851
+
852
+ FakeManager().do_thing()
853
+ self.assertEqual(calls, ["checked"])
854
+
855
+ def test_callable_perm_list_of_pairs(self):
856
+ """Callable returning list of (action, path) pairs checks each entry."""
857
+ ps = PermissionsCompiler.compile_policy_set(
858
+ {
859
+ "policies": [
860
+ {
861
+ "effect": "Allow",
862
+ "actors": [{"kind": "User", "claims": {"email": "bob"}}],
863
+ "actions": ["config:delete", "secret:delete"],
864
+ "resources": ["allowed/**"],
865
+ }
866
+ ]
867
+ }
868
+ )
869
+
870
+ class FakeManager:
871
+ auth = AuthManager({"_type": "user", "email": "bob"})
872
+ namespace = _ns_stub()
873
+ path = "ignored"
874
+
875
+ @require_permissions(
876
+ lambda self: [
877
+ ("config:delete", "allowed/cfg"),
878
+ ("secret:delete", "denied/sec"),
879
+ ]
880
+ )
881
+ def delete_folder(self):
882
+ return "ok"
883
+
884
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
885
+ with self.assertRaises(PermissionDenied):
886
+ FakeManager().delete_folder()
887
+
888
+
889
+ # ---------------------------------------------------------------------------
890
+ # Integration: namespace endpoints with permission checks (via managers)
891
+ # ---------------------------------------------------------------------------
892
+
893
+
894
+ class TestNamespacePermissionsIntegration(TestCase):
895
+ """
896
+ Tests the permission layer through manager calls with explicit AuthManager.
897
+ HTTP tests use the test OAuth2 bearer (global admin by default).
898
+ """
899
+
900
+ def setUp(self):
901
+ PermissionsCompiler._policy_cache.clear()
902
+ PermissionsCompiler._global_cache.clear()
903
+ self.client = Client()
904
+ self.ns = Namespace.objects.create(name="perm-test", description="test")
905
+
906
+ def tearDown(self):
907
+ PermissionsCompiler._policy_cache.clear()
908
+ PermissionsCompiler._global_cache.clear()
909
+
910
+ def test_api_list_namespaces_succeeds_for_global_admin(self):
911
+ resp = self.client.get("/api/v1/ns/")
912
+ self.assertEqual(resp.status_code, 200)
913
+
914
+ def test_api_get_namespace_succeeds_for_global_admin(self):
915
+ resp = self.client.get(f"/api/v1/ns/{self.ns.name}")
916
+ self.assertEqual(resp.status_code, 200)
917
+
918
+ def test_global_admin_manager_list_returns_all(self):
919
+ from core.managers.namespace import NamespaceManager
920
+ from ocmoapi.testing_auth import default_test_user_claims
921
+
922
+ auth = AuthManager(default_test_user_claims())
923
+ result = NamespaceManager(None, auth=auth).list()
924
+ self.assertIn("perm-test", [ns.name for ns in result])
925
+
926
+ def test_user_with_read_can_get_namespace(self):
927
+ GlobalPermissionRule.objects.create(
928
+ position=1.0,
929
+ rule={
930
+ "namespace": "perm-*",
931
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
932
+ },
933
+ )
934
+ from core.managers.namespace import NamespaceManager
935
+
936
+ auth = AuthManager({"_type": "user", "email": "bob@example.com"})
937
+ ns = NamespaceManager("perm-test", auth=auth).get_or_raise()
938
+ self.assertEqual(ns.name, "perm-test")
939
+
940
+ def test_user_without_read_is_denied(self):
941
+ GlobalPermissionRule.objects.create(
942
+ position=1.0,
943
+ rule={
944
+ "namespace": "perm-*",
945
+ "read": {"actors": [{"kind": "User", "claims": {"groups": "special"}}]},
946
+ },
947
+ )
948
+ from core.managers.namespace import NamespaceManager
949
+
950
+ auth = AuthManager({"_type": "user", "email": "bob@example.com", "groups": "other"})
951
+ with self.assertRaises(NotFound):
952
+ NamespaceManager("perm-test", auth=auth).get_or_raise()
953
+
954
+ def test_list_filters_readable_namespaces(self):
955
+ GlobalPermissionRule.objects.create(
956
+ position=1.0,
957
+ rule={
958
+ "namespace": "perm-*",
959
+ "read": {"actors": [{"kind": "User", "claims": {"email": "bob@example.com"}}]},
960
+ },
961
+ )
962
+ from core.managers.namespace import NamespaceManager
963
+
964
+ bob = AuthManager({"_type": "user", "email": "bob@example.com"})
965
+ alice = AuthManager({"_type": "user", "email": "alice@example.com"})
966
+
967
+ bob_result = NamespaceManager(None, auth=bob).list()
968
+ alice_result = NamespaceManager(None, auth=alice).list()
969
+
970
+ self.assertIn("perm-test", [ns.name for ns in bob_result])
971
+ self.assertEqual(len(alice_result), 0)
972
+
973
+ def test_write_implies_read_for_list(self):
974
+ GlobalPermissionRule.objects.create(
975
+ position=1.0,
976
+ rule={
977
+ "namespace": "perm-*",
978
+ "write": {"actors": [{"kind": "User", "claims": {"email": "admin@example.com"}}]},
979
+ },
980
+ )
981
+ from core.managers.namespace import NamespaceManager
982
+
983
+ auth = AuthManager({"_type": "user", "email": "admin@example.com"})
984
+ result = NamespaceManager(None, auth=auth).list()
985
+ self.assertIn("perm-test", [ns.name for ns in result])
986
+
987
+ def test_non_admin_cannot_manage_global_permissions(self):
988
+ from core.managers.global_permissions import GlobalPermissionsManager
989
+
990
+ auth = AuthManager({"_type": "user", "email": "regular@example.com"})
991
+ with self.assertRaises(PermissionDenied):
992
+ GlobalPermissionsManager(auth=auth).list()
993
+
994
+ def test_global_admin_can_manage_global_permissions(self):
995
+ from core.managers.global_permissions import GlobalPermissionsManager
996
+
997
+ auth = AuthManager({"_type": "user", "email": "admin@example.com"})
998
+ result = GlobalPermissionsManager(auth=auth).list()
999
+ self.assertIn("rules", result)
1000
+
1001
+ def test_namespace_audit_permission(self):
1002
+ PermissionsCompiler._global_cache.clear()
1003
+ GlobalPermissionRule.objects.create(
1004
+ position=1.0,
1005
+ rule={
1006
+ "namespace": "perm-*",
1007
+ "audit": {"actors": [{"kind": "User", "claims": {"email": "auditor@example.com"}}]},
1008
+ },
1009
+ )
1010
+ auth = AuthManager({"_type": "user", "email": "auditor@example.com"})
1011
+ self.assertTrue(auth.permissions().check_namespace_object("perm-test", "audit"))
1012
+ auth2 = AuthManager({"_type": "user", "email": "other@example.com"})
1013
+ self.assertFalse(auth2.permissions().check_namespace_object("perm-test", "audit"))
1014
+ PermissionsCompiler._global_cache.clear()
1015
+
1016
+
1017
+ # ---------------------------------------------------------------------------
1018
+ # Integration: tree ABAC check via PermissionsManager
1019
+ # ---------------------------------------------------------------------------
1020
+
1021
+
1022
+ class TestTreePermissionsIntegration(TestCase):
1023
+ def setUp(self):
1024
+ PermissionsCompiler._policy_cache.clear()
1025
+ PermissionsCompiler._global_cache.clear()
1026
+ self.client = Client()
1027
+ self.ns = create_test_namespace("tree-perm-test")
1028
+ self.client.post(
1029
+ f"/api/v1/ns/{self.ns.name}/~config/~create/app/cfg",
1030
+ data=b"key: value\n",
1031
+ content_type="application/yaml",
1032
+ )
1033
+
1034
+ def tearDown(self):
1035
+ PermissionsCompiler._policy_cache.clear()
1036
+ PermissionsCompiler._global_cache.clear()
1037
+
1038
+ def test_get_item_dev_admin_succeeds(self):
1039
+ """OIDC disabled → dev global admin → tree access succeeds."""
1040
+ resp = self.client.get(f"/api/v1/ns/{self.ns.name}/~get/app/cfg")
1041
+ self.assertEqual(resp.status_code, 200)
1042
+
1043
+ def test_get_item_missing_path_returns_404(self):
1044
+ resp = self.client.get(
1045
+ f"/api/v1/ns/{self.ns.name}/~get/tagtest/cfg/asdasd",
1046
+ )
1047
+ self.assertEqual(resp.status_code, 404, resp.content)
1048
+ self.assertIn("wasn't found by path", resp.json()["error"])
1049
+
1050
+ def test_tree_check_with_matching_policy_allowed(self):
1051
+ ps = PermissionsCompiler.compile_policy_set(
1052
+ {
1053
+ "policies": [
1054
+ {
1055
+ "effect": "Allow",
1056
+ "actors": [{"kind": "User", "claims": {"email": "*"}}],
1057
+ "actions": ["config:read"],
1058
+ "resources": ["**"],
1059
+ }
1060
+ ]
1061
+ }
1062
+ )
1063
+ auth = AuthManager({"_type": "user", "email": "bob@example.com"})
1064
+ pm = PermissionsManager(auth, self.ns)
1065
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
1066
+ self.assertTrue(pm.check_tree("config:read", "app/cfg"))
1067
+
1068
+ def test_tree_check_denied_by_empty_policy(self):
1069
+ ps = PermissionsCompiler.compile_policy_set({"policies": []})
1070
+ auth = AuthManager({"_type": "user", "email": "bob@example.com"})
1071
+ pm = PermissionsManager(auth, self.ns)
1072
+ with patch("core.utils.permissions_compiler.PermissionsCompiler.load_policy_set", return_value=ps):
1073
+ self.assertFalse(pm.check_tree("config:read", "app/cfg"))
1074
+
1075
+ def test_tree_check_denies_when_namespace_is_none(self):
1076
+ """check_tree always returns False when namespace is None (prevents existence leak)."""
1077
+ auth = AuthManager({"_type": "user", "email": "bob@example.com"})
1078
+ pm = PermissionsManager(auth, None)
1079
+ self.assertFalse(pm.check_tree("config:read", "app/cfg"))
1080
+
1081
+ def test_get_item_nonexistent_namespace_returns_403_not_404(self):
1082
+ """GET on a nonexistent namespace must return 403 (not 404) to hide existence."""
1083
+ resp = self.client.get("/api/v1/ns/does-not-exist/~get/app/cfg")
1084
+ # With OIDC disabled, dev admin (global admin) is used → passes check_tree
1085
+ # and falls through to 404 on the tree item. Regular users would get 403.
1086
+ # The important thing is that non-admins cannot tell existence apart.
1087
+ self.assertIn(resp.status_code, (403, 404))
1088
+
1089
+ def test_special_path_denied_without_global_write(self):
1090
+ """_permissions path denies user with only global read access."""
1091
+ GlobalPermissionRule.objects.create(
1092
+ position=1.0,
1093
+ rule={
1094
+ "namespace": "tree-perm-*",
1095
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
1096
+ },
1097
+ )
1098
+ auth = AuthManager({"_type": "user", "email": "bob@example.com"})
1099
+ pm = PermissionsManager(auth, self.ns)
1100
+ # _permissions requires global write, not just read
1101
+ self.assertFalse(pm.check_tree("config:read", "_permissions"))
1102
+
1103
+ def test_special_path_allowed_with_global_write(self):
1104
+ """_permissions path allows user with global write access."""
1105
+ GlobalPermissionRule.objects.create(
1106
+ position=1.0,
1107
+ rule={
1108
+ "namespace": "tree-perm-*",
1109
+ "write": {"actors": [{"kind": "User", "claims": {"email": "admin@example.com"}}]},
1110
+ },
1111
+ )
1112
+ auth = AuthManager({"_type": "user", "email": "admin@example.com"})
1113
+ pm = PermissionsManager(auth, self.ns)
1114
+ self.assertTrue(pm.check_tree("config:read", "_permissions"))
1115
+
1116
+ def test_load_policy_set_from_empty_ns(self):
1117
+ """Namespace without _permissions is broken."""
1118
+ PermissionsCompiler._policy_cache.clear()
1119
+ from core.exceptions import BrokenNamespace
1120
+
1121
+ bare = Namespace.objects.create(name="bare-ns", description="")
1122
+ with self.assertRaises(BrokenNamespace):
1123
+ PermissionsCompiler.load_policy_set(bare)
1124
+
1125
+ def test_load_global_rules_from_db(self):
1126
+ """load_global_rules compiles DB rules correctly."""
1127
+ PermissionsCompiler._global_cache.clear()
1128
+ GlobalPermissionRule.objects.create(
1129
+ position=1.0,
1130
+ rule={
1131
+ "namespace": "tree-perm-*",
1132
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
1133
+ },
1134
+ )
1135
+ compiled = PermissionsCompiler.load_global_rules()
1136
+ self.assertIsInstance(compiled, CompiledGlobalRules)
1137
+ self.assertEqual(len(compiled.rules), 1)
1138
+
1139
+ def test_load_global_rules_cache_hit(self):
1140
+ """Second call to load_global_rules returns the exact same compiled object."""
1141
+ PermissionsCompiler._global_cache.clear()
1142
+ GlobalPermissionRule.objects.create(
1143
+ position=1.0,
1144
+ rule={"namespace": "ns", "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]}},
1145
+ )
1146
+ r1 = PermissionsCompiler.load_global_rules()
1147
+ r2 = PermissionsCompiler.load_global_rules()
1148
+ self.assertIs(r1, r2) # same object from LRU cache