Skip to content

Stop logging the API key, and apply the log level and label settings - #137

Draft
zeevmoney wants to merge 23 commits into
per-16676/release-ci-hardeningfrom
per-16680/stop-logging-api-key
Draft

zeevmoney wants to merge 23 commits into
per-16676/release-ci-hardeningfrom
per-16680/stop-logging-api-key

Conversation

@zeevmoney

Copy link
Copy Markdown
Member

Linear issues

  • PER-16680: With logging enabled, the SDK wrote its API key to the log, and ignored the level, label and json log settings.

Why

With log={"enable": True}, creating a client (async Permit or permit.sync.Permit) logged json.dumps(config.dict()) at DEBUG. That dump includes token, and loguru's default stderr sink prints DEBUG, so the API key reached the log whatever log.level said. configure_logger() read only log.enable; level, label and json had no effect.

What changed

  • The config dump is gone. Creating a client logs Permit SDK initialized: api_url=..., pdp=... at DEBUG.
  • Every SDK log call goes through a new internal wrapper, permit/utils/sdk_logger.py. It drops records below log.level, replaces with [REDACTED] the API key of every client created in the process (longest key first, and the key without surrounding whitespace too), and puts [label] before the message. It logs with logger.opt(depth=2), so loguru still credits each record to the SDK module that logged it. The SDK adds no loguru sink and never calls logger.remove().
  • read_error_body() redacts the PDP error body it returns. The body goes into both the SDK's error record and the PermitConnectionError raised by check, bulk_check and authorized_users.
  • PermitConfig.token is declared with repr=False, so repr()/str() of a config, and tracebacks that print frame values (loguru's diagnose=True), do not show it.
  • log.enable False calls logger.disable("permit"), as before. True calls logger.enable("permit") only to undo that call from an earlier client.
  • wait_for_sync() yields a copy of the client whose PDP and API clients are rebuilt from the new config, through a _connect() hook the blocking client overrides. It no longer creates a new client, so it does not re-apply log settings.
  • A ruff banned-api rule (TID251) flags loguru.logger anywhere in permit/ except the wrapper and permit/logger.py.
  • The README gains a Logging section. The LoggerConfig docstring, its field descriptions and the configure_logger docstring describe the options as they now behave.

Behaviour changes

This is a bug and security fix inside 3.x, not an API break. It changes what users who enabled logs see.

  • With log.enable True, creating a client no longer logs its config. 3.0.0 and earlier wrote the API key to the log at any configured level whenever log.enable was True. Users who ran that way should rotate those keys and purge those logs.
  • Whatever the settings, the API key of every client in the process, and the same key without surrounding whitespace, is replaced with [REDACTED] in every message the SDK logs.
  • A PermitConnectionError for a non-200 PDP response from check, bulk_check or authorized_users quotes the response body with the key replaced by [REDACTED].
  • repr() and str() of PermitConfig no longer show token. The field, .dict() and .json() are unchanged.
  • log.level now applies when log.enable is True: SDK records below it are dropped before any sink, and the application's own records and sinks are not affected. With the default "info", the SDK's DEBUG records no longer appear: HTTP request/response lines, check/authorized_users/get_user_permissions bodies, API context changes and the init line. Set "debug" to keep them. Accepted names are loguru's built-in levels in any case, the aliases warn and fatal, and levels the application added with logger.level(), matched by their exact name (or in any case if the application named the level in upper case). For an unknown name the SDK logs a warning that names it and uses INFO; before, any string was accepted and ignored.
  • log.label now applies when log.enable is True: SDK messages start with [Permit] by default, or [<label>] . An empty label adds nothing.
  • A client created with log.enable True after one created with logging disabled now logs; before, it stayed silent. The SDK calls logger.enable("permit") only in that case, so a logger.disable() the application made for permit or one of its modules still applies. When the SDK does undo its own disable, loguru also drops any permit.* disable made after that earlier client.
  • The settings are process-wide, as loguru's logger is: the client created last decides whether the SDK logs, and the last one created with log.enable True sets the level and label.
  • wait_for_sync() with proxy_facts_via_pdp yields a copy of the client (same class, own PDP and API clients) instead of a new client. It no longer re-applies log settings or logs the init line.
  • log.log_as_json still has no effect. It is now documented as not applied, with the recipe: logger.remove(), then logger.add(sys.stderr, serialize=True).
  • Unchanged: log.enable False (the default) calls logger.disable("permit"), prints nothing, and does not read level, label or json. An application that calls logger.enable("permit") itself still gets the SDK's records, now without the key.

Known limitations, not changed here:

  • A user name and password written into the api_url or pdp URL are not redacted. The SDK's request lines logged them at DEBUG before this change too.
  • Only PDP error bodies are redacted in exceptions. Errors built from REST API responses are not scrubbed.
  • Two different keys that overlap at different positions in one string can leave a few characters of one of them.
  • Redaction cost per log call grows with the number of distinct keys in the process: 0.6 us at 10 keys and 6.5 us at 100, on a 329-character check() message.

How it was tested

  • tests/test_fix_logging.py: 53 offline tests. pytest_httpserver serves the PDP and REST API. Each test adds and removes its own sinks: a stderr sink in loguru's default format, read through capsys, and a JSON sink, both at DEBUG. An autouse fixture restores the SDK logger and loguru's permit switches. The tests cover:
    • the async and sync clients with level debug, info and unset, json on and off, through DEBUG, WARNING and ERROR records: no sentinel token and each record printed once;
    • a PDP that echoes the key back, in records and in raised errors (all three PDP paths, async and sync);
    • prefix keys, a trailing-space key, and empty or whitespace-only keys;
    • a logged traceback of a failed client creation with diagnose=True;
    • level filtering, unknown and app-added levels, label, and module attribution;
    • logging disabled;
    • the application's own disables surviving;
    • wait_for_sync() keeping log settings and sending X-Wait-Timeout/X-Timeout-Policy;
    • 40 clients adding no sinks.
  • pytest -m "not e2e": 361 passed, 3 skipped on both the pydantic 2 and pydantic 1 lanes, warnings as errors, none raised (base: 305 passed, 3 skipped). tests/test_typing_surface.py: 3 passed on both lanes; the sync stub is unchanged.
  • Mutation checks: each mutation was applied, the tests run, and the code restored. All were caught:
    • config dump restored as at the base: 25 tests fail, including every token test;
    • level check removed: 16 fail;
    • redaction removed: 5 fail;
    • label removed: 6 fail;
    • records credited to the wrapper (depth=0): 4 fail;
    • logger.disable dropped: 11 fail;
    • SDK-owned JSON sink: 9 fail;
    • a sink per client: 1 fails;
    • logger.remove(): 39 fail, 47 errors;
    • also caught: aliases removed, silent fallback on an unknown level, token registered only when enabled, repr=False removed, insertion-order and shortest-first redaction, untrimmed key only, scrub removed from error bodies, always/never logger.enable, the old wait_for_sync construction, a missing _connect, and a direct loguru import (flagged by ruff TID251).
  • pre-commit (ruff ALL, ruff format, strict mypy, typos, uv lock --check) passes. mypy is clean on both lanes. actionlint is clean. zizmor reports no findings.
  • e2e tests need a backend and keys: they were type-checked and collected (21 tests on both lanes) and run in CI.

Owner actions before merge

  • Let CI run the e2e and compatibility lanes.
  • log_as_json stays unapplied and documented for now; what it should do is decided separately.
  • Release notes: state the key exposure in 3.0.0 and earlier, advise affected users to rotate keys and purge logs, and list the behaviour changes above.
  • Update the docs repo (separate PR).

🤖 Generated with Claude Code

zeevmoney and others added 18 commits October 1, 2026 12:50
With log={"enable": True}, Permit() logged its whole config at DEBUG,
token included, and the SDK did not apply log.level, so the API key
reached the log whatever level was set (PER-16680). The line now names
the API and PDP URLs only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK's own log lines no longer hold the key, but some records carry
text from elsewhere: a PDP error body, an aiohttp error, a context the
caller passed to a check. The SDK's records now go through
permit.utils.sdk_logger, which replaces the API key of every client
created in the process with [REDACTED] before handing the record to
loguru, whether or not that client enabled logging (PER-16680).

Records are still attributed to the SDK module that logged them, so
logger.disable("permit") and the application's sinks see them as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LoggerConfig's level and label had no effect (PER-16680): loguru's
default sink printed every SDK record from DEBUG up, unlabelled. The
SDK still adds no sink and leaves the application's sinks and levels
alone. Instead, with log.enable True:

- level drops the SDK's records below it before they reach any sink.
  Names are loguru's, in any case, plus warn and fatal. An unknown
  name raises ValueError when the client is created.
- label is put in square brackets before every SDK message.
- logger.enable("permit") is called, so a client that enables logging
  after one that disabled it is no longer silenced.

log_as_json stays unapplied: loguru serializes per sink, and a JSON
sink of the SDK's own would print every record a second time through
loguru's default sink or the application's.

The settings are process-wide, as loguru's logger is. With log.enable
False nothing changes: the SDK calls logger.disable("permit") and
reads no other setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LoggerConfig's descriptions said each option set something on "the
Permit SDK Logger", which did not exist. They and a new Logging section
in the README now say where the SDK's records go, what enable, level
and label do, that json is not applied and how to get JSON from a
loguru sink, that the settings are process-wide, and that no record
holds the API key (PER-16680).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Offline tests for PER-16680, with loguru sinks added the way an
application adds them: a stderr sink in loguru's default format and a
JSON sink, both at DEBUG. For the async and the blocking client, at
level debug, info and unset, with json on and off, they go through
every kind of record the SDK logs and find neither the API key nor a
[REDACTED] marker. They also check that a key a PDP echoes back is
redacted, that level drops the SDK's records and no others, that label
prefixes every SDK message, that json leaves the format to the sinks,
that a disabled client logs nothing, that the client created last
decides, and that creating 40 clients adds no sink.

Each test starts from a fresh SdkLogger and puts loguru's permit switch
back the way it found it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
loguru's level names are case-sensitive, and log.level was looked up
only in upper case, so a level such as logger.level("audit", no=35)
raised ValueError although the error message offers levels added with
logger.level(). Try the name as given first, then the upper-case name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unknown log level now raises ValueError while the client is created.
An application that logs that failure with loguru's logger.exception
(diagnose=True by default) printed the config's repr from the SDK's
frames, and with it the API key. repr=False keeps the token out of
repr() and str() of the config; the field itself is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The keys were kept in a frozenset and replaced in its iteration order,
which depends on the hash seed. When a key that is a prefix of another
was replaced first, the rest of the longer key stayed in the record.
Keep the keys in a tuple sorted longest first, and move the replacement
into SdkLogger.scrub().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A key read from a file or an environment variable may end with a space.
aiohttp sends it, and a PDP that echoes the key back returns it trimmed,
so the exact-match replacement missed it. Register the trimmed key as
well, and ignore a key that is only whitespace, which would otherwise
replace every space in the SDK's messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK replaced a key the PDP echoed back in its own error record, then
raised PermitConnectionError with the same body unchanged, so an
application that logged or reported the exception wrote the key. Scrub
the body where it is read, which covers check, bulk_check and
authorized_users, and say in the docs what is and is not replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every client created with log.enable True called logger.enable("permit").
In loguru that also drops every disable the application set for a
module of the package, and overrides an application-wide
logger.disable(""). Now the SDK calls logger.enable("permit") only to
undo a logger.disable("permit") that an earlier client made, which keeps
the fix for an enabled client created after a disabled one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wait_for_sync() created a new client from the config, which ran
configure_logger again on every call. That turned SDK logging back on
after a client created later with logging disabled, and reset the level
and label. It now yields a copy of the client whose PDP and API clients
are rebuilt from the new config, through the _connect() hook the
blocking client overrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The documented logger.add(sys.stderr, serialize=True) keeps loguru's
default text sink, so every record was printed twice, once as text and
once as JSON. Say that the application replaces the default sink with
logger.remove() first, in the README, the log_as_json description and
the configure_logger docstring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API key redaction and the log level and label hold only while every
SDK log call goes through permit.utils.sdk_logger. A ruff banned-api
rule (TID251) now flags loguru.logger anywhere in permit/ except the
wrapper itself and permit/logger.py, which reads loguru's levels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* origin/per-16676/release-ci-hardening:
  Expect the cloud PDP's tenant-association role in user permissions
  Test real decisions on the cloud PDP instead of a 501
  Test offline that a PDP's 501 makes the SDK raise
An unknown log.level was ignored before this branch, so raising
ValueError at client creation would break an app on upgrade. The SDK
now logs a warning that names the value and uses INFO. The traceback
test forces a failure inside configure_logger instead, and still checks
that the API key never shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 1, 2026

Copy link
Copy Markdown

PER-16680

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown

Dependency Security Audit

Scanned: pyproject.toml dependencies + dev group, resolved at Python 3.10 (the current resolution, and the lowest versions the published specs permit under each pydantic major)

✅ No known vulnerabilities found.

Both the resolved dependency set and the lowest versions the published specs permit are clean at HIGH and CRITICAL.

zeevmoney and others added 5 commits October 1, 2026 20:20
* origin/per-16676/release-ci-hardening:
  Invite with a role of the invited resource in the invites e2e test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* origin/per-16676/release-ci-hardening:
  Poll for the PDP's role assignment list in the RBAC e2e tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zeevmoney
zeevmoney added this pull request to stack #145 October 2, 2026 18:24

This branch has not been deployed

No deployments
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