Skip to content

Embeddable auth and saved queries, plus stricter URL key validation (0.7.0) - #27

Merged
igor-ctrl merged 7 commits into
mainfrom
feat/injected-token-auth
Aug 4, 2026
Merged

Embeddable auth and saved queries, plus stricter URL key validation (0.7.0)#27
igor-ctrl merged 7 commits into
mainfrom
feat/injected-token-auth

Conversation

@igor-ctrl

Copy link
Copy Markdown
Owner

Overview

Three things, all needed to let something other than the CLI drive the SDK, plus a
correctness fix found while reviewing that work.

The motivating consumer is a hosted service that authenticates a user itself and then
calls BC on their behalf. It couldn't use the SDK at all: every auth provider
acquires a token, and the interactive one needs a browser and a loopback listener on
whichever machine runs the process. And it couldn't run saved queries without
importing the Typer CLI.

Changes

StaticTokenAuth + AsyncBCClient(auth=...) — supply a token you already hold.
Accepts a string or a callable; a callable is re-invoked per request so a long-running
process picks up a refreshed token rather than pinning one that expires. Validates at
the boundary: an empty token and a Bearer prefix both raise rather than surfacing
as a confusing 401 three layers away. Injected auth short-circuits _build_auth, so
the profile's auth_method is never consulted — everything else still comes from the
profile, including the registry and the disable_standard_api gate.

bcli.queries — catalog loading, parameter validation against each parameter's
declared type/pattern/min/max/enum, and ${{ params.X }} resolution with
OData escaping, split across _catalog / _params / _expand / _errors.
query_cmd.py becomes a consumer and drops 291 lines. Validating parameters before
any HTTP is the property worth not duplicating, which is why this moved rather than
being reimplemented downstream. CLI output, errors and exit codes are unchanged.

build_url validates entity_set_name and record_id as single path components.
Both are spliced straight into the request path; a record key containing / starts a
new path segment, so

entity_set_name="engineOverviews"
record_id="1)/../../../../../../glEntries('X'"

composed a URL addressing glEntries('X') while the registry lookup and the
disable_standard_api gate had only ever seen engineOverviews — both key on the
entity-set name alone. BC applies the caller's own permission set regardless, so this
did not reach data a user couldn't otherwise read, but the client-side restriction
wasn't doing what it claimed. Now rejects raw /, \, ?, # and ./.., and
points at percent-encoding. Real keys are unaffected — GUIDs, integers, 'V00010',
'O''Brien', 'ACME (US)', k1='a',k2='b' all still pass, since none of them can
start a path segment.

The same validation applies to the key inside a bound-action invocation. That
resolver splices (key)/Namespace.Action onto a parent URL and consults the registry
about the parent only; its comment claimed the tail was "opaque to the registry ...
gated on the parent, which is the security-relevant identity", which holds only once
keys can't carry path syntax.

Also: Python 3.14 added to the classifiers (requires-python stays >=3.11 — a lower
floor installs fine on 3.14 and raising it would drop 3.11–3.13 users), and a test
that asserted against the developer's real ~/.config/bcli now isolates its config
dir, so it passes on a machine that has recorded a bcli error instead of only on a
clean CI home.

Test plan

  • uv run pytest1060 passed, 5 skipped (was 1034; +26 URL validation, +61 queries, and the pre-existing suite unchanged)
  • uv run ruff check src tests — clean
  • The reported traversal payload is rejected at the client level, and a legitimate GUID key still resolves
  • StaticTokenAuth verified end-to-end from a separate project's venv: a browser profile builds a transport with the injected provider, no browser opened, no loopback listener bound
  • CLI behaviour spot-checked unchanged after the query_cmd.py refactor
  • Publish 0.7.0 to PyPI after merge

…=...)

Every existing provider *acquires* a token. Some embedders already hold one and
only need the SDK to use it: a hosted service doing an on-behalf-of exchange per
request so BC calls run as the signed-in user, a CI job handed a token by its
platform, a notebook pasting one in. None of those can use the browser flow,
which opens a browser and binds a loopback listener on whichever machine runs
the process.

StaticTokenAuth takes a fixed token or a supplier. Prefer the supplier in a
long-running process: it is re-invoked on every call, so a refreshed token is
picked up rather than one pinned at construction expiring mid-session. Nothing
is cached, so clear_cache() is a no-op and the caller owns the token lifetime.

It validates at the boundary — an empty token and a "Bearer " prefix both raise
ConfigError rather than surfacing as a confusing 401 several layers away.

AsyncBCClient(auth=...) short-circuits _build_auth, so the profile's auth_method
is never consulted. Everything else still comes from the profile: environment,
company, registry, and the disable_standard_api gate. Credential acquisition
moves out; routing and its security properties do not.
test_no_context_policy_path asserted against the developer's actual
~/.config/bcli, so it failed on any machine that had ever recorded a bcli error
and passed only on a clean CI home.

It mimics `bcli ask --no-context` but omitted the skip_last_error=True that
flag actually passes (ask_cmd.py), and unlike every sibling test in the file it
did not isolate config_dir. Do both.
bcli already runs on 3.14 (the dev and tool environments are 3.14.3) but the
classifier list stopped at 3.13, so the metadata understated what is tested.

requires-python stays >=3.11 deliberately. This is a published package and most
production Python is still 3.11-3.13; raising the floor would exclude those users
for no benefit, since a lower floor installs cleanly on 3.14 anyway.
Saved-query handling lived entirely in bcli_cli/commands/query_cmd.py, so
nothing outside the CLI could run a saved query without importing Typer. An
embedder — a service, a notebook, another tool — had to reimplement catalog
loading, parameter validation and placeholder resolution, which is exactly the
code you least want duplicated: validating params against their declared
type/pattern/min/max/enum *before* any HTTP is what keeps caller input out of
OData filter syntax.

bcli.queries now owns that, split by concern: _catalog (load and index a YAML
catalog), _params (validate supplied params against the declared schema),
_expand (resolve ${{ params.X }} and escape values), _errors (a small typed
hierarchy). query_cmd.py becomes a consumer and loses 291 lines; observable CLI
behaviour, output and exit codes are unchanged.

QueryCatalogError, QueryError and QueryParamError are exported from the package
root so an embedder can distinguish a bad catalog from a bad parameter.

61 new tests.
…mponents

build_url validated the custom-API route segments but spliced entity_set_name
and record_id in raw. Both land directly in the URL path, and a record key is
caller-influenced anywhere one is accepted from outside, so a raw '/' in it
starts a new path segment:

    entity_set_name="engineOverviews"
    record_id="1)/../../../../../../glEntries('X'"
    → .../engineOverviews(1)/../../../../../../glEntries('X')

which collapses to glEntries('X'). Every earlier check saw only
"engineOverviews" — including the endpoint-registry lookup and the
disable_standard_api gate, both of which key on the entity-set name alone. So a
profile restricted to a curated registry could still address an entity outside
it. Business Central applies the caller's own permission set regardless, so this
does not reach data the user could not otherwise read, but the client-side
restriction was not doing what it claimed.

validate_record_key rejects raw '/', '\', '?' and '#' plus the '.'/'..'
segments, and points the caller at percent-encoding. Quotes, commas, equals
signs, hyphens and parentheses inside quoted strings ('ACME (US)', 'O''Brien',
composite k1='a',k2='b') all still pass — none of them can start a path
segment.

Applied in build_url and in _parse_bound_action, the latter because the
bound-action resolver splices (key)/Namespace.Action onto a parent URL it
resolved from the registry while consulting the registry about the parent only.
Its comment asserted the tail was "opaque to the registry ... gated on the
parent, which is the security-relevant identity"; that is true only once keys
cannot carry path syntax, which it now is.

26 new tests, including the reported payload and the legitimate key shapes.
CI runs 'uv sync --locked', which refuses to re-resolve. bcli is a member of its
own lockfile, so bumping the project version left uv.lock stale and every matrix
job failed in the install step before running a single test.
@igor-ctrl
igor-ctrl merged commit f9aa11c into main Aug 4, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant