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,181 @@
1
+ import yaml
2
+ from django.test import Client, TestCase
3
+
4
+ from core.managers.resolving import ResolvePipelineManager
5
+ from core.managers.tree import TreeManager
6
+ from core.schemas.requests import TagPayload
7
+ from core.tests.namespace_helpers import create_test_namespace
8
+ from core.utils.permissions_compiler import PermissionsCompiler
9
+
10
+ _VERSION_PARAMS_YAML = """\
11
+ _ocmo:
12
+ parameters:
13
+ version_tag:
14
+ type: projected
15
+ value: .Version.tag
16
+ description: Version reference used to resolve this config
17
+ version_number:
18
+ type: projected
19
+ value: .Version.number
20
+ description: Resolved integer version number
21
+ build: "{!version_tag}-v{!version_number}"
22
+ """
23
+
24
+
25
+ class ResolveParametersVersionTests(TestCase):
26
+ def setUp(self):
27
+ PermissionsCompiler._policy_cache.clear()
28
+ self.client = Client()
29
+ self.ns = create_test_namespace("versionparamtest", description="test")
30
+ self._create_config("app/cfg", "key: one\n")
31
+ self._update_config("app/cfg", _VERSION_PARAMS_YAML)
32
+ TreeManager(self.ns, "app/cfg", auth=None).set_item_tag(TagPayload(tag="release", version=2))
33
+ TreeManager(self.ns, "app/cfg", auth=None).promote_stable_tag(2)
34
+
35
+ def _create_config(self, path: str, body: str):
36
+ TreeManager(self.ns, path, auth=None).create_item(body, "config")
37
+
38
+ def _update_config(self, path: str, body: str):
39
+ TreeManager(self.ns, path, auth=None).update_item(body)
40
+
41
+ def _resolve_parameters(self, path: str, **params):
42
+ query = "&".join(f"{k}={v}" for k, v in params.items())
43
+ url = f"/api/v1/ns/{self.ns.name}/~resolve-parameters/{path}"
44
+ if query:
45
+ url = f"{url}?{query}"
46
+ return self.client.get(url)
47
+
48
+ def test_projected_version_tag_from_custom_tag(self):
49
+ response = self._resolve_parameters("app/cfg", version="release")
50
+ self.assertEqual(response.status_code, 200, response.content)
51
+
52
+ params = response.json()["parameters"]
53
+ self.assertEqual(params["version_tag"]["raw_value"], "release")
54
+ self.assertEqual(params["version_tag"]["effective_value"], "release")
55
+ self.assertEqual(response.json()["version"], 2)
56
+ self.assertEqual(response.json()["requested_version"], "release")
57
+
58
+ def test_projected_version_number_from_latest(self):
59
+ response = self._resolve_parameters("app/cfg", version="latest")
60
+ self.assertEqual(response.status_code, 200, response.content)
61
+
62
+ params = response.json()["parameters"]
63
+ self.assertEqual(params["version_number"]["raw_value"], 2)
64
+ self.assertEqual(params["version_number"]["effective_value"], 2)
65
+
66
+ def test_projected_version_numeric_ref(self):
67
+ response = self._resolve_parameters("app/cfg", version="2")
68
+ self.assertEqual(response.status_code, 200, response.content)
69
+
70
+ params = response.json()["parameters"]
71
+ self.assertEqual(params["version_tag"]["raw_value"], "2")
72
+ self.assertEqual(params["version_tag"]["effective_value"], "2")
73
+ self.assertEqual(params["version_number"]["raw_value"], 2)
74
+ self.assertEqual(params["version_number"]["effective_value"], 2)
75
+
76
+ def test_resolve_substitutes_version_parameters(self):
77
+ mgr = ResolvePipelineManager(self.ns, "app/cfg", "stable")
78
+ outputs = mgr.resolve()
79
+ self.assertEqual(len(outputs), 1)
80
+
81
+ data = yaml.safe_load(outputs[0].data_text)
82
+ self.assertEqual(data["build"], "stable-v2")
83
+
84
+ def test_extend_chain_uses_per_config_version_tag(self):
85
+ self._create_config(
86
+ "shared/base",
87
+ """\
88
+ _ocmo:
89
+ parameters:
90
+ version_tag:
91
+ type: projected
92
+ value: .Version.tag
93
+ description: Version tag from config metadata
94
+ tag: "{!version_tag}"
95
+ """,
96
+ )
97
+ TreeManager(self.ns, "shared/base", auth=None).promote_stable_tag(1)
98
+
99
+ self._create_config(
100
+ "app/root",
101
+ """\
102
+ _ocmo:
103
+ parameters:
104
+ version_tag:
105
+ type: projected
106
+ value: .Version.tag
107
+ description: Version tag from config metadata
108
+ extend:
109
+ configs:
110
+ - ../shared/base@stable
111
+ mode: accumulate
112
+ root_tag: "{!version_tag}"
113
+ """,
114
+ )
115
+
116
+ root_mgr = ResolvePipelineManager(self.ns, "app/root", "latest")
117
+ outputs = root_mgr.resolve()
118
+ self.assertEqual(len(outputs), 1)
119
+
120
+ data = yaml.safe_load(outputs[0].data_text)
121
+ self.assertEqual(data["root_tag"], "latest")
122
+ self.assertEqual(data["tag"], "stable")
123
+
124
+ base_debug = self._resolve_parameters("shared/base", version="stable")
125
+ self.assertEqual(
126
+ base_debug.json()["parameters"]["version_tag"]["effective_value"],
127
+ "stable",
128
+ )
129
+
130
+ def test_null_transformer_emits_explicit_yaml_null(self):
131
+ self._create_config(
132
+ "app/nullable",
133
+ """\
134
+ _ocmo:
135
+ parameters:
136
+ optional:
137
+ type: dynamic
138
+ value: ""
139
+ description: Optional value coerced to null
140
+ transformers:
141
+ - "null"
142
+ optional: '{!optional}'
143
+ """,
144
+ )
145
+
146
+ mgr = ResolvePipelineManager(self.ns, "app/nullable", "latest")
147
+ outputs = mgr.resolve()
148
+ self.assertEqual(len(outputs), 1)
149
+ self.assertIn("optional: null", outputs[0].data_text)
150
+
151
+ data = yaml.safe_load(outputs[0].data_text)
152
+ self.assertIsNone(data["optional"])
153
+
154
+ def test_null_transformer_preserves_nonempty_string(self):
155
+ self._create_config(
156
+ "app/nullable-set",
157
+ """\
158
+ _ocmo:
159
+ parameters:
160
+ optional:
161
+ type: dynamic
162
+ value: ""
163
+ description: Optional value coerced to null when empty
164
+ transformers:
165
+ - "null"
166
+ optional: '{!optional}'
167
+ """,
168
+ )
169
+
170
+ mgr = ResolvePipelineManager(
171
+ self.ns,
172
+ "app/nullable-set",
173
+ "latest",
174
+ dynamic_params={"optional": "hello"},
175
+ )
176
+ outputs = mgr.resolve()
177
+ self.assertEqual(len(outputs), 1)
178
+ self.assertIn("optional: hello", outputs[0].data_text)
179
+
180
+ data = yaml.safe_load(outputs[0].data_text)
181
+ self.assertEqual(data["optional"], "hello")
@@ -0,0 +1,492 @@
1
+ """Resolve endpoint permission enforcement tests."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from django.core.cache import cache, caches
6
+ from django.test import Client, TestCase, override_settings
7
+
8
+ from core.managers.auth import AuthManager
9
+ from core.managers.resolver_tokens import ResolverTokenManager
10
+ from core.managers.tree import TreeManager
11
+ from core.models import ConfigVersion, GlobalPermissionRule, Namespace
12
+ from core.utils.permissions_compiler import PermissionsCompiler
13
+
14
+ _TEST_MASTER_KEY = "ZDPuvW6Hx/1UxDK7K/CydLouVKtJl24nbHyb2EkvTzs="
15
+
16
+ _POLICIES = """\
17
+ policies:
18
+ - effect: Allow
19
+ actors:
20
+ - kind: User
21
+ claims:
22
+ email: "reader@example.com"
23
+ actions:
24
+ - config:read
25
+ resources:
26
+ - "**"
27
+ - effect: Allow
28
+ actors:
29
+ - kind: User
30
+ claims:
31
+ email: "resolver-user@example.com"
32
+ actions:
33
+ - config:resolve
34
+ resources:
35
+ - app/**
36
+ - effect: Allow
37
+ actors:
38
+ - kind: Resolver
39
+ path: app/svc
40
+ actions:
41
+ - config:resolve
42
+ - secret:resolve
43
+ resources:
44
+ - shared/**
45
+ - other/**
46
+ - effect: Allow
47
+ actors:
48
+ - kind: User
49
+ claims:
50
+ email: "writer@example.com"
51
+ actions:
52
+ - "*:*"
53
+ resources:
54
+ - "**"
55
+ """
56
+
57
+ _BASE_YAML = "key: value\n"
58
+
59
+ _EXTEND_SHARED_YAML = """\
60
+ _ocmo:
61
+ extend:
62
+ mode: accumulate
63
+ configs:
64
+ - ../../shared/base@latest
65
+ key: child
66
+ """
67
+
68
+ _RENDER_SHARED_YAML = """\
69
+ _ocmo:
70
+ render:
71
+ mode: distribute
72
+ templates:
73
+ - ../../shared/tmpl@latest
74
+ key: child
75
+ """
76
+
77
+ _SECRET_PARAM_YAML = """\
78
+ _ocmo:
79
+ parameters:
80
+ cred:
81
+ type: secret
82
+ value: ../../shared/secret@latest
83
+ description: Shared secret reference for resolve tests
84
+ key: "{!cred}"
85
+ """
86
+
87
+ _SECRET_PARAM_B64_YAML = """\
88
+ _ocmo:
89
+ parameters:
90
+ cred:
91
+ type: secret
92
+ value: ../../shared/secret@latest
93
+ description: Shared secret reference for resolve tests
94
+ transformers:
95
+ - b64_encode
96
+ key: "{!cred}"
97
+ """
98
+
99
+
100
+ from core.utils.namespace_special_configs import init_namespace_special_configs
101
+
102
+
103
+ def _init_special_configs(ns: Namespace) -> None:
104
+ init_namespace_special_configs(ns)
105
+
106
+
107
+ def _grant_test_users_global_access() -> None:
108
+ GlobalPermissionRule.objects.create(
109
+ position=1.0,
110
+ rule={
111
+ "namespace": "resolve-perm-*",
112
+ "read": {
113
+ "actors": [
114
+ {"kind": "User", "claims": {"email": "reader@example.com"}},
115
+ {"kind": "User", "claims": {"email": "resolver-user@example.com"}},
116
+ {"kind": "User", "claims": {"email": "writer@example.com"}},
117
+ ]
118
+ },
119
+ "write": {
120
+ "actors": [
121
+ {"kind": "User", "claims": {"email": "writer@example.com"}},
122
+ ]
123
+ },
124
+ },
125
+ )
126
+
127
+
128
+ @override_settings(OCMO_MASTER_KEY=_TEST_MASTER_KEY)
129
+ class ResolvePermissionsTestCase(TestCase):
130
+ def setUp(self):
131
+ cache.clear()
132
+ caches["resolve"].clear()
133
+ PermissionsCompiler._policy_cache.clear()
134
+ PermissionsCompiler._global_cache.clear()
135
+ self.client = Client()
136
+ self.ns = Namespace.objects.create(name="resolve-perm-ns", description="test")
137
+ _init_special_configs(self.ns)
138
+ _grant_test_users_global_access()
139
+ TreeManager(self.ns, "_permissions", auth=None).update_item(_POLICIES)
140
+
141
+ TreeManager(self.ns, "app/cfg", auth=None).create_item(_BASE_YAML, "config")
142
+ TreeManager(self.ns, "other/cfg", auth=None).create_item(_BASE_YAML, "config")
143
+ TreeManager(self.ns, "shared/base", auth=None).create_item("base: true\n", "config")
144
+ TreeManager(self.ns, "shared/tmpl", auth=None).create_item("x: {{ key }}\n", "template")
145
+ with self._patch_client_auth(self._user_auth("writer@example.com")):
146
+ secret_resp = self.client.post(
147
+ f"/api/v1/ns/{self.ns.name}/~secret/~create/shared/secret",
148
+ data="token: abc\n",
149
+ content_type="application/yaml",
150
+ )
151
+ self.assertEqual(secret_resp.status_code, 201, secret_resp.content)
152
+
153
+ self.resolver_path, self.resolver_token = self._create_resolver("app/svc")
154
+
155
+ def tearDown(self):
156
+ cache.clear()
157
+ caches["resolve"].clear()
158
+ PermissionsCompiler._policy_cache.clear()
159
+ PermissionsCompiler._global_cache.clear()
160
+
161
+ def _user_auth(self, email: str) -> AuthManager:
162
+ return AuthManager({"_type": "user", "email": email, "name": email.split("@")[0]})
163
+
164
+ def _resolver_auth(self) -> AuthManager:
165
+ scope = "/".join(self.resolver_path.split("/")[:-1])
166
+ name = self.resolver_path.split("/")[-1]
167
+ return AuthManager(
168
+ {
169
+ "_type": "resolver",
170
+ "namespace": self.ns.id,
171
+ "name": name,
172
+ "access_scope": scope,
173
+ "token_number": 1,
174
+ }
175
+ )
176
+
177
+ def _patch_client_auth(self, auth: AuthManager):
178
+ return patch(
179
+ "core.managers.auth.AuthManager.from_request",
180
+ return_value=auth,
181
+ )
182
+
183
+ def _create_resolver(self, path: str) -> tuple[str, str]:
184
+ TreeManager(self.ns, path, auth=None).create_item("{}", "resolver")
185
+ resolver = TreeManager(self.ns, path, auth=None).get_or_raise(["resolver"])
186
+ return path, (mgr.plaintext if (mgr := ResolverTokenManager.from_resolver(resolver, 1)) else None)
187
+
188
+ def _resolve(self, path: str, **params):
189
+ query = "&".join(f"{k}={v}" for k, v in params.items()) if params else ""
190
+ url = f"/api/v1/ns/{self.ns.name}/~resolve/{path}"
191
+ if query:
192
+ url = f"{url}?{query}"
193
+ return self.client.get(url)
194
+
195
+ def _download_first_item(self, resp, auth: AuthManager | None = None) -> bytes:
196
+ self.assertEqual(resp.status_code, 200, resp.content)
197
+ item_url = resp.json()["items"][0]["url"]
198
+ # URL may be absolute; Client accepts path-only when same host.
199
+ path = item_url.split("/api/v1/", 1)[-1]
200
+ path = "/api/v1/" + path
201
+ if auth is not None:
202
+ with self._patch_client_auth(auth):
203
+ dl = self.client.get(path)
204
+ else:
205
+ dl = self.client.get(path)
206
+ self.assertEqual(dl.status_code, 200, dl.content)
207
+ return dl.content
208
+
209
+ def test_read_only_user_denied_on_resolve(self):
210
+ with self._patch_client_auth(self._user_auth("reader@example.com")):
211
+ resp = self._resolve("app/cfg")
212
+ self.assertEqual(resp.status_code, 403, resp.content)
213
+
214
+ def test_resolve_user_can_resolve_without_read_permissions(self):
215
+ with self._patch_client_auth(self._user_auth("resolver-user@example.com")):
216
+ resp = self._resolve("app/cfg")
217
+ self.assertEqual(resp.status_code, 200, resp.content)
218
+
219
+ def test_resolve_only_user_denied_on_get_without_read(self):
220
+ with self._patch_client_auth(self._user_auth("resolver-user@example.com")):
221
+ resp = self.client.get(f"/api/v1/ns/{self.ns.name}/~get/app/cfg")
222
+ self.assertEqual(resp.status_code, 403, resp.content)
223
+
224
+ def test_resolve_denied_when_extend_base_not_permitted(self):
225
+ ConfigVersion.objects.filter(config__namespace=self.ns, config__path="app/cfg", version=1).update(
226
+ data=_EXTEND_SHARED_YAML
227
+ )
228
+ with self._patch_client_auth(self._user_auth("reader@example.com")):
229
+ resp = self._resolve("app/cfg")
230
+ self.assertEqual(resp.status_code, 403, resp.content)
231
+
232
+ def test_resolve_denied_when_render_template_not_permitted(self):
233
+ ConfigVersion.objects.filter(config__namespace=self.ns, config__path="app/cfg", version=1).update(
234
+ data=_RENDER_SHARED_YAML
235
+ )
236
+ with self._patch_client_auth(self._user_auth("reader@example.com")):
237
+ resp = self._resolve("app/cfg")
238
+ self.assertEqual(resp.status_code, 403, resp.content)
239
+
240
+ def test_resolve_parameters_with_secret_param_resolves_relative_reference(self):
241
+ ConfigVersion.objects.filter(config__namespace=self.ns, config__path="app/cfg", version=1).update(
242
+ data=_SECRET_PARAM_YAML
243
+ )
244
+ with self._patch_client_auth(self._user_auth("writer@example.com")):
245
+ resp = self.client.get(f"/api/v1/ns/{self.ns.name}/~resolve-parameters/app/cfg")
246
+ self.assertEqual(resp.status_code, 200, resp.content)
247
+ cred = resp.json()["parameters"]["cred"]
248
+ self.assertEqual(cred["type"], "secret")
249
+ self.assertEqual(cred["secret_reference"], "../../shared/secret@latest")
250
+ self.assertEqual(cred["effective_value"], "***")
251
+
252
+ def test_resolve_denied_when_secret_param_not_permitted(self):
253
+ ConfigVersion.objects.filter(config__namespace=self.ns, config__path="app/cfg", version=1).update(
254
+ data=_SECRET_PARAM_YAML
255
+ )
256
+ with self._patch_client_auth(self._user_auth("reader@example.com")):
257
+ resp = self._resolve("app/cfg")
258
+ self.assertEqual(resp.status_code, 403, resp.content)
259
+
260
+ def test_resolve_no_creds_bypasses_secret_resolve(self):
261
+ ConfigVersion.objects.filter(config__namespace=self.ns, config__path="app/cfg", version=1).update(
262
+ data=_SECRET_PARAM_YAML
263
+ )
264
+ with self._patch_client_auth(self._user_auth("resolver-user@example.com")):
265
+ self._user_auth("resolver-user@example.com")
266
+ resp = self._resolve("app/cfg", **{"no-creds": "true"})
267
+ self.assertEqual(resp.status_code, 200, resp.content)
268
+ body = self._download_first_item(resp)
269
+ self.assertIn(b"<secret-value-placeholder>", body)
270
+ self.assertNotIn(b"abc", body)
271
+
272
+ def test_resolve_no_creds_skips_secret_transformers(self):
273
+ ConfigVersion.objects.filter(config__namespace=self.ns, config__path="app/cfg", version=1).update(
274
+ data=_SECRET_PARAM_B64_YAML
275
+ )
276
+ with self._patch_client_auth(self._user_auth("resolver-user@example.com")):
277
+ resp = self._resolve("app/cfg", **{"no-creds": "true"})
278
+ body = self._download_first_item(resp)
279
+ self.assertIn(b"<secret-value-placeholder>", body)
280
+ self.assertNotIn(b"YWJj", body) # base64("abc")
281
+
282
+ def test_resolve_no_creds_false_still_requires_secret_resolve(self):
283
+ ConfigVersion.objects.filter(config__namespace=self.ns, config__path="app/cfg", version=1).update(
284
+ data=_SECRET_PARAM_YAML
285
+ )
286
+ with self._patch_client_auth(self._user_auth("resolver-user@example.com")):
287
+ resp = self._resolve("app/cfg")
288
+ self.assertEqual(resp.status_code, 403, resp.content)
289
+
290
+ def test_resolver_in_scope_direct_resolve(self):
291
+ with self._patch_client_auth(self._resolver_auth()):
292
+ resp = self._resolve("cfg")
293
+ self.assertEqual(resp.status_code, 200, resp.content)
294
+
295
+ def test_resolver_can_download_resolved_artifact(self):
296
+ with self._patch_client_auth(self._resolver_auth()):
297
+ resp = self._resolve("cfg")
298
+ self.assertEqual(resp.status_code, 200, resp.content)
299
+ body = self._download_first_item(resp)
300
+ self.assertGreater(len(body), 0)
301
+
302
+ def test_download_ignores_request_auth(self):
303
+ """Signed download URL works without Authorization (token is the credential)."""
304
+ with self._patch_client_auth(self._user_auth("resolver-user@example.com")):
305
+ resp = self._resolve("app/cfg")
306
+ self.assertEqual(resp.status_code, 200, resp.content)
307
+ body = self._download_first_item(resp)
308
+ self.assertGreater(len(body), 0)
309
+
310
+ def test_resolver_scope_root_dot_path(self):
311
+ with self._patch_client_auth(self._resolver_auth()):
312
+ resp = self.client.get(f"/api/v1/ns/{self.ns.name}/~resolve/@")
313
+ self.assertEqual(resp.status_code, 200, resp.content)
314
+
315
+ def test_resolver_direct_resolve_outside_scope_denied_despite_policy(self):
316
+ """Policy grant on other/cfg does not allow direct resolve outside scope."""
317
+ with self._patch_client_auth(self._resolver_auth()):
318
+ resp = self._resolve("../other/cfg")
319
+ self.assertEqual(resp.status_code, 403, resp.content)
320
+
321
+ def test_resolver_extends_out_of_scope_base_with_policy_grant(self):
322
+ TreeManager(self.ns, "app/child", auth=None).create_item(_EXTEND_SHARED_YAML, "config")
323
+ with self._patch_client_auth(self._resolver_auth()):
324
+ resp = self._resolve("child")
325
+ self.assertEqual(resp.status_code, 200, resp.content)
326
+
327
+ def test_resolver_secret_param_out_of_scope_with_policy_grant(self):
328
+ TreeManager(self.ns, "app/secret-cfg", auth=None).create_item(_SECRET_PARAM_YAML, "config")
329
+ with self._patch_client_auth(self._resolver_auth()):
330
+ resp = self._resolve("secret-cfg")
331
+ self.assertEqual(resp.status_code, 200, resp.content)
332
+
333
+ def test_mark_stable_requires_config_write(self):
334
+ with self._patch_client_auth(self._user_auth("resolver-user@example.com")):
335
+ resp = self._resolve("app/cfg", **{"mark-stable": "true"})
336
+ self.assertEqual(resp.status_code, 403, resp.content)
337
+
338
+ def test_resolve_parameters_requires_config_resolve(self):
339
+ with self._patch_client_auth(self._user_auth("reader@example.com")):
340
+ resp = self.client.get(f"/api/v1/ns/{self.ns.name}/~resolve-parameters/app/cfg")
341
+ self.assertEqual(resp.status_code, 403, resp.content)
342
+
343
+ def test_resolve_parameters_works_without_read_permissions(self):
344
+ with self._patch_client_auth(self._user_auth("resolver-user@example.com")):
345
+ resp = self.client.get(f"/api/v1/ns/{self.ns.name}/~resolve-parameters/app/cfg")
346
+ self.assertEqual(resp.status_code, 200, resp.content)
347
+
348
+ def test_cache_hit_still_enforces_config_resolve(self):
349
+ writer = self._user_auth("writer@example.com")
350
+ with self._patch_client_auth(writer):
351
+ first = self._resolve("app/cfg")
352
+ self.assertEqual(first.status_code, 200, first.content)
353
+
354
+ TreeManager(self.ns, "_permissions", auth=None).update_item(
355
+ """\
356
+ policies:
357
+ - effect: Allow
358
+ actors:
359
+ - kind: User
360
+ claims:
361
+ email: "writer@example.com"
362
+ actions:
363
+ - config:read
364
+ resources:
365
+ - "**"
366
+ """
367
+ )
368
+ PermissionsCompiler._policy_cache.clear()
369
+ writer_fresh = self._user_auth("writer@example.com")
370
+ with self._patch_client_auth(writer_fresh):
371
+ second = self._resolve("app/cfg")
372
+ self.assertEqual(second.status_code, 403, second.content)
373
+
374
+ def _warm_resolve_cache(self, path: str, **params):
375
+ writer = self._user_auth("writer@example.com")
376
+ with self._patch_client_auth(writer):
377
+ first = self._resolve(path, **params)
378
+ self.assertEqual(first.status_code, 200, first.content)
379
+ with self._patch_client_auth(writer):
380
+ second = self._resolve(path, **params)
381
+ self.assertEqual(second.status_code, 200, second.content)
382
+
383
+ def _revoke_to_read_only(self):
384
+ TreeManager(self.ns, "_permissions", auth=None).update_item(
385
+ """\
386
+ policies:
387
+ - effect: Allow
388
+ actors:
389
+ - kind: User
390
+ claims:
391
+ email: "writer@example.com"
392
+ actions:
393
+ - config:read
394
+ resources:
395
+ - "**"
396
+ """
397
+ )
398
+ PermissionsCompiler._policy_cache.clear()
399
+
400
+ def _restrict_resolve_to_app_only(self):
401
+ TreeManager(self.ns, "_permissions", auth=None).update_item(
402
+ """\
403
+ policies:
404
+ - effect: Allow
405
+ actors:
406
+ - kind: User
407
+ claims:
408
+ email: "writer@example.com"
409
+ actions:
410
+ - config:resolve
411
+ resources:
412
+ - app/**
413
+ """
414
+ )
415
+ PermissionsCompiler._policy_cache.clear()
416
+
417
+ def test_cache_hit_denies_when_extend_base_permission_revoked(self):
418
+ ConfigVersion.objects.filter(config__namespace=self.ns, config__path="app/cfg", version=1).update(
419
+ data=_EXTEND_SHARED_YAML
420
+ )
421
+ self._warm_resolve_cache("app/cfg")
422
+ self._restrict_resolve_to_app_only()
423
+ with self._patch_client_auth(self._user_auth("writer@example.com")):
424
+ resp = self._resolve("app/cfg")
425
+ self.assertEqual(resp.status_code, 403, resp.content)
426
+
427
+ def test_cache_hit_denies_when_render_template_permission_revoked(self):
428
+ ConfigVersion.objects.filter(config__namespace=self.ns, config__path="app/cfg", version=1).update(
429
+ data=_RENDER_SHARED_YAML
430
+ )
431
+ self._warm_resolve_cache("app/cfg")
432
+ self._restrict_resolve_to_app_only()
433
+ with self._patch_client_auth(self._user_auth("writer@example.com")):
434
+ resp = self._resolve("app/cfg")
435
+ self.assertEqual(resp.status_code, 403, resp.content)
436
+
437
+ def test_cache_hit_denies_when_secret_param_permission_revoked(self):
438
+ ConfigVersion.objects.filter(config__namespace=self.ns, config__path="app/cfg", version=1).update(
439
+ data=_SECRET_PARAM_YAML
440
+ )
441
+ self._warm_resolve_cache("app/cfg")
442
+ TreeManager(self.ns, "_permissions", auth=None).update_item(
443
+ """\
444
+ policies:
445
+ - effect: Allow
446
+ actors:
447
+ - kind: User
448
+ claims:
449
+ email: "writer@example.com"
450
+ actions:
451
+ - config:resolve
452
+ resources:
453
+ - "**"
454
+ """
455
+ )
456
+ PermissionsCompiler._policy_cache.clear()
457
+ with self._patch_client_auth(self._user_auth("writer@example.com")):
458
+ resp = self._resolve("app/cfg")
459
+ self.assertEqual(resp.status_code, 403, resp.content)
460
+
461
+ def test_cache_hit_no_creds_skips_secret_permission_on_hit(self):
462
+ ConfigVersion.objects.filter(config__namespace=self.ns, config__path="app/cfg", version=1).update(
463
+ data=_SECRET_PARAM_YAML
464
+ )
465
+ self._warm_resolve_cache("app/cfg", **{"no-creds": "true"})
466
+ TreeManager(self.ns, "_permissions", auth=None).update_item(
467
+ """\
468
+ policies:
469
+ - effect: Allow
470
+ actors:
471
+ - kind: User
472
+ claims:
473
+ email: "writer@example.com"
474
+ actions:
475
+ - config:resolve
476
+ resources:
477
+ - "**"
478
+ """
479
+ )
480
+ PermissionsCompiler._policy_cache.clear()
481
+ with self._patch_client_auth(self._user_auth("writer@example.com")):
482
+ resp = self._resolve("app/cfg", **{"no-creds": "true"})
483
+ self.assertEqual(resp.status_code, 200, resp.content)
484
+
485
+ def test_cache_hit_denied_for_user_without_nested_resolve(self):
486
+ ConfigVersion.objects.filter(config__namespace=self.ns, config__path="app/cfg", version=1).update(
487
+ data=_EXTEND_SHARED_YAML
488
+ )
489
+ self._warm_resolve_cache("app/cfg")
490
+ with self._patch_client_auth(self._user_auth("resolver-user@example.com")):
491
+ resp = self._resolve("app/cfg")
492
+ self.assertEqual(resp.status_code, 403, resp.content)