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.
- LICENSE +201 -0
- NOTICE +16 -0
- core/__init__.py +0 -0
- core/api/__init__.py +201 -0
- core/api/_common.py +37 -0
- core/api/audit.py +244 -0
- core/api/auth.py +50 -0
- core/api/config.py +96 -0
- core/api/errors.py +65 -0
- core/api/global_permissions.py +107 -0
- core/api/lock.py +99 -0
- core/api/namespace.py +86 -0
- core/api/propagation.py +29 -0
- core/api/resolve.py +229 -0
- core/api/resolver.py +96 -0
- core/api/secret.py +53 -0
- core/api/system.py +42 -0
- core/api/template.py +53 -0
- core/api/tree.py +274 -0
- core/apps.py +18 -0
- core/audit_operations.py +0 -0
- core/constants/__init__.py +5 -0
- core/constants/audit_operations.py +35 -0
- core/constants/permission_actions.py +59 -0
- core/constants/permissions.py +41 -0
- core/constants/resolve.py +3 -0
- core/constants/transformers.py +22 -0
- core/constants/webhook_events.py +31 -0
- core/data/builtin_schemas/_git_sync.schema.yaml +24 -0
- core/data/builtin_schemas/_permissions.schema.yaml +211 -0
- core/data/builtin_schemas/_webhooks.schema.yaml +145 -0
- core/decorators/__init__.py +14 -0
- core/decorators/audit.py +105 -0
- core/decorators/binding.py +49 -0
- core/decorators/permissions.py +129 -0
- core/decorators/webhook.py +87 -0
- core/exceptions.py +165 -0
- core/management/__init__.py +0 -0
- core/management/commands/__init__.py +0 -0
- core/management/commands/dump_openapi.py +40 -0
- core/managers/artifacts.py +346 -0
- core/managers/audit/__init__.py +1172 -0
- core/managers/audit/timeline.py +365 -0
- core/managers/auth.py +276 -0
- core/managers/cast.py +506 -0
- core/managers/config_validation.py +216 -0
- core/managers/crypto.py +78 -0
- core/managers/global_permissions.py +183 -0
- core/managers/health.py +56 -0
- core/managers/lock.py +229 -0
- core/managers/namespace.py +132 -0
- core/managers/permissions.py +168 -0
- core/managers/propagation.py +244 -0
- core/managers/resolution.py +938 -0
- core/managers/resolve_cache.py +266 -0
- core/managers/resolve_parameters.py +503 -0
- core/managers/resolver.py +56 -0
- core/managers/resolver_tokens.py +114 -0
- core/managers/resolving.py +673 -0
- core/managers/secret.py +147 -0
- core/managers/system.py +44 -0
- core/managers/tree/__init__.py +85 -0
- core/managers/tree/_common.py +85 -0
- core/managers/tree/base.py +203 -0
- core/managers/tree/config_ops.py +167 -0
- core/managers/tree/constants.py +11 -0
- core/managers/tree/create.py +75 -0
- core/managers/tree/diff.py +270 -0
- core/managers/tree/mutate.py +433 -0
- core/managers/tree/paths.py +49 -0
- core/managers/tree/propagation.py +200 -0
- core/managers/tree/read.py +263 -0
- core/managers/tree/resolver_ops.py +21 -0
- core/managers/tree/tags.py +167 -0
- core/managers/tree/update.py +137 -0
- core/managers/tree/versions.py +87 -0
- core/managers/tree_capabilities.py +189 -0
- core/managers/webhook/__init__.py +3 -0
- core/managers/webhook/cache.py +68 -0
- core/managers/webhook/main.py +357 -0
- core/middleware.py +32 -0
- core/migrations/0001_initial.py +157 -0
- core/migrations/0002_folder.py +33 -0
- core/migrations/0003_alter_namespace_name.py +17 -0
- core/migrations/0004_alter_namespace_name_and_more.py +25 -0
- core/migrations/0005_configversion_templateversion.py +85 -0
- core/migrations/0006_alter_templateversion_options_and_more.py +47 -0
- core/migrations/0007_configversion_deleted_at_templateversion_deleted_at.py +22 -0
- core/migrations/0008_namespace_tags_dek_secret_global_permissions.py +139 -0
- core/migrations/0009_tree_lock.py +58 -0
- core/migrations/0010_resolver_token_encryption.py +75 -0
- core/migrations/0011_audit_event.py +134 -0
- core/migrations/0012_audit_event_namespace_name.py +24 -0
- core/migrations/0013_audit_event_object_id.py +28 -0
- core/migrations/0014_sync_permissions_webhooks_builtin_schemas.py +26 -0
- core/migrations/0015_audit_event_operation_subresource.py +27 -0
- core/migrations/0016_sync_permissions_schema_audit_actions.py +26 -0
- core/migrations/0017_sync_permissions_schema_valid_actions.py +26 -0
- core/migrations/__init__.py +0 -0
- core/models/__init__.py +9 -0
- core/models/audit.py +97 -0
- core/models/config.py +132 -0
- core/models/folder.py +12 -0
- core/models/global_permissions.py +27 -0
- core/models/lock.py +35 -0
- core/models/namespace.py +45 -0
- core/models/resolver.py +25 -0
- core/models/secret.py +58 -0
- core/models/tree.py +29 -0
- core/notice.py +18 -0
- core/operation_ids.py +153 -0
- core/schemas/__init__.py +2 -0
- core/schemas/audit.py +75 -0
- core/schemas/cast_json_schema.py +57 -0
- core/schemas/cast_options.py +508 -0
- core/schemas/generic.py +233 -0
- core/schemas/metadata_json_schema.py +21 -0
- core/schemas/propagation.py +88 -0
- core/schemas/requests.py +773 -0
- core/schemas/resolver_json_schema.py +65 -0
- core/schemas/responses.py +690 -0
- core/schemas/webhooks.py +40 -0
- core/shortcuts.py +415 -0
- core/tests/__init__.py +0 -0
- core/tests/auth_helpers.py +23 -0
- core/tests/namespace_helpers.py +29 -0
- core/tests/test_api_cli.py +43 -0
- core/tests/test_api_errors.py +151 -0
- core/tests/test_artifacts.py +123 -0
- core/tests/test_audit.py +973 -0
- core/tests/test_audit_event_id.py +90 -0
- core/tests/test_audit_operations.py +222 -0
- core/tests/test_builtin_config_schemas.py +174 -0
- core/tests/test_can_i.py +510 -0
- core/tests/test_cast_format_options.py +57 -0
- core/tests/test_cast_options_schema.py +54 -0
- core/tests/test_cast_yaml.py +26 -0
- core/tests/test_config_schema_api.py +173 -0
- core/tests/test_config_validation.py +378 -0
- core/tests/test_document_parser.py +122 -0
- core/tests/test_dump_openapi.py +46 -0
- core/tests/test_extend_key.py +267 -0
- core/tests/test_folder_get_item.py +23 -0
- core/tests/test_folder_resolve_missing_tags.py +73 -0
- core/tests/test_get_item_version.py +31 -0
- core/tests/test_global_permission_create.py +171 -0
- core/tests/test_list_pagination.py +262 -0
- core/tests/test_locks.py +209 -0
- core/tests/test_multiline_transformer.py +132 -0
- core/tests/test_namespace_create.py +11 -0
- core/tests/test_ocmo_metadata.py +127 -0
- core/tests/test_oidc_jwt_audiences.py +64 -0
- core/tests/test_omit_transformer.py +78 -0
- core/tests/test_operation_ids.py +47 -0
- core/tests/test_permissions.py +1148 -0
- core/tests/test_propagation.py +441 -0
- core/tests/test_public_base_url.py +20 -0
- core/tests/test_resolve_draft.py +176 -0
- core/tests/test_resolve_parameters_version.py +181 -0
- core/tests/test_resolve_permissions.py +492 -0
- core/tests/test_resolve_resolver_response.py +107 -0
- core/tests/test_resolver_auth.py +33 -0
- core/tests/test_resolver_folder_filters.py +95 -0
- core/tests/test_resolver_get_item.py +23 -0
- core/tests/test_resolver_navigate_search_scope.py +98 -0
- core/tests/test_resolver_schema_api.py +91 -0
- core/tests/test_security_hardening.py +324 -0
- core/tests/test_selectors.py +65 -0
- core/tests/test_special_namespace_paths.py +409 -0
- core/tests/test_stable_promotion.py +124 -0
- core/tests/test_system.py +140 -0
- core/tests/test_tree_manager_for_item.py +192 -0
- core/tests/test_two_layer_cache.py +334 -0
- core/tests/test_url_trailing_slashes.py +53 -0
- core/tests/test_validation_error_responses.py +43 -0
- core/tests/test_webhooks.py +985 -0
- core/utils/__init__.py +23 -0
- core/utils/deep_merge.py +91 -0
- core/utils/namespace_special_configs.py +105 -0
- core/utils/permissions_compiler.py +627 -0
- core/utils/permissions_schema_document.py +52 -0
- core/validation_errors.py +105 -0
- core/version.py +27 -0
- core/views.py +1 -0
- ocmo_api-0.8.19.dist-info/METADATA +252 -0
- ocmo_api-0.8.19.dist-info/RECORD +203 -0
- ocmo_api-0.8.19.dist-info/WHEEL +4 -0
- ocmo_api-0.8.19.dist-info/entry_points.txt +2 -0
- ocmo_api-0.8.19.dist-info/licenses/LICENSE +201 -0
- ocmo_api-0.8.19.dist-info/licenses/NOTICE +16 -0
- ocmoapi/__init__.py +1 -0
- ocmoapi/__main__.py +6 -0
- ocmoapi/asgi.py +16 -0
- ocmoapi/auth.py +163 -0
- ocmoapi/cli.py +85 -0
- ocmoapi/oauth2_provider.py +23 -0
- ocmoapi/parser.py +120 -0
- ocmoapi/settings.py +275 -0
- ocmoapi/templates/ninja/oauth2-redirect.html +81 -0
- ocmoapi/templates/ninja/swagger.html +55 -0
- ocmoapi/testing_auth.py +64 -0
- ocmoapi/urls.py +40 -0
- 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)
|