Repository navigation
Conversation
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>
Dependency Security AuditScanned: 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) ❌ 6 fixable HIGH/CRITICAL advisories -- blocking this build.
Advisory detailsCRITICAL -- GHSA-hq85-5cpj-mr62 ( Found by: trivy:dev-ceiling HIGH -- CVE-2026-111064 ( Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2 HIGH -- CVE-2026-111066 ( Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2 HIGH -- GHSA-2g87-pp6c-29x5 ( Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2, trivy:runtime-floor, trivy:runtime-floor-pydantic-v2 HIGH -- CVE-2026-114605 ( Found by: trivy:dev-ceiling, trivy:runtime-ceiling, trivy:runtime-floor, trivy:runtime-floor-pydantic-v2 HIGH -- CVE-2026-112940 ( Found by: trivy:dev-ceiling MEDIUM -- CVE-2026-111063 ( Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2 MEDIUM -- CVE-2026-111065 ( Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2 MEDIUM -- CVE-2026-111748 ( Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2 MEDIUM -- CVE-2026-111750 ( Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2 MEDIUM -- CVE-2026-112906 ( Found by: trivy:dev-ceiling MEDIUM -- CVE-2026-112908 ( Found by: trivy:dev-ceiling MEDIUM -- CVE-2026-112909 ( Found by: trivy:dev-ceiling MEDIUM -- CVE-2026-112910 ( Found by: trivy:dev-ceiling MEDIUM -- CVE-2026-112911 ( Found by: trivy:dev-ceiling MEDIUM -- CVE-2026-112914 ( Found by: trivy:dev-ceiling MEDIUM -- CVE-2026-112915 ( Found by: trivy:dev-ceiling MEDIUM -- CVE-2026-112934 ( Found by: trivy:dev-ceiling MEDIUM -- CVE-2026-112935 ( Found by: trivy:dev-ceiling MEDIUM -- CVE-2026-112937 ( Found by: trivy:dev-ceiling MEDIUM -- CVE-2026-112938 ( Found by: trivy:dev-ceiling MEDIUM -- CVE-2026-112939 ( Found by: trivy:dev-ceiling MEDIUM -- CVE-2026-104843 ( Found by: trivy:dev-ceiling MEDIUM -- CVE-2026-102598 ( Found by: trivy:dev-ceiling LOW -- CVE-2026-111749 ( Found by: trivy:runtime-floor, trivy:runtime-floor-pydantic-v2 LOW -- CVE-2026-112907 ( Found by: trivy:dev-ceiling LOW -- CVE-2026-112912 ( Found by: trivy:dev-ceiling LOW -- CVE-2026-112913 ( Found by: trivy:dev-ceiling LOW -- CVE-2026-112941 ( Found by: trivy:dev-ceiling UNKNOWN -- CVE-2026-111063 (GHSA-494j-54xm-rwp3) ( Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2 UNKNOWN -- CVE-2026-111064 (GHSA-5wr8-gcqf-gqf3) ( Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2 UNKNOWN -- CVE-2026-111065 (GHSA-6cjc-53gc-5rqm) ( Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2 UNKNOWN -- CVE-2026-111066 (GHSA-9xw5-w5jj-pvcg) ( Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2 UNKNOWN -- CVE-2026-111748 (GHSA-f9xw-jh8m-3mwq) ( Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2 UNKNOWN -- CVE-2026-111749 (GHSA-gx76-c99x-2c96) ( Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2 UNKNOWN -- CVE-2026-111750 (GHSA-pqxq-w44r-73xw) ( Found by: pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2 UNKNOWN -- CVE-2026-114605 (GHSA-45c3-73f7-pv4m) ( Found by: pip-audit:dev-ceiling, pip-audit:runtime-ceiling, pip-audit:runtime-floor, pip-audit:runtime-floor-pydantic-v2 UNKNOWN -- CVE-2026-112906 (GHSA-2g95-wg33-c5x2) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112907 (GHSA-567q-5qvw-pf92) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112908 (GHSA-6627-m3xw-93mp) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112909 (GHSA-69hj-f68j-2879) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112910 (GHSA-8mx5-7m5m-r77g) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112911 (GHSA-9c4x-gm8v-wgc4) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112912 (GHSA-9j6q-wvj5-jgjg) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112913 (GHSA-9vhv-jxww-xfm5) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112914 (GHSA-crv4-6mhj-j5jh) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112915 (GHSA-cwqr-hh33-gh44) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112934 (GHSA-fj95-fr6c-h845) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112935 (GHSA-fq56-3cx3-fjxp) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112937 (GHSA-jqc4-5cvw-7w85) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112938 (GHSA-m462-7rxw-wvpg) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112939 (GHSA-rfpc-qcw5-3pc4) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112940 (GHSA-v4x8-3wr5-3wq8) ( Found by: pip-audit:dev-ceiling UNKNOWN -- CVE-2026-112941 (GHSA-wfhq-q4j2-c37m) ( Found by: pip-audit:dev-ceiling UNKNOWN -- PYSEC-2026-4305 (CVE-2026-104843, GHSA-2cv4-cqwr-gwf7) ( Found by: pip-audit:dev-ceiling UNKNOWN -- PYSEC-2026-4321 (GHSA-hq85-5cpj-mr62) ( Found by: pip-audit:dev-ceiling UNKNOWN -- PYSEC-2026-4312 (CVE-2026-102598, GHSA-g6x2-hccm-hh4m) ( Found by: pip-audit:dev-ceiling How to fixRaise the affected lower bound in This audit resolves without the 7-day publish-age cooldown ( An advisory with no fix available does not block the build -- it is reported here so it can be tracked, but no version bump can resolve it. Suppression files are deliberately not honoured: the scan runs with |
* 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>
…nnect hook Permit.__init__ called self._connect(), a new override point: an application subclass with its own _connect method had it called by the SDK's constructor. The logging change doesn't need it, so the base constructor builds the four clients again and the blocking client's constructor builds its own after it, as before. A test checks that a subclass's own _connect is left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit b5de9d1)
- SdkLogger logs with loguru's lazy option, so the label, the key replacement and the message itself are built only for a record loguru passes on. With the SDK's logging off (the default) a debug record is dropped without being built or scrubbed. An application that turns the permit records on itself still gets them in full. - The enforcer's debug lines, which pformat the request and the response, pass a function that builds the message, so the pformat runs only for a record a sink receives. - A log level such as "²", which str.isdigit accepts but int() doesn't, is now an unknown level that falls back to INFO with a warning, instead of failing client creation. - Tests: the echoed-key test runs on both clients, at trace, debug, info, error and critical, with logging off and with the application turning it on; the level sweep runs on both clients, checks the key and that no level falls back to INFO, and covers "fatal"; tests for the 12-character boundary, the label on the unknown-level path, an enabled client undoing the SDK's own disable only once, and a disable the application made for an SDK module before. The fixture resets the set of level names already warned about. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit a8bf8c2)
…s; lint and deps
- README: list where the key is replaced (log messages, exception messages and
error bodies, the aiohttp objects the exceptions carry) and where it isn't (the
response's own headers and raw body, frame values in tracebacks), and keep the
user-name-and-password sentence to the start-up line.
- README and docstrings: undoing the SDK's own disable with logger.enable("permit")
also drops every disable set for a permit module, before or since, and overrides
an application-wide logger.disable("") for the SDK's records. A test covers both.
- The repr=False comment on PermitConfig.token now says what it covers: repr() and
str() of the config, not every value a traceback prints.
- ruff bans the standard logging module in the package, as it bans loguru's logger.
- multidict, imported directly, is a declared dependency, in aiohttp's range.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit bce2118)
|
Pushed the four review-round commits from #163 (cherry-picked, so the two PRs stay identical):
|
…er-cased key - The aiohttp error an SDK exception carries is scrubbed together with every exception chained under it, such as the HTTP parser errors aiohttp raises it from: their string args, message, line, and the URL an InvalidURL error prints. Tested against the whole formatted traceback, for a bad header, a bad status line, a redirect to an invalid URL and one to a non-HTTP URL, on the PDP and the REST API, with both clients. The non-HTTP redirect keeps the key in args only. - Each key is also replaced in lower case: aiohttp lower-cases some server text, such as a Content-Type, in its error messages. The test key is now mixed case. - The enforcer reads a PDP error body with errors="replace", so a body that isn't valid UTF-8 raises PermitConnectionError instead of a UnicodeDecodeError that keeps the raw bytes. - PermitConnectionError keeps a message that isn't a string as it is, as before. - README: say what the redaction covers, now including the chained exceptions, and that URLs and a redirect response's own headers are not covered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit e0c373d)
The test now runs bulk_check, authorized_users and get_user_permissions as well as check, and with the records on asserts that the message was built and scrubbed, without pinning how many pformat calls it makes. Two parametrizations use CLIENT_CLASSES instead of copying it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 8cc038f)
The multidict floor stays at aiohttp's own, 4.5, so declaring it narrows nothing users can install. The comment now says that the lowest-direct floor lanes therefore run 4.5.0, which has no C extension build for Python 3.10+. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 1eea9d8)
|
Pushed the three round-2 commits from #163 (cherry-picked):
|
…crub The walk over the exceptions chained under a carried aiohttp error reached, through __context__, an exception the application was handling when it called the SDK, and wrote to it. A frozen dataclass exception, read-only args or a headers dict with a value that isn't a string then made the scrub raise inside PermitConnectionError.__init__, so the caller got that error instead of a PermitConnectionError. - The walk keeps only exceptions raised and caught within the request: every frame of their traceback is in aiohttp, the libraries it raises errors from, asyncio or the SDK. It doesn't go past any other exception, so the application's exception and its own chain are never changed. - A value is written only when it holds a key, each write is guarded, and scrub_request_info never raises: it returns False when a value that holds a key can't be replaced. PermitConnectionError then attaches a plain ClientError that says so, and every raise site raises from original_error, so the unscrubbed error is neither attached nor the cause. handle_api_error doesn't chain to it either. - Every string and bytes argument and attribute of those exceptions is scrubbed, not a fixed list, and so is a yarl URL, which becomes a string when it holds a key. - Tests: the application's exception (frozen, headers with an int, read-only args) is left alone and PermitConnectionError is still raised, on both clients, for the PDP and the REST API; a scrub that fails is not attached or the cause, at every raise site; an error chained only as the context; a __cause__ cycle, in a subprocess; raw answers whose errors keep the key as bytes (a header line too long, a NUL in a header) and as a yarl URL; a padded key echoed in lower case. assert_holds_no_key also checks every attribute of every chained exception, and the raw-server test checks that each case still reaches a key to replace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 3294360)
…ceptions alone The redaction list says which chained exceptions are covered (those aiohttp and its libraries raised during the request), that string and bytes values and an invalid-URL error's URL are, the latter becoming a string, and that values that aren't text, such as a parsed HTTP message, are not. It says the SDK never changes an exception it didn't raise, and what it attaches when the key can't be removed. "Request URLs" now names the URLs that are not covered, so it no longer reads as contradicting the invalid-URL item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit d2d1f86)
|
Pushed the two round-3 commits from #163 (cherry-picked):
|
…rub can't reach - The error the SDK caught is always scrubbed; the frame check applies only to what is chained under it. - The exception the application was handling when it called the SDK is passed in as `handled` and is left alone, with everything under it. - Any other exception left as it is (raised in a package off the list, or with a frame of the application's), or text the scrub can't rewrite (a UnicodeError's `object`), makes the scrub return False if it still holds a key. - PermitConnectionError and PermitApiError are raised after the `except` block, so an error that wasn't attached isn't their context. - Tests: the caught error with a foreign frame; an idna error in a redirect host; a yarl-raised error; all four PDP calls with the application's exception; a refused connection; the frame-check, fail-closed, bytearray and builtin-field paths; and the raw answers on aiohttp's pure-Python parser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A traceback shows every member of an exception group and what is chained under each, so the scrub now walks them as it walks a cause and a context: members raised during the request are scrubbed, and a key in one left as it is fails the scrub. Works with the exceptiongroup backport on 3.10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEQWbhLbnWSwco3H5iNFis
The check for a key the scrub could not reach also catches most write failures, so several tests passed whether or not the scrub worked. They now check that the error is attached where the scrub should succeed, and new cases cover: a write that fails out of sight of that check, an error without the key that can't be written to, a key held only in an error's text or only in its args, a cycle among the exceptions the scrub leaves as they are, and the exception the application was handling, under one left as it is, with frames only in the request's packages, and while an error body that isn't JSON is read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEQWbhLbnWSwco3H5iNFis
) * PER-16680: log through an SDK logger that applies the log settings Ported to main from #137 (Zeev Manilovich), which is stacked on the release-CI draft #136; this carries only #137's own changes. - The client no longer logs its config on creation; it logs the PDP and API URL. - permit.utils.sdk_logger applies log.level and log.label to the SDK's records without touching the application's loguru sinks, and replaces every client's API key with [REDACTED] in what the SDK logs and in PDP error bodies. - PermitConfig.token is repr=False. - ruff bans logging straight to loguru inside the package. - README: what each log option does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: review follow-ups: scrub every raised error body, URL user info, wait_for_sync - handle_api_error scrubs the registered keys from the body it puts in an exception, text or JSON, so the REST API and the PDP's local API raise without them, as the enforcer already did. - The start-up line logs api_url and pdp without a user name or password. - wait_for_sync builds a client of the same class again, so a subclass's own setup runs, and keeps the process's log settings through the keep_log_settings context variable. - A secret shorter than 12 characters is not redacted, so a short test token doesn't mangle the SDK's text. - log.level takes a loguru severity number and surrounding whitespace; an unknown level is warned about once per process. - The SDK's enable/disable switch changes under a lock. - Drop sync.Permit.__init__, which only called super(). - README and docstrings match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: second review pass: scrub carried request headers, safe URL helper - aiohttp keeps the request's headers, Authorization included, on the ClientResponse and ClientResponseError the SDK's exceptions carry (original_error, response, the cause), and their repr prints them. scrub_request_info replaces every registered key there before an exception holds them, including the response's cached request_info. - without_userinfo moves to permit.utils.sdk_logger. It never raises (a bad port no longer fails client creation, an unparseable URL shows as <invalid URL>) and keeps IPv6 brackets. - PDP error messages show the PDP URL without a user name or password. - README: wait_for_sync builds a new client of the same class; what the key is replaced in. - Tests: REST and PDP errors for a non-JSON answer (200 and 502) and for an echoed key carry no key in their cause, context, original_error or response; without_userinfo cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: scrub JSON object keys and the labelled log line - handle_api_error's JSON scrubber replaces registered keys in object keys as well as values, so an error body that uses the key as a key doesn't expose it. - The SDK logger scrubs the message after the label is added, so a key placed in log.label is replaced too. - Tests: a key echoed as a JSON object key; a key in the label. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: scrub the request info aiohttp keeps in an error's args and redirect history A ClientResponseError keeps its RequestInfo as args[0] as well as in request_info, and the redirect responses in its (or a response's) history keep their own. scrub_request_info replaces the key in all of them. Test: neither the carried objects nor their args print the key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: scrub error bodies labelled JSON that aren't JSON, and aiohttp error text - An error response whose Content-Type says JSON but whose body isn't JSON (a proxy's error page, a truncated body, one that isn't UTF-8) now raises PermitApiError with the scrubbed body as its details, instead of letting a JSONDecodeError or UnicodeDecodeError, which keep the raw body, escape. The body is scrubbed before it is parsed, and the decode error isn't chained. - PermitConnectionError replaces the key in its message itself, so every raise site is covered, including the ones that build it from an aiohttp error's text. - The aiohttp error it carries has the key replaced in its message, the response headers it keeps and its string arguments, not only in the request headers. - Tests: a body labelled JSON that isn't JSON; an aiohttp error whose text echoes the key, for every enforcer call and the REST API; an echoed key after a followed redirect, and in a list of errors and an object key spelled with JSON escapes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: build the clients in the constructors again, without a _connect hook Permit.__init__ called self._connect(), a new override point: an application subclass with its own _connect method had it called by the SDK's constructor. The logging change doesn't need it, so the base constructor builds the four clients again and the blocking client's constructor builds its own after it, as before. A test checks that a subclass's own _connect is left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: build and scrub a log record only when a sink will receive it - SdkLogger logs with loguru's lazy option, so the label, the key replacement and the message itself are built only for a record loguru passes on. With the SDK's logging off (the default) a debug record is dropped without being built or scrubbed. An application that turns the permit records on itself still gets them in full. - The enforcer's debug lines, which pformat the request and the response, pass a function that builds the message, so the pformat runs only for a record a sink receives. - A log level such as "²", which str.isdigit accepts but int() doesn't, is now an unknown level that falls back to INFO with a warning, instead of failing client creation. - Tests: the echoed-key test runs on both clients, at trace, debug, info, error and critical, with logging off and with the application turning it on; the level sweep runs on both clients, checks the key and that no level falls back to INFO, and covers "fatal"; tests for the 12-character boundary, the label on the unknown-level path, an enabled client undoing the SDK's own disable only once, and a disable the application made for an SDK module before. The fixture resets the set of level names already warned about. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: say exactly what is redacted and what "enable": True undoes; lint and deps - README: list where the key is replaced (log messages, exception messages and error bodies, the aiohttp objects the exceptions carry) and where it isn't (the response's own headers and raw body, frame values in tracebacks), and keep the user-name-and-password sentence to the start-up line. - README and docstrings: undoing the SDK's own disable with logger.enable("permit") also drops every disable set for a permit module, before or since, and overrides an application-wide logger.disable("") for the SDK's records. A test covers both. - The repr=False comment on PermitConfig.token now says what it covers: repr() and str() of the config, not every value a traceback prints. - ruff bans the standard logging module in the package, as it bans loguru's logger. - multidict, imported directly, is a declared dependency, in aiohttp's range. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: scrub the errors chained under an aiohttp error, and a lower-cased key - The aiohttp error an SDK exception carries is scrubbed together with every exception chained under it, such as the HTTP parser errors aiohttp raises it from: their string args, message, line, and the URL an InvalidURL error prints. Tested against the whole formatted traceback, for a bad header, a bad status line, a redirect to an invalid URL and one to a non-HTTP URL, on the PDP and the REST API, with both clients. The non-HTTP redirect keeps the key in args only. - Each key is also replaced in lower case: aiohttp lower-cases some server text, such as a Content-Type, in its error messages. The test key is now mixed case. - The enforcer reads a PDP error body with errors="replace", so a body that isn't valid UTF-8 raises PermitConnectionError instead of a UnicodeDecodeError that keeps the raw bytes. - PermitConnectionError keeps a message that isn't a string as it is, as before. - README: say what the redaction covers, now including the chained exceptions, and that URLs and a redirect response's own headers are not covered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: the lazy-record test covers all four enforcer debug lines The test now runs bulk_check, authorized_users and get_user_permissions as well as check, and with the records on asserts that the message was built and scrubbed, without pinning how many pformat calls it makes. Two parametrizations use CLIENT_CLASSES instead of copying it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: note that the floor lanes run multidict 4.5.0 as pure Python The multidict floor stays at aiohttp's own, 4.5, so declaring it narrows nothing users can install. The comment now says that the lowest-direct floor lanes therefore run 4.5.0, which has no C extension build for Python 3.10+. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: change only what the request raised, and never fail on a scrub The walk over the exceptions chained under a carried aiohttp error reached, through __context__, an exception the application was handling when it called the SDK, and wrote to it. A frozen dataclass exception, read-only args or a headers dict with a value that isn't a string then made the scrub raise inside PermitConnectionError.__init__, so the caller got that error instead of a PermitConnectionError. - The walk keeps only exceptions raised and caught within the request: every frame of their traceback is in aiohttp, the libraries it raises errors from, asyncio or the SDK. It doesn't go past any other exception, so the application's exception and its own chain are never changed. - A value is written only when it holds a key, each write is guarded, and scrub_request_info never raises: it returns False when a value that holds a key can't be replaced. PermitConnectionError then attaches a plain ClientError that says so, and every raise site raises from original_error, so the unscrubbed error is neither attached nor the cause. handle_api_error doesn't chain to it either. - Every string and bytes argument and attribute of those exceptions is scrubbed, not a fixed list, and so is a yarl URL, which becomes a string when it holds a key. - Tests: the application's exception (frozen, headers with an int, read-only args) is left alone and PermitConnectionError is still raised, on both clients, for the PDP and the REST API; a scrub that fails is not attached or the cause, at every raise site; an error chained only as the context; a __cause__ cycle, in a subprocess; raw answers whose errors keep the key as bytes (a header line too long, a NUL in a header) and as a yarl URL; a padded key echoed in lower case. assert_holds_no_key also checks every attribute of every chained exception, and the raw-server test checks that each case still reaches a key to replace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: README: what the scrub covers, and that it leaves other exceptions alone The redaction list says which chained exceptions are covered (those aiohttp and its libraries raised during the request), that string and bytes values and an invalid-URL error's URL are, the latter becoming a string, and that values that aren't text, such as a parsed HTTP message, are not. It says the SDK never changes an exception it didn't raise, and what it attaches when the key can't be removed. "Request URLs" now names the URLs that are not covered, so it no longer reads as contradicting the invalid-URL item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: always scrub the caught error; fail closed on a key the scrub can't reach - The error the SDK caught is always scrubbed; the frame check applies only to what is chained under it. - The exception the application was handling when it called the SDK is passed in as `handled` and is left alone, with everything under it. - Any other exception left as it is (raised in a package off the list, or with a frame of the application's), or text the scrub can't rewrite (a UnicodeError's `object`), makes the scrub return False if it still holds a key. - PermitConnectionError and PermitApiError are raised after the `except` block, so an error that wasn't attached isn't their context. - Tests: the caught error with a foreign frame; an idna error in a redirect host; a yarl-raised error; all four PDP calls with the application's exception; a refused connection; the frame-check, fail-closed, bytearray and builtin-field paths; and the raw answers on aiohttp's pure-Python parser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PER-16680: walk the members of an exception group under an aiohttp error A traceback shows every member of an exception group and what is chained under each, so the scrub now walks them as it walks a cause and a context: members raised during the request are scrubbed, and a key in one left as it is fails the scrub. Works with the exceptiongroup backport on 3.10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEQWbhLbnWSwco3H5iNFis * PER-16680: test the scrub paths the final key check would otherwise hide The check for a key the scrub could not reach also catches most write failures, so several tests passed whether or not the scrub worked. They now check that the error is attached where the scrub should succeed, and new cases cover: a write that fails out of sight of that check, an error without the key that can't be written to, a key held only in an error's text or only in its args, a cycle among the exceptions the scrub leaves as they are, and the exception the application was handling, under one left as it is, with frames only in the request's packages, and while an error body that isn't JSON is read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEQWbhLbnWSwco3H5iNFis --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Zeev Manilovich <zeevmanilovich@gmail.com>
Linear issues
level,labelandjsonlog settings.Why
With
log={"enable": True}, creating a client (asyncPermitorpermit.sync.Permit) loggedjson.dumps(config.dict())at DEBUG. That dump includestoken, and loguru's default stderr sink prints DEBUG, so the API key reached the log whateverlog.levelsaid.configure_logger()read onlylog.enable;level,labelandjsonhad no effect.What changed
Permit SDK initialized: api_url=..., pdp=...at DEBUG.permit/utils/sdk_logger.py. It drops records belowlog.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 withlogger.opt(depth=2), so loguru still credits each record to the SDK module that logged it. The SDK adds no loguru sink and never callslogger.remove().read_error_body()redacts the PDP error body it returns. The body goes into both the SDK's error record and thePermitConnectionErrorraised bycheck,bulk_checkandauthorized_users.PermitConfig.tokenis declared withrepr=False, sorepr()/str()of a config, and tracebacks that print frame values (loguru'sdiagnose=True), do not show it.log.enableFalse callslogger.disable("permit"), as before. True callslogger.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.banned-apirule (TID251) flagsloguru.loggeranywhere inpermit/except the wrapper andpermit/logger.py.LoggerConfigdocstring, its field descriptions and theconfigure_loggerdocstring 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.
log.enableTrue, creating a client no longer logs its config. 3.0.0 and earlier wrote the API key to the log at any configured level wheneverlog.enablewas True. Users who ran that way should rotate those keys and purge those logs.[REDACTED]in every message the SDK logs.PermitConnectionErrorfor a non-200 PDP response fromcheck,bulk_checkorauthorized_usersquotes the response body with the key replaced by[REDACTED].repr()andstr()ofPermitConfigno longer showtoken. The field,.dict()and.json()are unchanged.log.levelnow applies whenlog.enableis 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_permissionsbodies, API context changes and the init line. Set"debug"to keep them. Accepted names are loguru's built-in levels in any case, the aliaseswarnandfatal, and levels the application added withlogger.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.labelnow applies whenlog.enableis True: SDK messages start with[Permit]by default, or[<label>]. An empty label adds nothing.log.enableTrue after one created with logging disabled now logs; before, it stayed silent. The SDK callslogger.enable("permit")only in that case, so alogger.disable()the application made forpermitor one of its modules still applies. When the SDK does undo its own disable, loguru also drops anypermit.*disable made after that earlier client.log.enableTrue sets the level and label.wait_for_sync()withproxy_facts_via_pdpyields 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_jsonstill has no effect. It is now documented as not applied, with the recipe:logger.remove(), thenlogger.add(sys.stderr, serialize=True).log.enableFalse (the default) callslogger.disable("permit"), prints nothing, and does not readlevel,labelorjson. An application that callslogger.enable("permit")itself still gets the SDK's records, now without the key.Known limitations, not changed here:
api_urlorpdpURL are not redacted. The SDK's request lines logged them at DEBUG before this change too.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'spermitswitches. The tests cover:diagnose=True;wait_for_sync()keeping log settings and sendingX-Wait-Timeout/X-Timeout-Policy;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.depth=0): 4 fail;logger.disabledropped: 11 fail;logger.remove(): 39 fail, 47 errors;repr=Falseremoved, insertion-order and shortest-first redaction, untrimmed key only, scrub removed from error bodies, always/neverlogger.enable, the oldwait_for_syncconstruction, a missing_connect, and a direct loguru import (flagged by ruff TID251).uv lock --check) passes. mypy is clean on both lanes. actionlint is clean. zizmor reports no findings.Owner actions before merge
log_as_jsonstays unapplied and documented for now; what it should do is decided separately.🤖 Generated with Claude Code