datamasque-cli 1.1.0__tar.gz → 1.2.0__tar.gz
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.
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/CHANGELOG.md +36 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/PKG-INFO +22 -1
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/README.md +21 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/pyproject.toml +1 -1
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/client.py +63 -25
- datamasque_cli-1.2.0/src/datamasque_cli/commands/ifm.py +354 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/main.py +2 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/output.py +26 -6
- datamasque_cli-1.2.0/tests/commands/test_ifm.py +577 -0
- datamasque_cli-1.2.0/tests/test_client_ifm.py +65 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/uv.lock +1 -1
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/.claude-plugin/marketplace.json +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/.github/workflows/ci.yml +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/.github/workflows/release-testpypi.yml +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/.github/workflows/release.yml +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/.gitignore +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/CONTRIBUTING.md +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/LICENSE +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/Makefile +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/NOTICE +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/assets/demo.gif +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/README.md +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/datamasque-cli/.claude-plugin/plugin.json +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/datamasque-cli/skills/datamasque-cli/SKILL.md +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-builder/.claude-plugin/plugin.json +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-builder/skills/ruleset-builder/SKILL.md +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/hash-columns-guide.md +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/mask-definitions-guide.md +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/ruleset-libraries-guide.md +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/ruleset-yaml-reference.md +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-splitter/.claude-plugin/plugin.json +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-splitter/skills/ruleset-splitter/SKILL.md +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/scripts/active_profile_env.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/scripts/bump_version.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/__init__.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/__init__.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/auth.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/connections.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/discovery.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/files.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/ruleset_libraries.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/rulesets.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/runs.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/seeds.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/system.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/users.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/config.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/py.typed +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/__init__.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/__init__.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_auth.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_catalog.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_connections.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_discovery.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_files.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_ruleset_libraries.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_rulesets.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_runs.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_seeds.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_system.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_users.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/conftest.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/integration/README.md +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/integration/__init__.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/integration/conftest.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/integration/test_connections.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/integration/test_delete_safety.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/integration/test_rulesets.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/integration/test_runs.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/test_client_auth.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/test_client_env.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/test_client_profile.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/test_config.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/test_output.py +0 -0
- {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/test_version.py +0 -0
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.2.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `dm ifm` command group
|
|
7
|
+
for managing in-flight masking ruleset plans
|
|
8
|
+
and running mask operations against the IFM service:
|
|
9
|
+
- `dm ifm list` —
|
|
10
|
+
list all IFM ruleset plans.
|
|
11
|
+
- `dm ifm get <name>` —
|
|
12
|
+
show plan metadata,
|
|
13
|
+
or the ruleset YAML with `--yaml`.
|
|
14
|
+
- `dm ifm create --name <name> --file <yaml>` —
|
|
15
|
+
create a plan from a YAML ruleset,
|
|
16
|
+
with optional `--enabled/--disabled` and `--log-level`.
|
|
17
|
+
- `dm ifm update <name>` —
|
|
18
|
+
update a plan;
|
|
19
|
+
pass any of `--file`, `--enabled/--disabled`, `--log-level`
|
|
20
|
+
and only those fields are sent.
|
|
21
|
+
- `dm ifm delete <name>` —
|
|
22
|
+
delete a plan
|
|
23
|
+
(interactive confirm,
|
|
24
|
+
or `--yes` to skip).
|
|
25
|
+
- `dm ifm mask <name> --data <file|->` —
|
|
26
|
+
mask a JSON list of records against a plan,
|
|
27
|
+
with `--disable-instance-secret`,
|
|
28
|
+
`--run-secret`,
|
|
29
|
+
`--log-level`,
|
|
30
|
+
`--request-id`,
|
|
31
|
+
and `--json/--no-json` (NDJSON) output.
|
|
32
|
+
- `dm ifm verify-token` —
|
|
33
|
+
verify the current IFM token and list its scopes.
|
|
34
|
+
|
|
35
|
+
Authentication reuses your existing `dm` profile credentials
|
|
36
|
+
via the SDK's `DataMasqueIfmClient`,
|
|
37
|
+
which transparently exchanges admin-server credentials for an IFM JWT.
|
|
38
|
+
|
|
3
39
|
## v1.1.0
|
|
4
40
|
|
|
5
41
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datamasque-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Official command-line interface for the DataMasque data-masking platform.
|
|
5
5
|
Project-URL: Homepage, https://datamasque.com/
|
|
6
6
|
Project-URL: Repository, https://github.com/datamasque/datamasque-cli
|
|
@@ -39,6 +39,7 @@ so teams can use production-shaped data in non-production environments without e
|
|
|
39
39
|
DataMasque CLI `dm` covers:
|
|
40
40
|
|
|
41
41
|
- connections, rulesets, ruleset libraries, and masking runs
|
|
42
|
+
- in-flight masking (IFM) ruleset plans and on-demand mask requests
|
|
42
43
|
- schema discovery and sensitive-data discovery
|
|
43
44
|
- users, files, and DataMasque instance administration
|
|
44
45
|
|
|
@@ -196,6 +197,26 @@ dm libraries validate <name> # Re-validate against current
|
|
|
196
197
|
dm libraries usage <name> # Show rulesets using it
|
|
197
198
|
```
|
|
198
199
|
|
|
200
|
+
### In-flight masking
|
|
201
|
+
|
|
202
|
+
The IFM service runs alongside the admin server,
|
|
203
|
+
reached at `<DataMasque URL>/ifm` via the standard nginx topology.
|
|
204
|
+
|
|
205
|
+
```console
|
|
206
|
+
dm ifm list # List ruleset plans
|
|
207
|
+
dm ifm get <name> # Show plan metadata
|
|
208
|
+
dm ifm get <name> --yaml # Print the ruleset YAML
|
|
209
|
+
dm ifm create --name myplan --file rules.yaml # Create (server suffixes a random string to the name)
|
|
210
|
+
dm ifm create --name myplan --file rules.yaml --disabled --log-level DEBUG
|
|
211
|
+
dm ifm update <name> --file rules.yaml # Replace the ruleset YAML
|
|
212
|
+
dm ifm update <name> --enabled # Toggle without re-sending the YAML
|
|
213
|
+
dm ifm update <name> --log-level INFO
|
|
214
|
+
dm ifm delete <name> --yes # Delete a plan
|
|
215
|
+
dm ifm mask <name> --data input.json # Mask a JSON list of records
|
|
216
|
+
dm ifm mask <name> --data - # Read records from stdin
|
|
217
|
+
dm ifm verify-token # Show scopes granted to the current IFM token
|
|
218
|
+
```
|
|
219
|
+
|
|
199
220
|
### Masking runs
|
|
200
221
|
|
|
201
222
|
```console
|
|
@@ -9,6 +9,7 @@ so teams can use production-shaped data in non-production environments without e
|
|
|
9
9
|
DataMasque CLI `dm` covers:
|
|
10
10
|
|
|
11
11
|
- connections, rulesets, ruleset libraries, and masking runs
|
|
12
|
+
- in-flight masking (IFM) ruleset plans and on-demand mask requests
|
|
12
13
|
- schema discovery and sensitive-data discovery
|
|
13
14
|
- users, files, and DataMasque instance administration
|
|
14
15
|
|
|
@@ -166,6 +167,26 @@ dm libraries validate <name> # Re-validate against current
|
|
|
166
167
|
dm libraries usage <name> # Show rulesets using it
|
|
167
168
|
```
|
|
168
169
|
|
|
170
|
+
### In-flight masking
|
|
171
|
+
|
|
172
|
+
The IFM service runs alongside the admin server,
|
|
173
|
+
reached at `<DataMasque URL>/ifm` via the standard nginx topology.
|
|
174
|
+
|
|
175
|
+
```console
|
|
176
|
+
dm ifm list # List ruleset plans
|
|
177
|
+
dm ifm get <name> # Show plan metadata
|
|
178
|
+
dm ifm get <name> --yaml # Print the ruleset YAML
|
|
179
|
+
dm ifm create --name myplan --file rules.yaml # Create (server suffixes a random string to the name)
|
|
180
|
+
dm ifm create --name myplan --file rules.yaml --disabled --log-level DEBUG
|
|
181
|
+
dm ifm update <name> --file rules.yaml # Replace the ruleset YAML
|
|
182
|
+
dm ifm update <name> --enabled # Toggle without re-sending the YAML
|
|
183
|
+
dm ifm update <name> --log-level INFO
|
|
184
|
+
dm ifm delete <name> --yes # Delete a plan
|
|
185
|
+
dm ifm mask <name> --data input.json # Mask a JSON list of records
|
|
186
|
+
dm ifm mask <name> --data - # Read records from stdin
|
|
187
|
+
dm ifm verify-token # Show scopes granted to the current IFM token
|
|
188
|
+
```
|
|
189
|
+
|
|
169
190
|
### Masking runs
|
|
170
191
|
|
|
171
192
|
```console
|
|
@@ -8,9 +8,10 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
10
|
|
|
11
|
-
from datamasque.client import DataMasqueClient
|
|
12
|
-
from datamasque.client.exceptions import DataMasqueApiError, DataMasqueTransportError
|
|
11
|
+
from datamasque.client import DataMasqueClient, DataMasqueIfmClient
|
|
12
|
+
from datamasque.client.exceptions import DataMasqueApiError, DataMasqueTransportError, IfmAuthError
|
|
13
13
|
from datamasque.client.models.dm_instance import DataMasqueInstanceConfig
|
|
14
|
+
from datamasque.client.models.ifm import DataMasqueIfmInstanceConfig
|
|
14
15
|
|
|
15
16
|
from datamasque_cli.config import Config, Profile, load_config
|
|
16
17
|
from datamasque_cli.output import ErrorCode, abort
|
|
@@ -54,14 +55,38 @@ def _resolve_profile(config: Config, profile_name: str | None) -> Profile:
|
|
|
54
55
|
abort(
|
|
55
56
|
f"Profile '{name}' is not configured.",
|
|
56
57
|
code=ErrorCode.AUTH_REQUIRED,
|
|
57
|
-
hint=(
|
|
58
|
-
f"Run: dm auth login --profile {name} --url <URL> --username <USER> "
|
|
59
|
-
f"or set {ENV_URL}, {ENV_USERNAME}, and {ENV_PASSWORD}."
|
|
60
|
-
),
|
|
58
|
+
hint=(f"Run: dm auth login --profile {name} or set {ENV_URL}, {ENV_USERNAME}, and {ENV_PASSWORD}."),
|
|
61
59
|
)
|
|
62
60
|
return profile
|
|
63
61
|
|
|
64
62
|
|
|
63
|
+
def _resolve_profile_with_verify(profile_name: str | None) -> tuple[Profile, bool]:
|
|
64
|
+
"""Resolve the active `Profile` and apply env-var overrides for `verify_ssl`."""
|
|
65
|
+
env_profile = profile_from_env() if profile_name is None else None
|
|
66
|
+
if env_profile is not None:
|
|
67
|
+
profile = env_profile
|
|
68
|
+
else:
|
|
69
|
+
config = load_config()
|
|
70
|
+
profile = _resolve_profile(config, profile_name)
|
|
71
|
+
return profile, _verify_ssl_from_env(default=profile.verify_ssl)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _authenticate_or_abort(
|
|
75
|
+
client: DataMasqueClient | DataMasqueIfmClient,
|
|
76
|
+
url: str,
|
|
77
|
+
*,
|
|
78
|
+
verify_ssl: bool,
|
|
79
|
+
failure_label: str = "Authentication",
|
|
80
|
+
extra_auth_excs: tuple[type[Exception], ...] = (),
|
|
81
|
+
) -> None:
|
|
82
|
+
try:
|
|
83
|
+
client.authenticate()
|
|
84
|
+
except DataMasqueTransportError as e:
|
|
85
|
+
abort(_format_transport_error(url, e, verify_ssl=verify_ssl), code=ErrorCode.TRANSPORT_ERROR)
|
|
86
|
+
except (DataMasqueApiError, *extra_auth_excs) as e:
|
|
87
|
+
abort(f"{failure_label} failed: {e}", code=ErrorCode.AUTH_FAILED)
|
|
88
|
+
|
|
89
|
+
|
|
65
90
|
def get_client(profile_name: str | None = None) -> DataMasqueClient:
|
|
66
91
|
"""Build and authenticate a `DataMasqueClient`.
|
|
67
92
|
|
|
@@ -69,18 +94,11 @@ def get_client(profile_name: str | None = None) -> DataMasqueClient:
|
|
|
69
94
|
1. Environment variables (DATAMASQUE_URL, DATAMASQUE_USERNAME, DATAMASQUE_PASSWORD)
|
|
70
95
|
2. Named profile (--profile flag)
|
|
71
96
|
3. Active profile from config file
|
|
72
|
-
"""
|
|
73
|
-
# Env vars take precedence unless a specific profile was requested.
|
|
74
|
-
env_profile = profile_from_env() if profile_name is None else None
|
|
75
|
-
if env_profile is not None:
|
|
76
|
-
profile = env_profile
|
|
77
|
-
else:
|
|
78
|
-
config = load_config()
|
|
79
|
-
profile = _resolve_profile(config, profile_name)
|
|
80
97
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
98
|
+
`DATAMASQUE_VERIFY_SSL` always wins over the stored profile so you can
|
|
99
|
+
flip TLS verification per-call without re-running `dm auth login`.
|
|
100
|
+
"""
|
|
101
|
+
profile, verify_ssl = _resolve_profile_with_verify(profile_name)
|
|
84
102
|
instance_config = DataMasqueInstanceConfig(
|
|
85
103
|
base_url=profile.url,
|
|
86
104
|
username=profile.username,
|
|
@@ -89,14 +107,7 @@ def get_client(profile_name: str | None = None) -> DataMasqueClient:
|
|
|
89
107
|
)
|
|
90
108
|
|
|
91
109
|
client = DataMasqueClient(instance_config)
|
|
92
|
-
|
|
93
|
-
try:
|
|
94
|
-
client.authenticate()
|
|
95
|
-
except DataMasqueTransportError as e:
|
|
96
|
-
abort(_format_transport_error(profile.url, e, verify_ssl=verify_ssl), code=ErrorCode.TRANSPORT_ERROR)
|
|
97
|
-
except DataMasqueApiError as e:
|
|
98
|
-
abort(f"Authentication failed: {e}", code=ErrorCode.AUTH_FAILED)
|
|
99
|
-
|
|
110
|
+
_authenticate_or_abort(client, profile.url, verify_ssl=verify_ssl)
|
|
100
111
|
return client
|
|
101
112
|
|
|
102
113
|
|
|
@@ -110,3 +121,30 @@ def _format_transport_error(url: str, error: Exception, *, verify_ssl: bool) ->
|
|
|
110
121
|
if verify_ssl and any(term in str(error).lower() for term in _SSL_HINT_TERMS):
|
|
111
122
|
message += "\nIf this is a self-signed local build, retry with --insecure or set DATAMASQUE_VERIFY_SSL=false."
|
|
112
123
|
return message
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_ifm_client(profile_name: str | None = None) -> DataMasqueIfmClient:
|
|
127
|
+
"""Build and authenticate a `DataMasqueIfmClient`.
|
|
128
|
+
|
|
129
|
+
Credential resolution order matches `get_client`.
|
|
130
|
+
The IFM base URL is derived as `<admin_url>/ifm`,
|
|
131
|
+
matching the standard nginx topology that proxies `/ifm/` to the IFM container on the same hostname.
|
|
132
|
+
"""
|
|
133
|
+
profile, verify_ssl = _resolve_profile_with_verify(profile_name)
|
|
134
|
+
instance_config = DataMasqueIfmInstanceConfig(
|
|
135
|
+
admin_server_base_url=profile.url,
|
|
136
|
+
ifm_base_url=f"{profile.url.rstrip('/')}/ifm",
|
|
137
|
+
username=profile.username,
|
|
138
|
+
password=profile.password,
|
|
139
|
+
verify_ssl=verify_ssl,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
client = DataMasqueIfmClient(instance_config)
|
|
143
|
+
_authenticate_or_abort(
|
|
144
|
+
client,
|
|
145
|
+
profile.url,
|
|
146
|
+
verify_ssl=verify_ssl,
|
|
147
|
+
failure_label="IFM authentication",
|
|
148
|
+
extra_auth_excs=(IfmAuthError,),
|
|
149
|
+
)
|
|
150
|
+
return client
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""In-flight masking (IFM) commands.
|
|
2
|
+
|
|
3
|
+
Wraps `DataMasqueIfmClient` for managing IFM ruleset plans and running mask operations.
|
|
4
|
+
The IFM service exposes a separate HTTP API;
|
|
5
|
+
the SDK handles JWT auth transparently using the same admin-server credentials as `dm rulesets`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, NoReturn
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from datamasque.client.exceptions import DataMasqueApiError
|
|
18
|
+
from datamasque.client.models.ifm import (
|
|
19
|
+
IfmMaskRequest,
|
|
20
|
+
RulesetPlanCreateRequest,
|
|
21
|
+
RulesetPlanOptions,
|
|
22
|
+
RulesetPlanPartialUpdateRequest,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from datamasque_cli.client import get_ifm_client
|
|
26
|
+
from datamasque_cli.output import ErrorCode, abort, print_error, print_json, print_success, render_output
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(help="Manage in-flight-masking (IFM) ruleset plans and execute masks.", no_args_is_help=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# IFM service maps HTTP statuses to the CLI's stable `ErrorCode` taxonomy so
|
|
32
|
+
# agents and scripts get the right exit code (see "Exit codes" in `README.md`).
|
|
33
|
+
# Anything not listed falls through to `ErrorCode.ERROR` (exit 1).
|
|
34
|
+
_STATUS_TO_ERROR_CODE: dict[int, ErrorCode] = {
|
|
35
|
+
400: ErrorCode.INVALID_INPUT,
|
|
36
|
+
404: ErrorCode.NOT_FOUND,
|
|
37
|
+
409: ErrorCode.CONFLICT,
|
|
38
|
+
422: ErrorCode.INVALID_INPUT,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _format_pydantic_errors(errors: list[Any]) -> str:
|
|
43
|
+
"""Flatten FastAPI's `detail` list (Pydantic `e.errors()`) into a readable string.
|
|
44
|
+
|
|
45
|
+
Each entry looks like `{"loc": [...], "msg": "...", "type": "..."}`;
|
|
46
|
+
we render `field.path: message` per entry, joined with `; `.
|
|
47
|
+
Entries that don't match the shape fall back to `str(entry)`.
|
|
48
|
+
"""
|
|
49
|
+
parts: list[str] = []
|
|
50
|
+
for entry in errors:
|
|
51
|
+
if isinstance(entry, dict) and "msg" in entry:
|
|
52
|
+
loc = entry.get("loc") or []
|
|
53
|
+
location = ".".join(str(part) for part in loc if part != "body") if isinstance(loc, (list, tuple)) else ""
|
|
54
|
+
parts.append(f"{location}: {entry['msg']}" if location else str(entry["msg"]))
|
|
55
|
+
else:
|
|
56
|
+
parts.append(str(entry))
|
|
57
|
+
return "; ".join(parts)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _server_error_detail(exc: DataMasqueApiError) -> str | None:
|
|
61
|
+
"""Pull a human-readable error string from the IFM response body, if present.
|
|
62
|
+
|
|
63
|
+
The IFM service returns `{"error": "..."}`;
|
|
64
|
+
FastAPI validation errors come back as `{"detail": ...}`,
|
|
65
|
+
where `detail` is either a string or a list of Pydantic error dicts (422s).
|
|
66
|
+
Falls through to `None` if the body is missing or not parseable.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
body = exc.response.json()
|
|
70
|
+
except (ValueError, AttributeError):
|
|
71
|
+
return None
|
|
72
|
+
if isinstance(body, dict):
|
|
73
|
+
error = body.get("error")
|
|
74
|
+
if isinstance(error, str):
|
|
75
|
+
return error
|
|
76
|
+
if "detail" in body:
|
|
77
|
+
detail = body["detail"]
|
|
78
|
+
if isinstance(detail, str):
|
|
79
|
+
return detail
|
|
80
|
+
if isinstance(detail, list):
|
|
81
|
+
return _format_pydantic_errors(detail)
|
|
82
|
+
return str(detail)
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _abort_api_error(prefix: str, exc: DataMasqueApiError) -> NoReturn:
|
|
87
|
+
"""Map an `DataMasqueApiError` to the right `ErrorCode` and surface the body.
|
|
88
|
+
|
|
89
|
+
The default `str(exc)` only includes the HTTP status,
|
|
90
|
+
so the actual server message is hidden without this.
|
|
91
|
+
"""
|
|
92
|
+
status_code = getattr(exc.response, "status_code", None)
|
|
93
|
+
code = _STATUS_TO_ERROR_CODE.get(status_code, ErrorCode.ERROR) if isinstance(status_code, int) else ErrorCode.ERROR
|
|
94
|
+
detail = _server_error_detail(exc)
|
|
95
|
+
message = f"{prefix}: {detail}" if detail else f"{prefix}: {exc}"
|
|
96
|
+
abort(message, code=code)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class LogLevel(StrEnum):
|
|
100
|
+
DEBUG = "DEBUG"
|
|
101
|
+
INFO = "INFO"
|
|
102
|
+
WARNING = "WARNING"
|
|
103
|
+
ERROR = "ERROR"
|
|
104
|
+
CRITICAL = "CRITICAL"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _options_from_flags(
|
|
108
|
+
enabled: bool | None,
|
|
109
|
+
log_level: LogLevel | None,
|
|
110
|
+
) -> RulesetPlanOptions | None:
|
|
111
|
+
if enabled is None and log_level is None:
|
|
112
|
+
return None
|
|
113
|
+
return RulesetPlanOptions(enabled=enabled, default_log_level=log_level)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _load_mask_input(data: str) -> list[Any]:
|
|
117
|
+
if data == "-":
|
|
118
|
+
raw = sys.stdin.read()
|
|
119
|
+
else:
|
|
120
|
+
try:
|
|
121
|
+
raw = Path(data).read_text()
|
|
122
|
+
except OSError as exc:
|
|
123
|
+
code = ErrorCode.NOT_FOUND if isinstance(exc, FileNotFoundError) else ErrorCode.INVALID_INPUT
|
|
124
|
+
abort(f"Could not read mask input file '{data}': {exc.strerror or exc}", code=code)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
parsed = json.loads(raw)
|
|
128
|
+
except json.JSONDecodeError as exc:
|
|
129
|
+
abort(f"Failed to parse mask input as JSON: {exc}", code=ErrorCode.INVALID_INPUT)
|
|
130
|
+
|
|
131
|
+
if not isinstance(parsed, list):
|
|
132
|
+
abort("Mask input must be a JSON list (array) of records.", code=ErrorCode.INVALID_INPUT)
|
|
133
|
+
return parsed
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.command("list")
|
|
137
|
+
def list_plans(
|
|
138
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
139
|
+
is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
140
|
+
) -> None:
|
|
141
|
+
"""List all IFM ruleset plans."""
|
|
142
|
+
client = get_ifm_client(profile)
|
|
143
|
+
try:
|
|
144
|
+
plans = client.list_ruleset_plans()
|
|
145
|
+
except DataMasqueApiError as exc:
|
|
146
|
+
_abort_api_error("Failed to list IFM ruleset plans", exc)
|
|
147
|
+
|
|
148
|
+
data = [
|
|
149
|
+
{
|
|
150
|
+
"name": plan.name,
|
|
151
|
+
"serial": plan.serial,
|
|
152
|
+
"created": plan.created_time.isoformat(),
|
|
153
|
+
"modified": plan.modified_time.isoformat(),
|
|
154
|
+
"enabled": plan.options.enabled,
|
|
155
|
+
}
|
|
156
|
+
for plan in plans
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
render_output(
|
|
160
|
+
data,
|
|
161
|
+
is_json=is_json,
|
|
162
|
+
columns=["name", "serial", "created", "modified", "enabled"],
|
|
163
|
+
title="IFM ruleset plans",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@app.command("get")
|
|
168
|
+
def get_plan(
|
|
169
|
+
name: str = typer.Argument(help="Ruleset plan name"),
|
|
170
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
171
|
+
is_yaml: bool = typer.Option(False, "--yaml", help="Output the ruleset YAML only"),
|
|
172
|
+
is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Show an IFM ruleset plan's metadata or YAML."""
|
|
175
|
+
client = get_ifm_client(profile)
|
|
176
|
+
try:
|
|
177
|
+
plan = client.get_ruleset_plan(name)
|
|
178
|
+
except DataMasqueApiError as exc:
|
|
179
|
+
_abort_api_error(f"Failed to get IFM ruleset plan '{name}'", exc)
|
|
180
|
+
|
|
181
|
+
if is_yaml:
|
|
182
|
+
if plan.ruleset_yaml is None:
|
|
183
|
+
abort(f"IFM ruleset plan '{name}' has no ruleset YAML.")
|
|
184
|
+
typer.echo(plan.ruleset_yaml)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
data: dict[str, object] = {
|
|
188
|
+
"name": plan.name,
|
|
189
|
+
"serial": plan.serial,
|
|
190
|
+
"created": plan.created_time.isoformat(),
|
|
191
|
+
"modified": plan.modified_time.isoformat(),
|
|
192
|
+
"enabled": plan.options.enabled,
|
|
193
|
+
"default_log_level": plan.options.default_log_level,
|
|
194
|
+
"ruleset_yaml": plan.ruleset_yaml,
|
|
195
|
+
}
|
|
196
|
+
render_output(data, is_json=is_json, title=f"IFM plan: {name}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.command("create")
|
|
200
|
+
def create_plan(
|
|
201
|
+
name: str = typer.Option(..., "--name", help="Ruleset plan name (server may suffix a random string)"),
|
|
202
|
+
file: Path = typer.Option(..., "--file", "-f", help="Path to YAML ruleset file", exists=True, readable=True),
|
|
203
|
+
enabled: bool | None = typer.Option(
|
|
204
|
+
None,
|
|
205
|
+
"--enabled/--disabled",
|
|
206
|
+
help="Enable or disable the plan immediately. Defaults to the server default.",
|
|
207
|
+
),
|
|
208
|
+
log_level: LogLevel | None = typer.Option(
|
|
209
|
+
None,
|
|
210
|
+
"--log-level",
|
|
211
|
+
case_sensitive=False,
|
|
212
|
+
help="Default log level.",
|
|
213
|
+
),
|
|
214
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Create a new IFM ruleset plan from a YAML file."""
|
|
217
|
+
client = get_ifm_client(profile)
|
|
218
|
+
request = RulesetPlanCreateRequest(
|
|
219
|
+
name=name,
|
|
220
|
+
ruleset_yaml=file.read_text(),
|
|
221
|
+
options=_options_from_flags(enabled, log_level),
|
|
222
|
+
)
|
|
223
|
+
try:
|
|
224
|
+
created = client.create_ruleset_plan(request)
|
|
225
|
+
except DataMasqueApiError as exc:
|
|
226
|
+
_abort_api_error("Failed to create IFM ruleset plan", exc)
|
|
227
|
+
|
|
228
|
+
print_success(f"IFM ruleset plan '{created.name}' created (serial {created.serial}).")
|
|
229
|
+
if created.url:
|
|
230
|
+
typer.echo(created.url)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@app.command("update")
|
|
234
|
+
def update_plan(
|
|
235
|
+
name: str = typer.Argument(help="Existing ruleset plan name"),
|
|
236
|
+
file: Path | None = typer.Option(
|
|
237
|
+
None, "--file", "-f", help="Path to YAML ruleset file (optional)", exists=True, readable=True
|
|
238
|
+
),
|
|
239
|
+
enabled: bool | None = typer.Option(None, "--enabled/--disabled", help="Enable or disable the plan."),
|
|
240
|
+
log_level: LogLevel | None = typer.Option(None, "--log-level", case_sensitive=False, help="Default log level."),
|
|
241
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Update an IFM ruleset plan: only fields you pass are sent."""
|
|
244
|
+
if file is None and enabled is None and log_level is None:
|
|
245
|
+
abort(
|
|
246
|
+
"Pass at least one of --file, --enabled/--disabled, or --log-level.",
|
|
247
|
+
code=ErrorCode.INVALID_INPUT,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
client = get_ifm_client(profile)
|
|
251
|
+
request = RulesetPlanPartialUpdateRequest(
|
|
252
|
+
ruleset_yaml=file.read_text() if file is not None else None,
|
|
253
|
+
options=_options_from_flags(enabled, log_level),
|
|
254
|
+
)
|
|
255
|
+
try:
|
|
256
|
+
updated = client.patch_ruleset_plan(name, request)
|
|
257
|
+
except DataMasqueApiError as exc:
|
|
258
|
+
_abort_api_error(f"Failed to update IFM ruleset plan '{name}'", exc)
|
|
259
|
+
|
|
260
|
+
print_success(f"IFM ruleset plan '{name}' updated (serial {updated.serial}).")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@app.command("delete")
|
|
264
|
+
def delete_plan(
|
|
265
|
+
name: str = typer.Argument(help="Ruleset plan name to delete"),
|
|
266
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
267
|
+
is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Delete an IFM ruleset plan."""
|
|
270
|
+
if not is_confirmed:
|
|
271
|
+
typer.confirm(f"Delete IFM ruleset plan '{name}'?", abort=True)
|
|
272
|
+
|
|
273
|
+
client = get_ifm_client(profile)
|
|
274
|
+
try:
|
|
275
|
+
client.delete_ruleset_plan(name)
|
|
276
|
+
except DataMasqueApiError as exc:
|
|
277
|
+
_abort_api_error(f"Failed to delete IFM ruleset plan '{name}'", exc)
|
|
278
|
+
|
|
279
|
+
print_success(f"IFM ruleset plan '{name}' deleted.")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@app.command("mask")
|
|
283
|
+
def mask(
|
|
284
|
+
name: str = typer.Argument(help="Ruleset plan name to mask against"),
|
|
285
|
+
data: str = typer.Option(
|
|
286
|
+
...,
|
|
287
|
+
"--data",
|
|
288
|
+
"-d",
|
|
289
|
+
help="Path to a JSON file containing a list of records to mask, or '-' to read from stdin.",
|
|
290
|
+
),
|
|
291
|
+
disable_instance_secret: bool = typer.Option(
|
|
292
|
+
False, "--disable-instance-secret", help="Disable the per-instance secret for this run."
|
|
293
|
+
),
|
|
294
|
+
run_secret: str | None = typer.Option(None, "--run-secret", help="Override the run secret for this call."),
|
|
295
|
+
log_level: LogLevel | None = typer.Option(
|
|
296
|
+
None, "--log-level", case_sensitive=False, help="Override the plan's default log level."
|
|
297
|
+
),
|
|
298
|
+
request_id: str | None = typer.Option(None, "--request-id", help="Custom request id (echoed in the response)."),
|
|
299
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
300
|
+
is_json: bool = typer.Option(
|
|
301
|
+
True,
|
|
302
|
+
"--json/--no-json",
|
|
303
|
+
help="Output the masked records as a JSON array (default). Use --no-json for NDJSON (one record per line).",
|
|
304
|
+
),
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Run an IFM mask against a list of records."""
|
|
307
|
+
records = _load_mask_input(data)
|
|
308
|
+
client = get_ifm_client(profile)
|
|
309
|
+
request = IfmMaskRequest(
|
|
310
|
+
data=records,
|
|
311
|
+
disable_instance_secret=disable_instance_secret or None,
|
|
312
|
+
run_secret=run_secret,
|
|
313
|
+
log_level=log_level,
|
|
314
|
+
request_id=request_id,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
result = client.mask(name, request)
|
|
319
|
+
except DataMasqueApiError as exc:
|
|
320
|
+
_abort_api_error("Mask request failed", exc)
|
|
321
|
+
|
|
322
|
+
if not result.success:
|
|
323
|
+
print_error("Mask failed.")
|
|
324
|
+
for log in result.logs or []:
|
|
325
|
+
print_error(f" [{log.log_level}] {log.timestamp} {log.message}")
|
|
326
|
+
raise SystemExit(1)
|
|
327
|
+
|
|
328
|
+
if is_json:
|
|
329
|
+
print_json(result.data or [])
|
|
330
|
+
else:
|
|
331
|
+
for record in result.data or []:
|
|
332
|
+
typer.echo(json.dumps(record, default=str))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@app.command("verify-token")
|
|
336
|
+
def verify_token(
|
|
337
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
|
|
338
|
+
is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Verify the current IFM token and list its scopes."""
|
|
341
|
+
client = get_ifm_client(profile)
|
|
342
|
+
try:
|
|
343
|
+
info = client.verify_token()
|
|
344
|
+
except DataMasqueApiError as exc:
|
|
345
|
+
_abort_api_error("Failed to verify IFM token", exc)
|
|
346
|
+
if is_json:
|
|
347
|
+
print_json({"scopes": info.scopes})
|
|
348
|
+
return
|
|
349
|
+
render_output(
|
|
350
|
+
[{"scope": scope} for scope in info.scopes],
|
|
351
|
+
is_json=False,
|
|
352
|
+
columns=["scope"],
|
|
353
|
+
title="IFM token scopes",
|
|
354
|
+
)
|
|
@@ -22,6 +22,7 @@ from datamasque_cli.commands import (
|
|
|
22
22
|
connections,
|
|
23
23
|
discovery,
|
|
24
24
|
files,
|
|
25
|
+
ifm,
|
|
25
26
|
ruleset_libraries,
|
|
26
27
|
rulesets,
|
|
27
28
|
runs,
|
|
@@ -47,6 +48,7 @@ app.add_typer(seeds.app, name="seeds")
|
|
|
47
48
|
app.add_typer(files.app, name="files")
|
|
48
49
|
app.add_typer(system.app, name="system")
|
|
49
50
|
app.add_typer(ruleset_libraries.app, name="libraries")
|
|
51
|
+
app.add_typer(ifm.app, name="ifm")
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
@app.command()
|
|
@@ -20,6 +20,7 @@ from typing import Any, NoReturn
|
|
|
20
20
|
import typer
|
|
21
21
|
from rich.console import Console
|
|
22
22
|
from rich.table import Table
|
|
23
|
+
from rich.text import Text
|
|
23
24
|
from rich.theme import Theme
|
|
24
25
|
|
|
25
26
|
_DM_THEME = Theme(
|
|
@@ -124,6 +125,21 @@ def print_json(data: object) -> None:
|
|
|
124
125
|
typer.echo(json.dumps(data, indent=2, default=str))
|
|
125
126
|
|
|
126
127
|
|
|
128
|
+
def _cell(value: object) -> Text:
|
|
129
|
+
"""Coerce a cell value into a `Text` so Rich treats it literally.
|
|
130
|
+
|
|
131
|
+
Without this, square brackets in YAML inline lists (e.g. `path: [a, b]`)
|
|
132
|
+
are parsed by Rich as console markup tags and silently dropped from the
|
|
133
|
+
rendered cell. `Text` instances pass through unchanged so callers that
|
|
134
|
+
*want* styling (see `style_status`) still work.
|
|
135
|
+
"""
|
|
136
|
+
if value is None:
|
|
137
|
+
return Text("")
|
|
138
|
+
if isinstance(value, Text):
|
|
139
|
+
return value
|
|
140
|
+
return Text(str(value))
|
|
141
|
+
|
|
142
|
+
|
|
127
143
|
def print_table(
|
|
128
144
|
columns: list[str],
|
|
129
145
|
rows: list[list[Any]],
|
|
@@ -135,7 +151,7 @@ def print_table(
|
|
|
135
151
|
# rather than silently ellipsizing them, so IDs stay copyable in narrow terminals.
|
|
136
152
|
table.add_column(col, overflow="fold")
|
|
137
153
|
for row in rows:
|
|
138
|
-
table.add_row(*[
|
|
154
|
+
table.add_row(*[_cell(v) for v in row])
|
|
139
155
|
stdout_console.print(table)
|
|
140
156
|
|
|
141
157
|
|
|
@@ -145,7 +161,7 @@ def print_kv(data: dict[str, Any], title: str | None = None) -> None:
|
|
|
145
161
|
table.add_column("Key", style="bold")
|
|
146
162
|
table.add_column("Value", overflow="fold")
|
|
147
163
|
for key, value in data.items():
|
|
148
|
-
table.add_row(key,
|
|
164
|
+
table.add_row(key, _cell(value))
|
|
149
165
|
stdout_console.print(table)
|
|
150
166
|
|
|
151
167
|
|
|
@@ -171,10 +187,14 @@ def print_info(message: str) -> None:
|
|
|
171
187
|
console.print(f"[dim]{message}[/dim]")
|
|
172
188
|
|
|
173
189
|
|
|
174
|
-
def style_status(status: str) ->
|
|
175
|
-
"""Wrap a run status string in the appropriate colour tag.
|
|
176
|
-
|
|
177
|
-
|
|
190
|
+
def style_status(status: str) -> Text:
|
|
191
|
+
"""Wrap a run status string in the appropriate colour tag.
|
|
192
|
+
|
|
193
|
+
Returns a `Text` (not a markup string) so it passes through `print_table`
|
|
194
|
+
and `print_kv` unchanged. Returning a raw markup string would be re-escaped
|
|
195
|
+
by `_cell` and lose its colour.
|
|
196
|
+
"""
|
|
197
|
+
return Text(status, style=f"status.{status}")
|
|
178
198
|
|
|
179
199
|
|
|
180
200
|
def render_output(
|