Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7abf954
PER-16680: log through an SDK logger that applies the log settings
EliMoshkovich Oct 27, 2026
ec41876
PER-16680: review follow-ups: scrub every raised error body, URL user…
EliMoshkovich Oct 27, 2026
65cc670
PER-16680: second review pass: scrub carried request headers, safe UR…
EliMoshkovich Oct 28, 2026
9758ab5
PER-16680: scrub JSON object keys and the labelled log line
EliMoshkovich Oct 28, 2026
fbc59c0
PER-16680: scrub the request info aiohttp keeps in an error's args an…
EliMoshkovich Oct 28, 2026
8d02ede
PER-16680: scrub error bodies labelled JSON that aren't JSON, and aio…
EliMoshkovich Oct 29, 2026
b5de9d1
PER-16680: build the clients in the constructors again, without a _co…
EliMoshkovich Oct 29, 2026
a8bf8c2
PER-16680: build and scrub a log record only when a sink will receive it
EliMoshkovich Oct 29, 2026
bce2118
PER-16680: say exactly what is redacted and what "enable": True undoe…
EliMoshkovich Oct 29, 2026
e0c373d
PER-16680: scrub the errors chained under an aiohttp error, and a low…
EliMoshkovich Oct 29, 2026
8cc038f
PER-16680: the lazy-record test covers all four enforcer debug lines
EliMoshkovich Oct 29, 2026
1eea9d8
PER-16680: note that the floor lanes run multidict 4.5.0 as pure Python
EliMoshkovich Oct 29, 2026
3294360
PER-16680: change only what the request raised, and never fail on a s…
EliMoshkovich Oct 29, 2026
d2d1f86
PER-16680: README: what the scrub covers, and that it leaves other ex…
EliMoshkovich Oct 29, 2026
a4ae142
PER-16680: always scrub the caught error; fail closed on a key the sc…
EliMoshkovich Oct 30, 2026
8914a72
PER-16680: walk the members of an exception group under an aiohttp error
zeevmoney Oct 30, 2026
43454fd
PER-16680: test the scrub paths the final key check would otherwise hide
zeevmoney Oct 30, 2026
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
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,71 @@ calls into the SDK against its type annotations. No pydantic mypy plugin is need
- The blocking client, `permit.sync.Permit`, is typed as blocking:
`permit.api.users.get("user")` returns a `UserRead`, not a coroutine.

## Logging

The SDK logs with [loguru](https://github.com/Delgan/loguru) and logs nothing unless you
enable it in the `log` option:

```py
permit = Permit(token="<YOUR_API_KEY>", log={"enable": True, "level": "debug"})
```

- The SDK adds no loguru sink of its own. Its records go to the sinks your application has
added, or to loguru's default stderr sink, in the format of those sinks.
- `"enable": False` (the default) calls loguru's `logger.disable("permit")`. `"enable": True`
undoes that call, with `logger.enable("permit")`, only if an earlier client made it.
Otherwise a `logger.disable()` your application made for `permit` or one of its modules
still applies. When it does undo it, loguru's `logger.enable("permit")` also undoes your
application's own `logger.disable("permit")`, every `logger.disable()` your application
made for a `permit.*` module, before or since, and, for the SDK's records, an
application-wide `logger.disable("")`. Make those calls after creating the clients if
they must hold.
- `level` (default `"info"`) is the lowest severity the SDK logs. Its records below it never
reach a sink. Your application's own records are not affected. The SDK logs its HTTP
requests and the PDP's responses at `"debug"`. With `"enable": True`, for a level name
loguru does not know, the SDK logs a warning that names it and uses `"info"`.
- `label` (default `"Permit"`) is put in square brackets before every message the SDK logs.
- `json` is not applied. For JSON output, give your application a serialized sink in place
of loguru's default one: `logger.remove()`, then `logger.add(sys.stderr, serialize=True)`.
Added next to the default sink, it prints every record a second time.
- loguru's logger is process-wide, so these settings are too: the client created last
decides whether the SDK logs, and the last one created with `"enable": True` decides the
level and the label, for every client in the process. `wait_for_sync()` builds a new
client of the same class, but keeps the process's log settings as they are.
- The SDK replaces the API key of every client in the process, as given and in lower case,
with `[REDACTED]` in:
- the messages it logs;
- the messages of the exceptions it raises, and the error bodies in them
(`PermitApiError.details`);
- the aiohttp response or error those exceptions carry (`original_error`, `response`,
the cause), and the exceptions chained under that error (as a cause or a context, or
as a member of an exception group) that aiohttp and the libraries it uses raised
during the request: the request headers they keep, the request headers of the
redirect responses in their `history`, and each error's arguments and attributes
that are strings or bytes (such as its message), the response headers it keeps, and
the URL of an invalid-URL error (`InvalidURL.url`, which then becomes a string).

The SDK never changes an exception it did not raise during the request, such as one
your application was handling when it called the SDK, which aiohttp keeps in its
error's chain. If the key cannot be removed from the aiohttp error or an exception
under it, or is still in one under it that the SDK leaves as it is (other than the one
your application was handling), a `PermitConnectionError` carries a plain
`aiohttp.ClientError` that says so in its place, as `original_error` and as the cause,
and a `PermitApiError` for an error body that isn't JSON has no cause.

It does not replace the key elsewhere: not in values that are neither text nor a URL,
such as a parsed HTTP message; not in request URLs (`PermitApiError.request_url`, the
`url` and `real_url` of a response or its `request_info`, the `url=` part of an aiohttp
error's text); not in the headers or raw body of a response itself
(`PermitApiError.response.headers`, a redirect response's `Location`,
`await PermitApiError.response.text()`); and not in the values a traceback shows for
each frame (loguru's `diagnose=True`, error trackers that record local variables), where
the key is in the client's options and its request headers. A key shorter than 12
characters is not replaced. The client's start-up line shows `api_url` and `pdp` without
any user name or password in them.
- `level` also takes a loguru severity number, such as `10` for DEBUG. An unknown name is
warned about once per process.

## Deprecations

A future major release, permit 4.0, will remove the following. They still work in 3.x, and
Expand Down
8 changes: 4 additions & 4 deletions permit/api/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import aiohttp
from aiohttp import ClientTimeout
from loguru import logger

from permit.api.encoders import jsonable_encoder
from permit.utils.pydantic_version import PYDANTIC_VERSION
from permit.utils.sdk_logger import sdk_logger

if TYPE_CHECKING:
# The v1 API is what runs under either pydantic major, so type-check against it.
Expand Down Expand Up @@ -68,10 +68,10 @@ def __init__(
self._client_config["timeout"] = ClientTimeout(total=timeout)

def _log_request(self, url: str, method: str) -> None:
logger.debug(f"Sending HTTP request: {method} {url}")
sdk_logger.debug(f"Sending HTTP request: {method} {url}")

def _log_response(self, url: str, method: str, status: int) -> None:
logger.debug(f"Received HTTP response: {method} {url}, status: {status}")
sdk_logger.debug(f"Received HTTP response: {method} {url}, status: {status}")

def _prepare_json(
self, json: BaseModel | dict[str, Any] | list[Any] | None = None
Expand Down Expand Up @@ -241,7 +241,7 @@ def _build_http_client(

async def _set_context_from_api_key(self) -> None:
"""Set the API context and permitted access level based on the API key scope."""
logger.debug("Fetching api key scope")
sdk_logger.debug("Fetching api key scope")
scope = await self.__api_keys.get("/scope", model=APIKeyScopeRead)

if scope.organization_id is not None:
Expand Down
9 changes: 4 additions & 5 deletions permit/api/context.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from enum import Enum

from loguru import logger

from permit.exceptions import PermitContextChangeError
from permit.utils.sdk_logger import sdk_logger


class ApiKeyAccessLevel(str, Enum):
Expand Down Expand Up @@ -198,7 +197,7 @@ def set_organization_level_context(self, org: str) -> None:
org: The organization key.
"""
self.__verify_can_access_org(org)
logger.debug(f"Setting organization level context: {org}")
sdk_logger.debug(f"Setting organization level context: {org}")
self._context_level = ApiContextLevel.ORGANIZATION
self._organization = org
self._project = None
Expand All @@ -212,7 +211,7 @@ def set_project_level_context(self, org: str, project: str) -> None:
project: The project key.
"""
self.__verify_can_access_project(org, project)
logger.debug(f"Setting project level context: {org}/{project}")
sdk_logger.debug(f"Setting project level context: {org}/{project}")
self._context_level = ApiContextLevel.PROJECT
self._organization = org
self._project = project
Expand All @@ -227,7 +226,7 @@ def set_environment_level_context(self, org: str, project: str, environment: str
environment: The environment key.
"""
self.__verify_can_access_environment(org, project, environment)
logger.debug(f"Setting environment level context: {org}/{project}/{environment}")
sdk_logger.debug(f"Setting environment level context: {org}/{project}/{environment}")
self._context_level = ApiContextLevel.ENVIRONMENT
self._organization = org
self._project = project
Expand Down
41 changes: 36 additions & 5 deletions permit/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,49 @@


class LoggerConfig(BaseModel):
"""Logging settings of the SDK."""
"""Logging settings of the SDK.

The SDK logs with loguru and adds no sink of its own: its records go to the loguru sinks
the application has added, or to loguru's default stderr sink, in the format of those
sinks. loguru's logger is process-wide, so these settings are too: the client created
last decides whether the SDK logs, and the last one created with `enable` True decides
the level and the label, for every client in the process.

Whatever these settings, the SDK replaces the API key of every client in the process
with `[REDACTED]` in the messages it logs, in the messages and error bodies of the
exceptions it raises, and in the aiohttp objects those exceptions carry; the README
lists what it does not cover. The client's start-up line logs `api_url` and `pdp`
without a user name or password.
"""

enable: bool = Field(
default=False, description="Whether or not to enable logging from the Permit library"
default=False,
description="Whether the SDK logs. False calls loguru's logger.disable('permit'), so "
"nothing is logged. True undoes that call with logger.enable('permit') if an earlier "
"client made it, and otherwise leaves loguru's switches alone, so a logger.disable() "
"the application made for 'permit' or one of its modules still applies. When it does "
"undo it, loguru also drops every logger.disable() set for 'permit' or one of its "
"modules, and overrides an application-wide logger.disable('') for the SDK's records.",
)
level: str = Field(
default="info", description="Sets the log level configured for the Permit SDK Logger."
default="info",
description="The lowest severity the SDK logs, such as 'debug', 'info', 'warning' or "
"'error', in any case; 'warn' and 'fatal' are read as 'warning' and 'critical'. The SDK "
"drops its records below it before they reach any sink. "
"Read only when enable is True; for a name loguru does not know, the SDK logs a "
"warning and uses 'info'.",
)
label: str = Field(
default="Permit",
description="Sets the label configured for logs emitted by the Permit SDK Logger.",
description="Put in square brackets before the message of every record the SDK logs, "
"as in '[Permit] ...'. An empty string adds nothing. Read only when enable is True.",
)
log_as_json: bool = Field(
default=False,
alias="json",
description="Sets whether the SDK log output should be in JSON format.",
description="Not applied. The format of the SDK's records is that of the loguru sinks "
"they reach. For JSON, the application replaces loguru's default sink with a "
"serialized one: logger.remove(), then logger.add(sys.stderr, serialize=True).",
)


Expand All @@ -54,8 +81,12 @@ class PermitConfig(BaseModel):

# A positional `...`, not `default=...`: type checkers take any `default=`
# keyword as a default, so `PermitConfig()` without a token would pass them.
# repr=False keeps the key out of repr() and str() of the config. Tracebacks that print
# frame values, such as loguru's with diagnose=True, still show it in other values: the
# options a client is created with, and the headers of its HTTP clients.
token: str = Field(
...,
repr=False,
description="The token (API Key) used for authorization against the PDP "
"and the Permit REST API.",
)
Expand Down
Loading
Loading