From e58e9928c5963ba572a61afff719154e3bc21ab9 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 1 Jun 2026 15:01:23 +0200 Subject: [PATCH 1/2] Restructure README like castle-ruby Lead with quick start and minimal configuration (api_secret, failover, timeout). Move headers, IP detection, and proxy options to Advanced configuration. --- README.rst | 213 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 136 insertions(+), 77 deletions(-) diff --git a/README.rst b/README.rst index 13cd662..b6bbdd0 100644 --- a/README.rst +++ b/README.rst @@ -5,134 +5,193 @@ Python SDK for Castle :alt: Build Status :target: https://github.com/castle/castle-python/actions/workflows/specs.yml -`Castle `_ **analyzes user behavior in web and mobile apps to stop fraud before it happens.** +The official Python SDK for `Castle `_. Castle analyzes user behavior in web and mobile apps to stop fraud before it happens. + +This package is a thin wrapper around the `Castle HTTP API `_. It exposes risk assessment, event logging, Lists, Privacy (GDPR), Events (enterprise), and webhook verification. See the API reference for supported events and payload shapes. + +Requirements +------------ + +- Python 3.9 or newer +- A `Castle `_ API secret Installation ------------ -``pip install castle`` +.. code:: bash + + pip install castle + +Quick start +----------- + +.. code:: python + + import os + from castle.configuration import configuration + from castle.client import Client + + configuration.api_secret = os.environ['CASTLE_API_SECRET'] + + client = Client.from_request(request) + verdict = client.risk({ + 'event': '$login', + 'status': '$succeeded', + 'request_token': request.POST.get('castle_request_token'), + 'user': {'id': '12345', 'email': 'user@example.com'}, + }) + + action = verdict.get('policy', {}).get('action') or verdict.get('action') + if action == 'deny': + # block the user + pass + elif action == 'challenge': + # send 2FA / additional verification + pass + else: + # allow + pass + +``Client.from_request`` builds request context (IP, headers, client id) from a framework request object. See `Advanced configuration`_ for header allow/deny lists and proxy chains. Configuration ------------- -Import and configure the library with your Castle API secret. +The minimal, recommended setup: .. code:: python - from castle.configuration import configuration, DEFAULT_ALLOWLIST, TRUSTED_PROXIES + import os + from castle.configuration import configuration - configuration.api_secret = ':YOUR-API-SECRET' + configuration.api_secret = os.environ['CASTLE_API_SECRET'] - # For risk/filter methods you can set failover strategies: allow(default), deny, challenge, throw - configuration.failover_strategy = 'deny' + # Behavior when Castle's API is unreachable or returns a 5xx. + # One of: allow (default), deny, challenge, throw + configuration.failover_strategy = 'allow' - # RequestError is raised when timing out in milliseconds (default: 1000 milliseconds) - configuration.request_timeout = 1500 + # Request timeout in milliseconds (default: 1000). + # RequestError is raised on timeout. + configuration.request_timeout = 1000 - # Base Castle API url - # configuration.base_url = "https://api.castle.io/v1" +Logging +~~~~~~~ - # Logger (need to respond to info method) - logs Castle API requests and responses - # configuration.logger = logging.getLogger() +.. code:: python - # Allowlisted and Denylisted headers are case insensitive - # and allow to use _ and - as a separator, http prefixes are removed - # By default all headers are passed, but some are automatically scrubbed. - # If you need to apply an allowlist, we recommend using the minimum set of - # standard headers that we've exposed in the `DEFAULT_ALLOWLIST` constant. - # Allowlisted headers - configuration.allowlisted = DEFAULT_ALLOWLIST + ['X_HEADER'] + import logging + from castle.configuration import configuration - # Denylisted headers take advantage over allowlisted elements. Note that - # some headers are always scrubbed, for security reasons. - configuration.denylisted = ['HTTP-X-header'] + configuration.logger = logging.getLogger('castle') - # Castle needs the original IP of the client, not the IP of your proxy or load balancer. - # The SDK will only trust the proxy chain as defined in the configuration. - # We try to fetch the client IP based on X-Forwarded-For or Remote-Addr headers in that order, - # but sometimes the client IP may be stored in a different header or order. - # The SDK can be configured to look for the client IP address in headers that you specify. +The logger only needs to respond to ``info``. Each request and response is logged with sensitive values stripped. - # Sometimes, Cloud providers do not use consistent IP addresses to proxy requests. - # In this case, the client IP is usually preserved in a custom header. Example: - # Cloudflare preserves the client request in the 'Cf-Connecting-Ip' header. - # It would be used like so: configuration.ip_headers=['Cf-Connecting-Ip'] - configuration.ip_headers = [] +Multi-environment / multi-tenant +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - # If the specified header or X-Forwarded-For default contains a proxy chain with public IP addresses, - # then you must choose only one of the following (but not both): - # 1. The trusted_proxies value must match the known proxy IPs. This option is preferable if the IP is static. - # 2. The trusted_proxy_depth value must be set to the number of known trusted proxies in the chain (see below). - # This option is preferable if the IPs are ephemeral, but the depth is consistent. +Most apps only need the global ``configuration`` singleton, but you can also create standalone ``Configuration`` instances and pass them per call via ``APIRequest``: - # Additionally to make X-Forwarded-For and other headers work better discovering client ip address, - # and not the address of a reverse proxy server, you can define trusted proxies - # which will help to fetch proper ip from those headers +.. code:: python - # In order to extract the client IP of the X-Forwarded-For header - # and not the address of a reverse proxy server, you must define all trusted public proxies - # you can achieve this by listing all the proxies ip defined by string or regular expressions - # in the trusted_proxies setting - configuration.trusted_proxies = [] - # or by providing number of trusted proxies used in the chain - configuration.trusted_proxy_depth = 0 - # note that you must pick one approach over the other. + from castle.configuration import Configuration + from castle.api_request import APIRequest + from castle.commands.risk import CommandsRisk - # If there is no possibility to define options above and there is no other header that holds the client IP, - # then you may set trust_proxy_chain = true to trust all of the proxy IPs in X-Forwarded-For - configuration.trust_proxy_chain = false - # *Warning*: this mode is highly promiscuous and could lead to wrongly trusting a spoofed IP if the request passes through a malicious proxy + config = Configuration() + config.api_secret = os.environ['CASTLE_API_SECRET_TENANT_A'] - # *Note: the default list of proxies that are always marked as "trusted" can be found in TRUSTED_PROXIES + APIRequest(config).call(CommandsRisk(context).call({ + 'event': '$login', + 'status': '$succeeded', + 'request_token': '', + 'user': {'id': '1234'}, + })) Usage -------------------------------- +----- -See `documentation `_ for how to use this SDK with the Castle APIs +See `Castle documentation `_ and the `API reference `_ for endpoint details, event types, and integration guides. +Advanced configuration +---------------------- -Multi-environment configuration -------------------------------- +The defaults work for most deployments. The options below only matter if you have a non-trivial proxy chain or strict header policies. -It is also possible to define multiple configs within one application. +Header allow/deny lists +~~~~~~~~~~~~~~~~~~~~~~~ + +By default the SDK sends every HTTP header except ``Cookie`` and ``Authorization``. Castle uses these headers to fingerprint the request. .. code:: python - from castle.configuration import Configuration + from castle.configuration import configuration, DEFAULT_ALLOWLIST - # Initialize a separate Configuration instance - config = Configuration() - config.api_secret = ':YOUR-API-SECRET' + # Always-blocked headers (in addition to Cookie/Authorization). + configuration.denylisted = ['HTTP-X-Internal-Header'] + + # Strict allow-list mode. Headers outside the list are scrubbed, + # except User-Agent which is always preserved. + configuration.allowlisted = DEFAULT_ALLOWLIST + +Header names are case-insensitive and accept both ``_`` and ``-`` as separators. A leading ``HTTP_`` prefix is stripped automatically. + +Client IP detection +~~~~~~~~~~~~~~~~~~~ -After a successful setup, you can pass the config to a client and call any API as follows: +Castle needs the original client IP, not the IP of your proxy or load balancer. The SDK reads ``X-Forwarded-For`` and ``Remote-Addr`` by default; pick **one** of the strategies below: .. code:: python - from castle.client import Client + from castle.configuration import configuration, TRUSTED_PROXIES - client = Client({'context': {}}) - client.risk({ - 'request_token': '', - 'event': '$login', - 'status': '$succeeded', - 'user': {'id': '1234'} - }) + # 1. Custom header (e.g. Cloudflare's Cf-Connecting-Ip). + configuration.ip_headers = ['Cf-Connecting-Ip'] + + # 2. Static, known proxy IPs (strings or regexes). + configuration.trusted_proxies = ['10.0.0.1'] + # 3. Ephemeral proxies but known chain depth. + configuration.trusted_proxy_depth = 2 + + # 4. Last resort: trust the entire X-Forwarded-For chain. + # Warning: vulnerable to header spoofing if a malicious proxy is in path. + configuration.trust_proxy_chain = False + +Use **either** ``trusted_proxies`` **or** ``trusted_proxy_depth``, not both. Private/loopback ranges in ``TRUSTED_PROXIES`` are always considered trusted. + +Optional settings +~~~~~~~~~~~~~~~~~ + +.. code:: python + + from castle.configuration import configuration + + # Override the API base URL (default: https://api.castle.io/v1) + # configuration.base_url = 'https://api.castle.io/v1' Signature --------- +Secure mode signs user identifiers on the server: + .. code:: python from castle.secure_mode import signature signature(user_id) -will create a signed user_id. - Exceptions ---------- -``CastleError`` will be thrown if the Castle API returns a 400 or a 500 -level HTTP response. You can also choose to catch a more `finegrained -error `__. +All exceptions inherit from ``CastleError``. The most useful ones: + +- ``ConfigurationError`` — the SDK is misconfigured (missing API secret, invalid URL, etc.) +- ``RequestError`` — network failure or timeout reaching Castle +- ``InvalidRequestTokenError`` — the request token is missing or invalid +- ``InvalidParametersError`` — 422 response with validation details +- ``RateLimitError`` — 429 response; back off and retry +- ``UnauthorizedError`` — 401; bad API secret +- ``InternalServerError`` — 5xx response from Castle +- ``WebhookVerificationError`` — webhook signature did not match + +The full list is in `castle/errors.py `_. From f45810b917e448e0e9f4f4fe0470799dc9eea474 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 1 Jun 2026 15:12:14 +0200 Subject: [PATCH 2/2] Update DEVELOPMENT and RELEASING docs for 7.x workflow Document Makefile targets, GitHub Actions CI, and clarify that GitHub releases and PyPI publish are separate steps. --- DEVELOPMENT.rst | 39 +++++++++++++++++++---------- RELEASING.rst | 65 ++++++++++++++++++++++++++++++++++++------------- 2 files changed, 74 insertions(+), 30 deletions(-) diff --git a/DEVELOPMENT.rst b/DEVELOPMENT.rst index 2ecf5de..f138658 100644 --- a/DEVELOPMENT.rst +++ b/DEVELOPMENT.rst @@ -1,3 +1,11 @@ +Development +=========== + +Requirements +------------ + +Python 3.9 or newer. The repo pins a version in ``.tool-versions``; with `asdf` installed, run commands via ``asdf exec`` (or ensure that Python is active in your shell). + Installation ------------ @@ -5,37 +13,42 @@ Installation $ git clone git@github.com:castle/castle-python.git $ cd castle-python - $ pip3 install -e ".[test,lint]" + $ make setup +``make setup`` installs the package in editable mode with test and lint extras (``pip install -e ".[test,lint]"``). Test ------------- +---- .. code-block:: console - $ python3 -m unittest castle.test + $ make test + +Runs ``python3 -m unittest -v castle.test``. CI runs the same suite on Python 3.9–3.13 via GitHub Actions (``.github/workflows/specs.yml``). Linting ------------- +------- .. code-block:: console - $ pip3 install ruff - $ ruff check castle - $ ruff format --check castle + $ make lint + +Runs ``ruff check`` and ``ruff format --check`` on ``castle/``. CI uses the same checks (``.github/workflows/lint.yml``). To auto-fix and format: .. code-block:: console - $ ruff check --fix castle - $ ruff format castle + $ make format Coverage ------------- +-------- .. code-block:: console - $ pip3 install coverage - $ coverage run -m unittest castle.test - $ coverage report + $ make coverage + +Makefile targets +---------------- + +``make help`` lists ``setup``, ``test``, ``lint``, ``format``, and ``coverage``. diff --git a/RELEASING.rst b/RELEASING.rst index 0d002ff..8e018e0 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -2,25 +2,56 @@ Releasing ========= #. Create release branch ``X.Y.Z`` from ``develop``. -#. Update ``VERSION`` in ``castle/version.py`` to the new version -#. Update the ``CHANGELOG.rst`` for the impending release -#. ``git commit -am "release X.Y.Z"`` (where X.Y.Z is the new version) -#. Push to Github, make PR to the develop branch, and when approved, merge. -#. Pull latest ``develop``, merge it to ``master``, and push it. -#. Make a release on Github from the ``master`` branch, specify tag as ``vX.Y.Z`` to create a tag. -#. ``git checkout master && git pull`` -#. ``rm -rf dist build`` -#. ``python3 -m build`` -#. ``twine upload dist/*`` +#. Update ``VERSION`` in ``castle/version.py`` to the new version. +#. Update ``CHANGELOG.rst`` for the impending release. +#. ``git commit -am "release X.Y.Z"`` (where ``X.Y.Z`` is the new version). +#. Push to GitHub, open a PR to ``develop``, and merge when approved. +#. On ``develop``: run ``make test`` and ``make lint`` (or confirm CI is green). +#. Pull latest ``develop``, merge into ``master``, and **push ``master`` to ``origin``**. +#. Create a GitHub release from ``master`` with tag ``vX.Y.Z`` (see below). +#. Publish to PyPI from ``master`` (see below). A GitHub release does **not** publish the package; PyPI is updated only via ``twine upload``. -When you change something in the README.rst make sure it is in the -correct format, as pypi will ignore the file if it is not valid. +GitHub release +-------------- -To validate the rendered metadata locally: +Create the release only after ``master`` on ``origin`` contains the release commit. -``pip3 install build twine`` +.. code-block:: console -``python3 -m build && twine check dist/*`` + gh release create vX.Y.Z \ + --title "Release X.Y.Z" \ + --notes-file release-notes.md -To upload to testpypi -``twine upload --repository-url https://test.pypi.org/legacy/ dist/*`` +Use ``--latest`` as a separate flag if you need to mark the release as Latest; do not put ``--latest`` in ``--title``. + +PyPI +---- + +From a clean checkout of ``master`` at the release commit: + +.. code-block:: console + + git checkout master && git pull + rm -rf dist build + pip install build twine + python3 -m build + twine check dist/* + twine upload dist/* + +README and metadata +------------------- + +When you change ``README.rst``, validate that PyPI will accept it: + +.. code-block:: console + + python3 -m build && twine check dist/* + +PyPI ignores ``README.rst`` if it is not valid reStructuredText. + +TestPyPI +-------- + +.. code-block:: console + + twine upload --repository-url https://test.pypi.org/legacy/ dist/*