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
84 changes: 84 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.8.0] - 2026-08-10

### Added

- `--out` on `bcli get`: write a record's media stream (a scanned invoice, a
rendered document) to a file instead of printing records. BC advertises a
streamable property as a `<field>@odata.mediaReadLink` annotation, so the
record is read first — without `$select`, which would drop the annotations —
and the single advertised link is streamed to disk. Several media properties
on one record, or none, is an error naming what was found; `--media <field>`
picks one explicitly, and also composes the conventional
`<record-url>/<field>` sub-resource for pages that serve a media property
without annotating it. The record read goes through the same resolver as any
other read, so the endpoint registry and `disable_standard_api` govern a media
download exactly as they govern `bcli get` — a media stream is not a side door
around the profile's allowlist. An existing file is refused without
`--overwrite` and a missing parent directory is an error, both checked before
any request goes out.

The SDK half is `AsyncBCClient.get_media` / `BCClient.get_media` over a new
`BCTransport.download`, which streams rather than buffering and never parses
the body as JSON. `download` is a deliberate sibling of the shared `_request`
loop rather than a branch inside it, because that method's success path calls
`response.json()` — wrong for every byte of a PDF. It re-validates the URL
against the BC host allowlist first, since a `mediaReadLink` is a URL the
*server* chose and the bearer token must not follow it off-origin. Bytes land
in a `.part` sibling that is truncated at the start of every attempt and moved
onto the destination with `os.replace` only on success: a retry after a
half-streamed response would otherwise append the second body to the first
half of the first, and a failed download would leave a truncated file where a
complete one is expected. Nothing is left behind on any failure path. The
downloaded file inherits the temp file's `0600` mode rather than the umask,
on the grounds that a downloaded invoice is the account's data.

- `--out` on `bcli action`: decode a bound action's base64 return value and
write the raw bytes. Distinct from `--result-out`, which writes the JSON
result envelope *about* the invocation — the two compose, and the help text
says so, because an agent reaching for "write the output to a file" can
otherwise pick either. The destination is vetted before the POST is sent: an
action can change BC, and discovering an unwritable path afterwards would
leave the mutation applied with its payload nowhere to go. A 204 No Content
response reports that there was no payload rather than writing a zero-byte
file, which would be indistinguishable from a successful download. Decoding
happens before the success envelope is emitted, so a payload that can't be
decoded is recorded as failed.

- `bcli.odata` now exports `extract_field_references`, `suggest_field` and
`validate_filter_fields`; `bcli.registry` now exports `import_from_metadata`.
Downstream tooling validating a saved-query catalog against live endpoint
fields needs both, and was otherwise importing `bcli.odata._filter_fields` and
`bcli.registry._importers` directly — private modules we could not have
changed without breaking those consumers silently. Same reasoning as the
`bcli.queries` extraction in 0.7.0.

### Security

Three hardenings from a security review of the `--out` media path, before it
ships:

- **The BC-origin guard now requires `https`.** `is_bc_origin` (and the ETL
layer's inline copy) previously accepted `http` for an allowlisted host, so a
tampered `@odata.mediaReadLink` or `@odata.nextLink` of
`http://api.businesscentral.dynamics.com/…` would have received the bearer
token over cleartext. A credential-bearing request must never be plaintext;
BC only ever serves `https`. This tightens every caller of the guard — the
media download and the paginator alike — not just `--out`.
- **`--media` can no longer smuggle percent-encoded path separators.**
`validate_record_key` rejects raw `/ \ ? #` but accepted percent escapes, so a
field like `..%2F..%2Fapi%2Fv2.0%2F…` could splice encoded traversal into the
token-bearing fallback URL for any gateway that decodes `%2F` before routing.
The field is now percent-encoded as a single path component at construction
(`%2F` becomes `%252F`), so no decodable separator survives. (`disable_standard_api`
was never the real boundary — the BC permission set is — but the client should
not construct a URL it did not intend.)
- **`--out`'s no-overwrite promise is enforced at the commit, not only at
pre-flight.** The existence check and the `os.replace` publish resolved the
parent path twice, so a local attacker who swapped the output directory for a
symlink in between could overwrite a same-named victim file without
`--overwrite`. When overwrite is not requested, the download and the decoded
action payload now publish with a no-replace primitive (`os.link`) that fails
if the destination appeared after the check. The SDK's `get_media` /
`download` keep `overwrite=True` as their default for direct callers; only the
CLI opts into the strict path.

## [0.7.0] - 2026-08-04

### Added
Expand Down
55 changes: 55 additions & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,65 @@ bcli get <endpoint> [<record-id>] [options]
| `--skip <n>` | Records to skip |
| `--count` | Include total record count |
| `--all` | Follow pagination for all records |
| `--out <path>` | Write the record's media stream (raw bytes) to this path instead of printing records. Requires a record id |
| `--media <field>` | Media property to download (default: auto-discovered from the record's `@odata.mediaReadLink` annotations). Requires `--out` |
| `--overwrite` | Replace an existing `--out` file (refused by default) |
| `--publisher <name>` | Custom API publisher override |
| `--group <name>` | Custom API group override |
| `--version <name>` | Custom API version override |

Downloading a record's attachment:

```bash
# Find the record, then stream its media property to a file.
bcli get incomingDocuments --filter "description eq 'March invoice'" --top 1 -f json
bcli get incomingDocuments <systemId> --out invoice.pdf
```

`--out` is single-record mode and cannot be combined with the query-shaping
flags (`--filter`, `--select`, `--expand`, `--orderby`, `--top`, `--skip`,
`--count`, `--all`) or an explicit `--format`. The record is fetched through
normal endpoint resolution, so `disable_standard_api` and the endpoint registry
apply to a media download exactly as they do to a read. See
[querying.md](querying.md#downloading-media-streams-pdfs).

---

## action

Invoke an OData v4 bound action on a record: `POST <entitySet>(<key>)/<Namespace>.<action>`.

```bash
bcli action <endpoint> <record-key> <action-name> [options]
```

| Option | Description |
|--------|-------------|
| `--data <json>` | JSON body for the action (literal or `@file`). Defaults to an empty body |
| `--no-data` | Explicitly send an empty body — same as omitting `--data` |
| `--namespace <ns>` | Action namespace (default: `Microsoft.NAV`) |
| `--out <path>` | Decode the action's base64 return value and write the raw bytes here |
| `--overwrite` | Replace an existing `--out` file (refused by default) |
| `--result-out <path>` | Write the JSON result envelope to this path (atomic) |
| `--result-fd <n>` | Write the JSON result envelope to this file descriptor |
| `--idempotency-key <k>` | Opaque token forwarded as the `Idempotency-Key` header |
| `--yes` | Skip the read-only-profile warning prompt |

```bash
bcli action examples 42 archive
bcli action documents 42 renderPdf --out document.pdf
```

**`--out` and `--result-out` write different things.** `--out` writes the
action's *decoded payload bytes* — the PDF or export the action returned as
base64 in its `value` property. `--result-out` writes the *JSON result
envelope* describing the invocation (status, exit code, correlation id). They
compose; neither implies the other.

The destination is checked before the POST is sent, so an unwritable path fails
without invoking the action. An action that returns 204 No Content has no
payload to write, and `--out` reports that rather than creating an empty file.

---

## post
Expand Down
38 changes: 38 additions & 0 deletions docs/querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,44 @@ bcli -f json -q get customers --top 100 | jq '.[] | select(.city == "Chicago") |
bcli -f csv -q get items --select number,displayName,unitPrice --all > items.csv
```

## Downloading Media Streams (PDFs)

Some records carry a binary attachment — a scanned invoice, a rendered
document, an image. BC advertises those as `<field>@odata.mediaReadLink`
annotations on the record, and `--out` streams the bytes to a file instead of
printing records.

It takes two steps, because a media download addresses exactly one record:

```bash
# 1. Find the record's systemId.
bcli get incomingDocuments --filter "description eq 'March invoice'" --top 1 -f json

# 2. Download its media stream.
bcli get incomingDocuments <systemId> --out invoice.pdf
# ✓ Wrote 48,215 bytes to invoice.pdf (application/pdf, media field: content)
```

With no `--media`, the media property is auto-discovered from the record's
annotations. If the record exposes several, bcli lists them and asks you to
pick one rather than guessing:

```bash
bcli get incomingDocuments <systemId> --media attachmentContent --out invoice.pdf
```

Notes:

- An existing file is never replaced without `--overwrite`, and a missing
parent directory is an error rather than an `mkdir`.
- The record is fetched through the normal endpoint resolution, so a profile
with `disable_standard_api = true` refuses a media download from an
unregistered entity exactly as it refuses a read.
- `--out` is single-record mode: it can't be combined with `--filter`,
`--select`, `--top`, `--all` and friends. Use step 1 for those.
- For a bound action that *returns* a base64 payload, the equivalent flag is
`bcli action ... --out` (see the command reference).

## Context Banner

By default, bcli shows the active profile, environment, and company before output:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ build-backend = "hatchling.build"
# installed CLI binary (`bcli`) are unaffected — only `pip install` /
# `uv tool install` use this name.
name = "bc-cli"
version = "0.7.0"
version = "0.8.0"
description = "Python SDK and CLI for Microsoft Dynamics 365 Business Central APIs"
readme = "README.md"
license = "Apache-2.0"
Expand Down
5 changes: 4 additions & 1 deletion src/bcli/_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,10 @@ def is_bc_origin(url: str) -> bool:
if not parsed.scheme:
# Relative URL — caller will resolve it against the BC base URL.
return True
if parsed.scheme not in ("http", "https"):
if parsed.scheme != "https":
# A bearer token must never ride a cleartext request. BC always serves
# https, so an absolute http:// URL is tampering or misconfiguration —
# refuse it before the token is attached.
return False
host = (parsed.hostname or "").lower()
if not host:
Expand Down
118 changes: 118 additions & 0 deletions src/bcli/client/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import re
from pathlib import Path
from typing import Any
from urllib.parse import quote

from bcli._url import build_companies_url, build_url, validate_record_key
from bcli.auth._base import AuthProvider
Expand Down Expand Up @@ -47,6 +48,13 @@
r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$"
)

# BC advertises a streamable media property on a record as an OData annotation
# keyed ``<propertyName>@odata.mediaReadLink``. The value is an absolute URL to
# the raw bytes — the only place the download link is published, which is why
# ``get_media`` reads the record without ``$select`` (a projection drops
# annotations along with the fields it filters out).
_MEDIA_READ_LINK_RE = re.compile(r"^(?P<field>.+)@odata\.mediaReadLink$")


def _parse_bound_action(entity_set_name: str) -> tuple[str, str, str] | None:
"""Recognise a bound-action invocation in ``entity_set_name``.
Expand Down Expand Up @@ -454,6 +462,116 @@ async def upload_attachment(
"record": bc_record or None,
}

async def get_media(
self,
entity_set_name: str,
record_id: str,
dest: str | Path,
*,
media_field: str | None = None,
publisher: str | None = None,
group: str | None = None,
version: str | None = None,
overwrite: bool = True,
) -> dict[str, Any]:
"""Download a record's media stream (PDF, image, blob) to ``dest``.

The download counterpart of :meth:`upload_attachment`, and the one read
whose payload never reaches stdout: BC hands back raw bytes, so they go
straight to a file.

Two requests. First the record itself, resolved through the same
``_resolve_url`` every other read uses — so registry routing, an
explicit ``publisher``/``group``/``version`` override and the
``disable_standard_api`` lockdown all apply here exactly as they do to
``get``. A media download is not a side door around the profile's
endpoint allowlist. Then the media link itself, streamed to disk by
:meth:`BCTransport.download` (which re-checks the origin, because that
URL came from the response body).

Field resolution:

- ``media_field`` names the property explicitly. Its
``@odata.mediaReadLink`` is used when the record carries one;
otherwise the conventional ``<record-url>/<field>`` sub-resource is
composed, which is what BC pages that omit the annotation still
serve.
- Otherwise the record's annotations are scanned. Exactly one media
property is downloaded; zero or several raise, because guessing
would quietly write the wrong stream to the caller's file.

Returns ``{"path", "bytes_written", "media_field", "content_type",
"media_fields_discovered"}``.

Read-only. Does not go through SafeContext.
"""
transport = self._ensure_transport()

record_url = self._resolve_url(
entity_set_name,
record_id=record_id,
publisher=publisher,
group=group,
version=version,
)

# No $select: a projection drops the @odata.mediaReadLink annotations,
# which are the only thing this read is after.
record = await transport.get(record_url)

discovered = [
m.group("field")
for m in (_MEDIA_READ_LINK_RE.match(key) for key in record)
if m is not None
]

if media_field is not None:
# The field is spliced into a URL path when the record carries no
# annotation for it, so it gets the same single-path-component
# validation as a record key.
validate_record_key("media_field", media_field)
field = media_field
link = record.get(f"{media_field}@odata.mediaReadLink")
if not isinstance(link, str) or not link:
# Percent-encode the field as a single path component.
# validate_record_key blocks *raw* separators but accepts percent
# escapes, so '..%2F..%2Fapi%2Fv2.0%2F...' would otherwise splice
# encoded traversal into the token-bearing URL for any server that
# decodes %2F before routing. quote(safe="") turns %2F into %252F.
link = f"{record_url}/{quote(media_field, safe='')}"
elif len(discovered) == 1:
field = discovered[0]
link = record[f"{field}@odata.mediaReadLink"]
elif not discovered:
raise BCLIError(
f"No media stream on {entity_set_name}({record_id}): the record carries "
f"no '@odata.mediaReadLink' annotation, so there is nothing to download. "
f"Pass --media <field> if you know the property name, and run "
f"'bcli endpoint fields {entity_set_name}' to see what this endpoint exposes."
)
else:
candidates = ", ".join(sorted(discovered))
raise BCLIError(
f"{entity_set_name}({record_id}) exposes {len(discovered)} media "
f"properties: {candidates}. Pass --media <field> to pick one — writing "
f"whichever came first would be a silent guess."
)

dest_path = Path(dest).expanduser()
outcome = await transport.download(
link, dest_path,
log_context={"endpoint": entity_set_name},
overwrite=overwrite,
)

return {
"path": str(dest_path),
"bytes_written": outcome["bytes_written"],
"media_field": field,
"content_type": outcome["content_type"],
"media_fields_discovered": discovered,
}

async def list_companies(self) -> list[dict[str, Any]]:
"""Discover all companies in the current environment."""
transport = self._ensure_transport()
Expand Down
11 changes: 11 additions & 0 deletions src/bcli/client/_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@ def upload_attachment(
self._async.upload_attachment(parent_type, parent_id, file_path, **kwargs)
)

def get_media(
self,
entity_set_name: str,
record_id: str,
dest: str | Path,
**kwargs,
) -> dict[str, Any]:
return self._run(
self._async.get_media(entity_set_name, record_id, dest, **kwargs)
)

def list_companies(self) -> list[dict[str, Any]]:
return self._run(self._async.list_companies())

Expand Down
Loading
Loading