pal-found-cli 0.1.2__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.
- pal_found_cli/__init__.py +1 -0
- pal_found_cli/_version.py +24 -0
- pal_found_cli/admin/__init__.py +2 -0
- pal_found_cli/admin/scripts/__init__.py +2 -0
- pal_found_cli/admin/scripts/pal_found_admin_cli.py +600 -0
- pal_found_cli/aip_agents/__init__.py +1 -0
- pal_found_cli/aip_agents/metadata-allow-list.md +20 -0
- pal_found_cli/aip_agents/scripts/__init__.py +1 -0
- pal_found_cli/aip_agents/scripts/pal_found_aip_agents_cli.py +704 -0
- pal_found_cli/audit/__init__.py +1 -0
- pal_found_cli/audit/metadata-allow-list.md +4 -0
- pal_found_cli/audit/scripts/__init__.py +1 -0
- pal_found_cli/audit/scripts/pal_found_audit_cli.py +387 -0
- pal_found_cli/checkpoints/__init__.py +9 -0
- pal_found_cli/checkpoints/metadata-allow-list.md +7 -0
- pal_found_cli/checkpoints/scripts/__init__.py +1 -0
- pal_found_cli/checkpoints/scripts/pal_found_checkpoints_cli.py +527 -0
- pal_found_cli/common/__init__.py +56 -0
- pal_found_cli/common/access_control_guard.py +591 -0
- pal_found_cli/common/async_client_factory.py +161 -0
- pal_found_cli/common/auth_provider.py +82 -0
- pal_found_cli/common/binary_download_handler.py +288 -0
- pal_found_cli/common/config_loader.py +388 -0
- pal_found_cli/common/error_serializer.py +412 -0
- pal_found_cli/common/log_setup.py +241 -0
- pal_found_cli/common/metadata-allow-list.md +169 -0
- pal_found_cli/common/output_formatter.py +267 -0
- pal_found_cli/common/pagination_helper.py +212 -0
- pal_found_cli/common/retry.py +617 -0
- pal_found_cli/common/sdk_error_utils.py +112 -0
- pal_found_cli/common/session_manager.py +461 -0
- pal_found_cli/common/tracing_provider.py +107 -0
- pal_found_cli/connectivity/__init__.py +9 -0
- pal_found_cli/connectivity/metadata-allow-list.md +24 -0
- pal_found_cli/connectivity/scripts/__init__.py +1 -0
- pal_found_cli/connectivity/scripts/pal_found_connectivity_cli.py +720 -0
- pal_found_cli/data_health/__init__.py +9 -0
- pal_found_cli/data_health/metadata-allow-list.md +10 -0
- pal_found_cli/data_health/scripts/__init__.py +1 -0
- pal_found_cli/data_health/scripts/pal_found_data_health_cli.py +458 -0
- pal_found_cli/datasets/__init__.py +1 -0
- pal_found_cli/datasets/scripts/__init__.py +1 -0
- pal_found_cli/datasets/scripts/pal_found_datasets_cli.py +732 -0
- pal_found_cli/filesystem/__init__.py +2 -0
- pal_found_cli/filesystem/scripts/__init__.py +2 -0
- pal_found_cli/filesystem/scripts/pal_found_filesystem_cli.py +659 -0
- pal_found_cli/functions/__init__.py +2 -0
- pal_found_cli/functions/scripts/__init__.py +2 -0
- pal_found_cli/functions/scripts/pal_found_functions_cli.py +389 -0
- pal_found_cli/language_models/__init__.py +1 -0
- pal_found_cli/language_models/metadata-allow-list.md +6 -0
- pal_found_cli/language_models/scripts/__init__.py +1 -0
- pal_found_cli/language_models/scripts/pal_found_language_models_cli.py +342 -0
- pal_found_cli/media_sets/__init__.py +9 -0
- pal_found_cli/media_sets/metadata-allow-list.md +23 -0
- pal_found_cli/media_sets/scripts/__init__.py +1 -0
- pal_found_cli/media_sets/scripts/pal_found_media_sets_cli.py +642 -0
- pal_found_cli/models/__init__.py +5 -0
- pal_found_cli/models/metadata-allow-list.md +27 -0
- pal_found_cli/models/scripts/__init__.py +1 -0
- pal_found_cli/models/scripts/pal_found_models_cli.py +714 -0
- pal_found_cli/ontologies/__init__.py +1 -0
- pal_found_cli/ontologies/scripts/__init__.py +1 -0
- pal_found_cli/ontologies/scripts/pal_found_ontologies_cli.py +1225 -0
- pal_found_cli/orchestration/__init__.py +9 -0
- pal_found_cli/orchestration/metadata-allow-list.md +24 -0
- pal_found_cli/orchestration/scripts/__init__.py +1 -0
- pal_found_cli/orchestration/scripts/pal_found_orchestration_cli.py +640 -0
- pal_found_cli/sql_queries/__init__.py +9 -0
- pal_found_cli/sql_queries/metadata-allow-list.md +9 -0
- pal_found_cli/sql_queries/scripts/__init__.py +1 -0
- pal_found_cli/sql_queries/scripts/pal_found_sql_queries_cli.py +514 -0
- pal_found_cli/streams/__init__.py +9 -0
- pal_found_cli/streams/metadata-allow-list.md +19 -0
- pal_found_cli/streams/scripts/__init__.py +1 -0
- pal_found_cli/streams/scripts/pal_found_streams_cli.py +615 -0
- pal_found_cli/third_party_applications/__init__.py +9 -0
- pal_found_cli/third_party_applications/metadata-allow-list.md +13 -0
- pal_found_cli/third_party_applications/scripts/__init__.py +1 -0
- pal_found_cli/third_party_applications/scripts/pal_found_third_party_applications_cli.py +547 -0
- pal_found_cli/widgets/__init__.py +9 -0
- pal_found_cli/widgets/metadata-allow-list.md +12 -0
- pal_found_cli/widgets/scripts/__init__.py +1 -0
- pal_found_cli/widgets/scripts/pal_found_widgets_cli.py +571 -0
- pal_found_cli-0.1.2.dist-info/METADATA +74 -0
- pal_found_cli-0.1.2.dist-info/RECORD +89 -0
- pal_found_cli-0.1.2.dist-info/WHEEL +5 -0
- pal_found_cli-0.1.2.dist-info/entry_points.txt +19 -0
- pal_found_cli-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""AccessControlGuard — 8-step precedence model (SRS §4.2, ADR-007).
|
|
3
|
+
|
|
4
|
+
Implements the access control evaluation order:
|
|
5
|
+
0. Global ENABLED absolute gate
|
|
6
|
+
1. Operation-level ENABLED
|
|
7
|
+
2. Namespace-level ENABLED
|
|
8
|
+
3. Operation-level READONLY override (false = permit write when parent READONLY=true)
|
|
9
|
+
4. Namespace-level READONLY override (false = permit write when parent READONLY=true)
|
|
10
|
+
5. Global READONLY (blocks writes)
|
|
11
|
+
6. Namespace-level METADATA_ONLY override (false = permit content when parent METADATA_ONLY=true)
|
|
12
|
+
7. Global METADATA_ONLY (blocks content reads not in allow-list)
|
|
13
|
+
8. Permit
|
|
14
|
+
|
|
15
|
+
Per ADR-007, operation-level READONLY=true is NOT independently supported.
|
|
16
|
+
Only READONLY=false override of parent READONLY=true is valid.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from pal_found_cli.common.config_loader import ConfigLoader
|
|
26
|
+
from pal_found_cli.common.error_serializer import EXIT_ACCESS_CONTROL
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Write operations — heuristic: operations whose name contains known write verbs
|
|
32
|
+
# are classified as writes; all others are reads.
|
|
33
|
+
_WRITE_VERBS = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"create",
|
|
36
|
+
"delete",
|
|
37
|
+
"put",
|
|
38
|
+
"post",
|
|
39
|
+
"patch",
|
|
40
|
+
"replace",
|
|
41
|
+
"update",
|
|
42
|
+
"add",
|
|
43
|
+
"remove",
|
|
44
|
+
"commit",
|
|
45
|
+
"abort",
|
|
46
|
+
"upload",
|
|
47
|
+
"download",
|
|
48
|
+
"modify",
|
|
49
|
+
"upsert",
|
|
50
|
+
"cancel",
|
|
51
|
+
"revoke",
|
|
52
|
+
"preregister",
|
|
53
|
+
"execute",
|
|
54
|
+
"apply",
|
|
55
|
+
"publish",
|
|
56
|
+
"deploy",
|
|
57
|
+
"run",
|
|
58
|
+
"transform",
|
|
59
|
+
"clear",
|
|
60
|
+
"purge",
|
|
61
|
+
"build",
|
|
62
|
+
"blocking_continue",
|
|
63
|
+
"streaming_continue",
|
|
64
|
+
"rag_context",
|
|
65
|
+
"messages",
|
|
66
|
+
"embeddings",
|
|
67
|
+
# Mutating/cost-bearing operations introduced by the models and
|
|
68
|
+
# orchestration namespaces (DESIGN-013/014): these verbs mutate
|
|
69
|
+
# resources or launch billable work and must never be classified as
|
|
70
|
+
# reads under READONLY/METADATA_ONLY tiers.
|
|
71
|
+
"launch",
|
|
72
|
+
"promote",
|
|
73
|
+
"pause",
|
|
74
|
+
"unpause",
|
|
75
|
+
# Streams namespace (DESIGN-016): stream.reset and
|
|
76
|
+
# subscriber.reset_offsets destroy or rewind stream state and must
|
|
77
|
+
# stay write-classified.
|
|
78
|
+
"reset",
|
|
79
|
+
# Media Sets namespace (DESIGN-018): media_set.register registers a
|
|
80
|
+
# media item into a media set and media_set.calculate starts a
|
|
81
|
+
# transformation job. Both mutate media-set state or launch billable
|
|
82
|
+
# work and must never be classified as reads under the
|
|
83
|
+
# READONLY/METADATA_ONLY tiers.
|
|
84
|
+
"register",
|
|
85
|
+
"calculate",
|
|
86
|
+
# Third-Party Applications namespace (DESIGN-021): website.deploy and
|
|
87
|
+
# website.undeploy mutate deployed Website state and must never be
|
|
88
|
+
# classified as reads under the READONLY/METADATA_ONLY tiers.
|
|
89
|
+
"deploy",
|
|
90
|
+
"undeploy",
|
|
91
|
+
# Widgets namespace (DESIGN-022, corrected 8-op catalog): the final
|
|
92
|
+
# write set is dev_mode_settings.enable, dev_mode_settings.
|
|
93
|
+
# set_widget_set_by_id, release.delete, and repository.publish.
|
|
94
|
+
# ``enable`` is token-scoped but still a mutating POST; ``set_widget_set``
|
|
95
|
+
# covers both set_widget_set and set_widget_set_by_id via the prefix
|
|
96
|
+
# match. ``delete`` and ``publish`` were already classified as writes.
|
|
97
|
+
"enable",
|
|
98
|
+
"set_widget_set",
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Read-only metadata operations that are permitted even in METADATA_ONLY tier
|
|
103
|
+
# when NOT in the allow-list file (these are the minimal structural metadata)
|
|
104
|
+
_METADATA_VERBS = frozenset(
|
|
105
|
+
{
|
|
106
|
+
"get",
|
|
107
|
+
"list",
|
|
108
|
+
"search",
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AccessControlError(Exception):
|
|
114
|
+
"""Access control policy violation (exit code 8).
|
|
115
|
+
|
|
116
|
+
Attributes
|
|
117
|
+
----------
|
|
118
|
+
message : str
|
|
119
|
+
Human-readable error message.
|
|
120
|
+
exit_code : int
|
|
121
|
+
Exit code (always EXIT_ACCESS_CONTROL = 8).
|
|
122
|
+
step : int
|
|
123
|
+
Which step of the 8-step precedence model triggered the block.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
message: str,
|
|
129
|
+
step: int = 0,
|
|
130
|
+
blocked_rule: dict[str, Any] | None = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
super().__init__(message)
|
|
133
|
+
self.message = message
|
|
134
|
+
self.exit_code = EXIT_ACCESS_CONTROL
|
|
135
|
+
self.step = step
|
|
136
|
+
self.blocked_rule = blocked_rule or {"step": step, "message": message}
|
|
137
|
+
self.details = {"blocked_rule": self.blocked_rule}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class AccessControlGuard:
|
|
141
|
+
"""Evaluates access control using 8-step precedence model (SRS §4.2).
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
cfg : ConfigLoader
|
|
146
|
+
Configuration instance.
|
|
147
|
+
namespace : str
|
|
148
|
+
Namespace name (e.g., "DATASETS").
|
|
149
|
+
metadata_allowlist_path : str, optional
|
|
150
|
+
Path to metadata allow-list file; if None, uses the canonical location.
|
|
151
|
+
|
|
152
|
+
Usage
|
|
153
|
+
-----
|
|
154
|
+
>>> guard = AccessControlGuard(cfg, "DATASETS")
|
|
155
|
+
>>> guard.check("dataset", "create") # raises AccessControlError on block
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
cfg: ConfigLoader,
|
|
161
|
+
namespace: str,
|
|
162
|
+
metadata_allowlist_path: str | None = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
self.cfg = cfg
|
|
165
|
+
self.namespace = namespace.upper()
|
|
166
|
+
self._metadata_allowlist: set[str] | None = None
|
|
167
|
+
self._metadata_allowlist_path = metadata_allowlist_path
|
|
168
|
+
|
|
169
|
+
def _is_write_operation(self, operation: str) -> bool:
|
|
170
|
+
"""Classify an operation as a write operation.
|
|
171
|
+
|
|
172
|
+
Heuristic: if the operation name (lowercase) starts with any known
|
|
173
|
+
write verb, it's classified as a write. This covers the 355 SDK
|
|
174
|
+
operations without hardcoding a per-namespace list.
|
|
175
|
+
|
|
176
|
+
Parameters
|
|
177
|
+
----------
|
|
178
|
+
operation : str
|
|
179
|
+
Operation name (e.g., "create", "get", "put_schema").
|
|
180
|
+
|
|
181
|
+
Returns
|
|
182
|
+
-------
|
|
183
|
+
bool
|
|
184
|
+
True if the operation is classified as a write.
|
|
185
|
+
"""
|
|
186
|
+
op_lower = operation.lower()
|
|
187
|
+
for verb in _WRITE_VERBS:
|
|
188
|
+
if op_lower == verb or op_lower.startswith(verb + "_"):
|
|
189
|
+
return True
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
def _operation_env_key(self, resource: str, operation: str) -> str:
|
|
193
|
+
"""Build canonical env key suffix for an operation.
|
|
194
|
+
|
|
195
|
+
Follows the canonical transformation rule (SRS §5.2): the entire
|
|
196
|
+
SDK path ``{ns}.{resource}.{operation}`` is uppercased and joined
|
|
197
|
+
with underscores, keeping the operation name verbatim (NOT
|
|
198
|
+
reordered by verb). This ensures ``put_schema`` maps to
|
|
199
|
+
``DATASETS_DATASET_PUT_SCHEMA``, matching the canonical env-var
|
|
200
|
+
reference table and the Step-3 ``_READONLY=false`` override.
|
|
201
|
+
|
|
202
|
+
Parameters
|
|
203
|
+
----------
|
|
204
|
+
resource : str
|
|
205
|
+
Resource/class name (e.g., "dataset").
|
|
206
|
+
operation : str
|
|
207
|
+
Operation name (e.g., "put_schema", "get").
|
|
208
|
+
|
|
209
|
+
Returns
|
|
210
|
+
-------
|
|
211
|
+
str
|
|
212
|
+
Env key suffix such as ``DATASETS_DATASET_PUT_SCHEMA``.
|
|
213
|
+
"""
|
|
214
|
+
return f"{self.namespace}_{resource.upper()}_{operation.upper()}"
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def _is_true_env(value: str | None) -> bool:
|
|
218
|
+
return value is not None and value.lower() in ("true", "1", "yes", "on")
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _is_false_env(value: str | None) -> bool:
|
|
222
|
+
return value is not None and value.lower() in ("false", "0", "no", "off")
|
|
223
|
+
|
|
224
|
+
def _get_global_readonly(self) -> bool:
|
|
225
|
+
env_val = os.environ.get("FOUNDRY_AGENTIC_CLI_READONLY")
|
|
226
|
+
if env_val is not None:
|
|
227
|
+
return self._is_true_env(env_val)
|
|
228
|
+
return bool(self.cfg.global_readonly)
|
|
229
|
+
|
|
230
|
+
def _get_global_enabled(self) -> bool:
|
|
231
|
+
"""Return false only when global CLI enablement is explicitly disabled."""
|
|
232
|
+
env_val = os.environ.get("FOUNDRY_AGENTIC_CLI_ENABLED")
|
|
233
|
+
if env_val is not None:
|
|
234
|
+
return not self._is_false_env(env_val)
|
|
235
|
+
configured = getattr(self.cfg, "global_enabled", True)
|
|
236
|
+
return bool(configured)
|
|
237
|
+
|
|
238
|
+
def _get_global_metadata_only(self) -> bool:
|
|
239
|
+
env_val = os.environ.get("FOUNDRY_AGENTIC_CLI_METADATA_ONLY")
|
|
240
|
+
if env_val is not None:
|
|
241
|
+
return self._is_true_env(env_val)
|
|
242
|
+
return bool(self.cfg.global_metadata_only)
|
|
243
|
+
|
|
244
|
+
def _get_namespace_readonly(self) -> bool:
|
|
245
|
+
"""Check if namespace-level READONLY is explicitly set to true.
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
bool
|
|
250
|
+
True if FOUNDRY_AGENTIC_CLI_{NS}_READONLY is explicitly "true".
|
|
251
|
+
"""
|
|
252
|
+
ns_readonly_env = f"FOUNDRY_AGENTIC_CLI_{self.namespace}_READONLY"
|
|
253
|
+
ns_readonly_val = os.environ.get(ns_readonly_env)
|
|
254
|
+
if ns_readonly_val is None:
|
|
255
|
+
return False
|
|
256
|
+
return ns_readonly_val.lower() in ("true", "1", "yes")
|
|
257
|
+
|
|
258
|
+
def _get_namespace_metadata_only(self) -> bool:
|
|
259
|
+
"""Check if namespace-level METADATA_ONLY is explicitly set to true.
|
|
260
|
+
|
|
261
|
+
Returns
|
|
262
|
+
-------
|
|
263
|
+
bool
|
|
264
|
+
True if FOUNDRY_AGENTIC_CLI_{NS}_METADATA_ONLY is explicitly "true".
|
|
265
|
+
"""
|
|
266
|
+
ns_metadata_env = f"FOUNDRY_AGENTIC_CLI_{self.namespace}_METADATA_ONLY"
|
|
267
|
+
ns_metadata_val = os.environ.get(ns_metadata_env)
|
|
268
|
+
if ns_metadata_val is None:
|
|
269
|
+
return False
|
|
270
|
+
return ns_metadata_val.lower() in ("true", "1", "yes")
|
|
271
|
+
|
|
272
|
+
def _log_decision(
|
|
273
|
+
self,
|
|
274
|
+
decision: str,
|
|
275
|
+
resource: str,
|
|
276
|
+
operation: str,
|
|
277
|
+
step: int,
|
|
278
|
+
reason: str,
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Log access control decision as NDJSON (ADR-005).
|
|
281
|
+
|
|
282
|
+
Parameters
|
|
283
|
+
----------
|
|
284
|
+
decision : str
|
|
285
|
+
"BLOCKED" or "PERMITTED".
|
|
286
|
+
resource : str
|
|
287
|
+
Resource name.
|
|
288
|
+
operation : str
|
|
289
|
+
Operation name.
|
|
290
|
+
step : int
|
|
291
|
+
Step number (1-8) in precedence model.
|
|
292
|
+
reason : str
|
|
293
|
+
Human-readable reason for the decision.
|
|
294
|
+
"""
|
|
295
|
+
logger.info(
|
|
296
|
+
"Access control decision: %s (Step %d) — %s",
|
|
297
|
+
decision,
|
|
298
|
+
step,
|
|
299
|
+
reason,
|
|
300
|
+
extra={
|
|
301
|
+
"access_decision": decision,
|
|
302
|
+
"op": f"{self.namespace.lower()}.{resource.lower()}.{operation.lower()}",
|
|
303
|
+
"step": step,
|
|
304
|
+
"reason": reason,
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _load_metadata_allowlist(self) -> set[str]:
|
|
309
|
+
"""Load metadata allow-list from canonical file.
|
|
310
|
+
|
|
311
|
+
Returns
|
|
312
|
+
-------
|
|
313
|
+
set of str
|
|
314
|
+
Set of operation keys (e.g., "datasets.dataset.get") permitted
|
|
315
|
+
in METADATA_ONLY tier.
|
|
316
|
+
|
|
317
|
+
Notes
|
|
318
|
+
-----
|
|
319
|
+
The allow-list file is a Markdown table. This parser is intentionally
|
|
320
|
+
lightweight and extracts lines containing "PERMITTED" to build the set.
|
|
321
|
+
"""
|
|
322
|
+
allowlist_path: Path | None
|
|
323
|
+
if self._metadata_allowlist_path:
|
|
324
|
+
allowlist_path = Path(self._metadata_allowlist_path)
|
|
325
|
+
else:
|
|
326
|
+
# Core namespaces share a packaged policy so installed commands do
|
|
327
|
+
# not depend on the design repository or the current directory.
|
|
328
|
+
allowlist_path = Path(__file__).with_name("metadata-allow-list.md")
|
|
329
|
+
|
|
330
|
+
if not allowlist_path.exists():
|
|
331
|
+
return set()
|
|
332
|
+
|
|
333
|
+
permitted: set[str] = set()
|
|
334
|
+
canonical_row = re.compile(
|
|
335
|
+
r"^\|\s*`(?P<sdk_path>[a-z0-9_]+\.[a-z0-9_]+\.[a-z0-9_]+)`\s*"
|
|
336
|
+
r"\|\s*PERMITTED\s*\|",
|
|
337
|
+
)
|
|
338
|
+
text = allowlist_path.read_text(encoding="utf-8")
|
|
339
|
+
for line in text.splitlines():
|
|
340
|
+
match = canonical_row.match(line.strip())
|
|
341
|
+
if match:
|
|
342
|
+
permitted.add(match.group("sdk_path").lower())
|
|
343
|
+
return permitted
|
|
344
|
+
|
|
345
|
+
def _is_in_metadata_allowlist(self, resource: str, operation: str) -> bool:
|
|
346
|
+
"""Check if an operation is in the metadata allow-list.
|
|
347
|
+
|
|
348
|
+
Parameters
|
|
349
|
+
----------
|
|
350
|
+
resource : str
|
|
351
|
+
Resource name.
|
|
352
|
+
operation : str
|
|
353
|
+
Operation name.
|
|
354
|
+
|
|
355
|
+
Returns
|
|
356
|
+
-------
|
|
357
|
+
bool
|
|
358
|
+
True if the operation is permitted in METADATA_ONLY tier.
|
|
359
|
+
"""
|
|
360
|
+
if self._metadata_allowlist is None:
|
|
361
|
+
self._metadata_allowlist = self._load_metadata_allowlist()
|
|
362
|
+
|
|
363
|
+
ns_lower = self.namespace.lower()
|
|
364
|
+
key = f"{ns_lower}.{resource.lower()}.{operation.lower()}"
|
|
365
|
+
return key in self._metadata_allowlist
|
|
366
|
+
|
|
367
|
+
def check(
|
|
368
|
+
self,
|
|
369
|
+
resource: str,
|
|
370
|
+
operation: str,
|
|
371
|
+
) -> None:
|
|
372
|
+
"""Evaluate access control for an operation (8-step precedence).
|
|
373
|
+
|
|
374
|
+
Parameters
|
|
375
|
+
----------
|
|
376
|
+
resource : str
|
|
377
|
+
Resource/class name (e.g., "dataset", "branch").
|
|
378
|
+
operation : str
|
|
379
|
+
Operation name (e.g., "create", "get").
|
|
380
|
+
|
|
381
|
+
Raises
|
|
382
|
+
------
|
|
383
|
+
AccessControlError
|
|
384
|
+
If access control blocks the operation.
|
|
385
|
+
"""
|
|
386
|
+
full_key = self._operation_env_key(resource, operation)
|
|
387
|
+
is_write = self._is_write_operation(operation)
|
|
388
|
+
op_path = f"{self.namespace.lower()}.{resource.lower()}.{operation.lower()}"
|
|
389
|
+
|
|
390
|
+
def _blocked(
|
|
391
|
+
message: str, step: int, env_var: str, value: str
|
|
392
|
+
) -> AccessControlError:
|
|
393
|
+
return AccessControlError(
|
|
394
|
+
message,
|
|
395
|
+
step=step,
|
|
396
|
+
blocked_rule={
|
|
397
|
+
"step": step,
|
|
398
|
+
"operation": op_path,
|
|
399
|
+
"env_var": env_var,
|
|
400
|
+
"value": value,
|
|
401
|
+
"message": message,
|
|
402
|
+
},
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Global disable is an absolute gate. Narrower true values cannot
|
|
406
|
+
# re-enable a globally disabled CLI.
|
|
407
|
+
if not self._get_global_enabled():
|
|
408
|
+
global_enabled_env = "FOUNDRY_AGENTIC_CLI_ENABLED"
|
|
409
|
+
global_enabled_val = os.environ.get(global_enabled_env, "false")
|
|
410
|
+
self._log_decision(
|
|
411
|
+
"BLOCKED", resource, operation, 0, "Global ENABLED=false"
|
|
412
|
+
)
|
|
413
|
+
raise _blocked(
|
|
414
|
+
"Operation blocked: global ENABLED=false",
|
|
415
|
+
0,
|
|
416
|
+
global_enabled_env,
|
|
417
|
+
global_enabled_val,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# Step 1: Operation-level ENABLED
|
|
421
|
+
enabled_env = f"FOUNDRY_AGENTIC_CLI_{full_key}_ENABLED"
|
|
422
|
+
enabled_val = os.environ.get(enabled_env)
|
|
423
|
+
if enabled_val is not None and enabled_val.lower() == "false":
|
|
424
|
+
self._log_decision(
|
|
425
|
+
"BLOCKED", resource, operation, 1, f"ENABLED=false for {full_key}"
|
|
426
|
+
)
|
|
427
|
+
raise _blocked(
|
|
428
|
+
f"Operation blocked: ENABLED=false for {full_key}",
|
|
429
|
+
1,
|
|
430
|
+
enabled_env,
|
|
431
|
+
enabled_val,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# Step 2: Namespace-level ENABLED
|
|
435
|
+
ns_enabled_env = f"FOUNDRY_AGENTIC_CLI_{self.namespace}_ENABLED"
|
|
436
|
+
ns_enabled_val = os.environ.get(ns_enabled_env)
|
|
437
|
+
if ns_enabled_val is not None and ns_enabled_val.lower() == "false":
|
|
438
|
+
self._log_decision(
|
|
439
|
+
"BLOCKED", resource, operation, 2, f"ENABLED=false for {self.namespace}"
|
|
440
|
+
)
|
|
441
|
+
raise _blocked(
|
|
442
|
+
f"Namespace blocked: ENABLED=false for {self.namespace}",
|
|
443
|
+
2,
|
|
444
|
+
ns_enabled_env,
|
|
445
|
+
ns_enabled_val,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
ns_metadata_env = f"FOUNDRY_AGENTIC_CLI_{self.namespace}_METADATA_ONLY"
|
|
449
|
+
ns_metadata_val = os.environ.get(ns_metadata_env)
|
|
450
|
+
namespace_metadata_only = self._is_true_env(ns_metadata_val)
|
|
451
|
+
namespace_metadata_override = self._is_false_env(ns_metadata_val)
|
|
452
|
+
global_metadata_only = self._get_global_metadata_only()
|
|
453
|
+
metadata_only_active = namespace_metadata_only or (
|
|
454
|
+
global_metadata_only and not namespace_metadata_override
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# Steps 3-5: READONLY evaluation (only for write operations)
|
|
458
|
+
if is_write:
|
|
459
|
+
parent_readonly = (
|
|
460
|
+
self._get_global_readonly() or self._get_namespace_readonly()
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
if parent_readonly:
|
|
464
|
+
# Step 3: Operation-level READONLY override
|
|
465
|
+
op_readonly_env = f"FOUNDRY_AGENTIC_CLI_{full_key}_READONLY"
|
|
466
|
+
op_readonly_val = os.environ.get(op_readonly_env)
|
|
467
|
+
if op_readonly_val is not None and op_readonly_val.lower() == "false":
|
|
468
|
+
self._log_decision(
|
|
469
|
+
"PERMITTED",
|
|
470
|
+
resource,
|
|
471
|
+
operation,
|
|
472
|
+
3,
|
|
473
|
+
f"READONLY=false override for {full_key}",
|
|
474
|
+
)
|
|
475
|
+
return # Explicit write permit for this operation
|
|
476
|
+
|
|
477
|
+
# Step 4: Namespace-level READONLY override
|
|
478
|
+
ns_readonly_env = f"FOUNDRY_AGENTIC_CLI_{self.namespace}_READONLY"
|
|
479
|
+
ns_readonly_val = os.environ.get(ns_readonly_env)
|
|
480
|
+
if ns_readonly_val is not None and ns_readonly_val.lower() == "false":
|
|
481
|
+
self._log_decision(
|
|
482
|
+
"PERMITTED",
|
|
483
|
+
resource,
|
|
484
|
+
operation,
|
|
485
|
+
4,
|
|
486
|
+
f"READONLY=false override for {self.namespace}",
|
|
487
|
+
)
|
|
488
|
+
return # Explicit write permit for this namespace
|
|
489
|
+
|
|
490
|
+
if self._get_namespace_readonly():
|
|
491
|
+
self._log_decision(
|
|
492
|
+
"BLOCKED",
|
|
493
|
+
resource,
|
|
494
|
+
operation,
|
|
495
|
+
4,
|
|
496
|
+
f"READONLY=true for {self.namespace}",
|
|
497
|
+
)
|
|
498
|
+
raise _blocked(
|
|
499
|
+
"Operation blocked: namespace read-only mode active",
|
|
500
|
+
4,
|
|
501
|
+
ns_readonly_env,
|
|
502
|
+
"true",
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# Step 5: Global READONLY
|
|
506
|
+
if self._get_global_readonly():
|
|
507
|
+
self._log_decision(
|
|
508
|
+
"BLOCKED",
|
|
509
|
+
resource,
|
|
510
|
+
operation,
|
|
511
|
+
5,
|
|
512
|
+
"Global READONLY=true blocks write",
|
|
513
|
+
)
|
|
514
|
+
raise _blocked(
|
|
515
|
+
"Operation blocked: read-only mode active",
|
|
516
|
+
5,
|
|
517
|
+
"FOUNDRY_AGENTIC_CLI_READONLY",
|
|
518
|
+
"true",
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
# Steps 6-7: METADATA_ONLY evaluation.
|
|
522
|
+
if global_metadata_only and namespace_metadata_override:
|
|
523
|
+
self._log_decision(
|
|
524
|
+
"PERMITTED",
|
|
525
|
+
resource,
|
|
526
|
+
operation,
|
|
527
|
+
6,
|
|
528
|
+
f"METADATA_ONLY=false override for {self.namespace}",
|
|
529
|
+
)
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
if metadata_only_active:
|
|
533
|
+
metadata_env = (
|
|
534
|
+
ns_metadata_env
|
|
535
|
+
if namespace_metadata_only
|
|
536
|
+
else "FOUNDRY_AGENTIC_CLI_METADATA_ONLY"
|
|
537
|
+
)
|
|
538
|
+
if is_write:
|
|
539
|
+
step = 6 if namespace_metadata_only else 7
|
|
540
|
+
self._log_decision(
|
|
541
|
+
"BLOCKED", resource, operation, 7, "METADATA_ONLY=true blocks write"
|
|
542
|
+
)
|
|
543
|
+
raise _blocked(
|
|
544
|
+
"Operation blocked: metadata-only mode active",
|
|
545
|
+
step,
|
|
546
|
+
metadata_env,
|
|
547
|
+
"true",
|
|
548
|
+
)
|
|
549
|
+
if not self._is_in_metadata_allowlist(resource, operation):
|
|
550
|
+
self._log_decision(
|
|
551
|
+
"BLOCKED",
|
|
552
|
+
resource,
|
|
553
|
+
operation,
|
|
554
|
+
7,
|
|
555
|
+
"Operation not in metadata allow-list",
|
|
556
|
+
)
|
|
557
|
+
raise _blocked(
|
|
558
|
+
"Operation blocked: not in metadata allow-list",
|
|
559
|
+
7,
|
|
560
|
+
metadata_env,
|
|
561
|
+
"true",
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# Step 8: Permit
|
|
565
|
+
self._log_decision("PERMITTED", resource, operation, 8, "Default full access")
|
|
566
|
+
return
|
|
567
|
+
|
|
568
|
+
def _is_metadata_operation(self, resource: str, operation: str) -> bool:
|
|
569
|
+
"""Check if an operation is a structural metadata operation.
|
|
570
|
+
|
|
571
|
+
Heuristic: operations whose name starts with known metadata verbs
|
|
572
|
+
(get, list, search) are structural metadata and are always permitted
|
|
573
|
+
in METADATA_ONLY tier even if not explicitly in the allow-list.
|
|
574
|
+
|
|
575
|
+
Parameters
|
|
576
|
+
----------
|
|
577
|
+
resource : str
|
|
578
|
+
Resource name.
|
|
579
|
+
operation : str
|
|
580
|
+
Operation name.
|
|
581
|
+
|
|
582
|
+
Returns
|
|
583
|
+
-------
|
|
584
|
+
bool
|
|
585
|
+
True if the operation is structural metadata.
|
|
586
|
+
"""
|
|
587
|
+
op_lower = operation.lower()
|
|
588
|
+
for verb in _METADATA_VERBS:
|
|
589
|
+
if op_lower == verb or op_lower.startswith(verb + "_"):
|
|
590
|
+
return True
|
|
591
|
+
return False
|