Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
# Changelog

## v1.2.0

### Added
- `dm ifm` command group
for managing in-flight masking ruleset plans
and running mask operations against the IFM service:
- `dm ifm list` —
list all IFM ruleset plans.
- `dm ifm get <name>` —
show plan metadata,
or the ruleset YAML with `--yaml`.
- `dm ifm create --name <name> --file <yaml>` —
create a plan from a YAML ruleset,
with optional `--enabled/--disabled` and `--log-level`.
- `dm ifm update <name>` —
update a plan;
pass any of `--file`, `--enabled/--disabled`, `--log-level`
and only those fields are sent.
- `dm ifm delete <name>` —
delete a plan
(interactive confirm,
or `--yes` to skip).
- `dm ifm mask <name> --data <file|->` —
mask a JSON list of records against a plan,
with `--disable-instance-secret`,
`--run-secret`,
`--log-level`,
`--request-id`,
and `--json/--no-json` (NDJSON) output.
- `dm ifm verify-token` —
verify the current IFM token and list its scopes.

Authentication reuses your existing `dm` profile credentials
via the SDK's `DataMasqueIfmClient`,
which transparently exchanges admin-server credentials for an IFM JWT.

## v1.1.0

### Added
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ so teams can use production-shaped data in non-production environments without e
DataMasque CLI `dm` covers:

- connections, rulesets, ruleset libraries, and masking runs
- in-flight masking (IFM) ruleset plans and on-demand mask requests
- schema discovery and sensitive-data discovery
- users, files, and DataMasque instance administration

Expand Down Expand Up @@ -166,6 +167,26 @@ dm libraries validate <name> # Re-validate against current
dm libraries usage <name> # Show rulesets using it
```

### In-flight masking

The IFM service runs alongside the admin server,
reached at `<DataMasque URL>/ifm` via the standard nginx topology.

```console
dm ifm list # List ruleset plans
dm ifm get <name> # Show plan metadata
dm ifm get <name> --yaml # Print the ruleset YAML
dm ifm create --name myplan --file rules.yaml # Create (server suffixes a random string to the name)
dm ifm create --name myplan --file rules.yaml --disabled --log-level DEBUG
dm ifm update <name> --file rules.yaml # Replace the ruleset YAML
dm ifm update <name> --enabled # Toggle without re-sending the YAML
dm ifm update <name> --log-level INFO
dm ifm delete <name> --yes # Delete a plan
dm ifm mask <name> --data input.json # Mask a JSON list of records
dm ifm mask <name> --data - # Read records from stdin
dm ifm verify-token # Show scopes granted to the current IFM token
```

### Masking runs

```console
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "datamasque-cli"
version = "1.1.0"
version = "1.2.0"
description = "Official command-line interface for the DataMasque data-masking platform."
authors = [
{ name = "DataMasque Ltd" },
Expand Down
83 changes: 62 additions & 21 deletions src/datamasque_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@

import os

from datamasque.client import DataMasqueClient
from datamasque.client.exceptions import DataMasqueApiError, DataMasqueTransportError
from datamasque.client import DataMasqueClient, DataMasqueIfmClient
from datamasque.client.exceptions import DataMasqueApiError, DataMasqueTransportError, IfmAuthError
from datamasque.client.models.dm_instance import DataMasqueInstanceConfig
from datamasque.client.models.ifm import DataMasqueIfmInstanceConfig

from datamasque_cli.config import Config, Profile, load_config
from datamasque_cli.output import ErrorCode, abort
Expand Down Expand Up @@ -59,25 +60,45 @@ def _resolve_profile(config: Config, profile_name: str | None) -> Profile:
return profile


def _resolve_profile_with_verify(profile_name: str | None) -> tuple[Profile, bool]:
"""Resolve the active `Profile` and apply env-var overrides for `verify_ssl`."""
env_profile = profile_from_env() if profile_name is None else None
if env_profile is not None:
profile = env_profile
else:
config = load_config()
profile = _resolve_profile(config, profile_name)
return profile, _verify_ssl_from_env(default=profile.verify_ssl)


def _authenticate_or_abort(
client: DataMasqueClient | DataMasqueIfmClient,
url: str,
*,
verify_ssl: bool,
failure_label: str = "Authentication",
extra_auth_excs: tuple[type[Exception], ...] = (),
) -> None:
try:
client.authenticate()
except DataMasqueTransportError as e:
abort(_format_transport_error(url, e, verify_ssl=verify_ssl), code=ErrorCode.TRANSPORT_ERROR)
except (DataMasqueApiError, *extra_auth_excs) as e:
abort(f"{failure_label} failed: {e}", code=ErrorCode.AUTH_FAILED)


def get_client(profile_name: str | None = None) -> DataMasqueClient:
"""Build and authenticate a `DataMasqueClient`.

Credential resolution order:
1. Environment variables (DATAMASQUE_URL, DATAMASQUE_USERNAME, DATAMASQUE_PASSWORD)
2. Named profile (--profile flag)
3. Active profile from config file
"""
# Env vars take precedence unless a specific profile was requested.
env_profile = profile_from_env() if profile_name is None else None
if env_profile is not None:
profile = env_profile
else:
config = load_config()
profile = _resolve_profile(config, profile_name)

# `DATAMASQUE_VERIFY_SSL` always wins over the stored profile so you can
# flip TLS verification per-call without re-running `dm auth login`.
verify_ssl = _verify_ssl_from_env(default=profile.verify_ssl)
`DATAMASQUE_VERIFY_SSL` always wins over the stored profile so you can
flip TLS verification per-call without re-running `dm auth login`.
"""
profile, verify_ssl = _resolve_profile_with_verify(profile_name)
instance_config = DataMasqueInstanceConfig(
base_url=profile.url,
username=profile.username,
Expand All @@ -86,14 +107,7 @@ def get_client(profile_name: str | None = None) -> DataMasqueClient:
)

client = DataMasqueClient(instance_config)

try:
client.authenticate()
except DataMasqueTransportError as e:
abort(_format_transport_error(profile.url, e, verify_ssl=verify_ssl), code=ErrorCode.TRANSPORT_ERROR)
except DataMasqueApiError as e:
abort(f"Authentication failed: {e}", code=ErrorCode.AUTH_FAILED)

_authenticate_or_abort(client, profile.url, verify_ssl=verify_ssl)
return client


Expand All @@ -107,3 +121,30 @@ def _format_transport_error(url: str, error: Exception, *, verify_ssl: bool) ->
if verify_ssl and any(term in str(error).lower() for term in _SSL_HINT_TERMS):
message += "\nIf this is a self-signed local build, retry with --insecure or set DATAMASQUE_VERIFY_SSL=false."
return message


def get_ifm_client(profile_name: str | None = None) -> DataMasqueIfmClient:
"""Build and authenticate a `DataMasqueIfmClient`.

Credential resolution order matches `get_client`.
The IFM base URL is derived as `<admin_url>/ifm`,
matching the standard nginx topology that proxies `/ifm/` to the IFM container on the same hostname.
"""
profile, verify_ssl = _resolve_profile_with_verify(profile_name)
instance_config = DataMasqueIfmInstanceConfig(
admin_server_base_url=profile.url,
ifm_base_url=f"{profile.url.rstrip('/')}/ifm",
username=profile.username,
password=profile.password,
verify_ssl=verify_ssl,
)

client = DataMasqueIfmClient(instance_config)
_authenticate_or_abort(
client,
profile.url,
verify_ssl=verify_ssl,
failure_label="IFM authentication",
extra_auth_excs=(IfmAuthError,),
)
return client
Loading
Loading