Skip to main content

cr-service

WorkOS authentication, logging and observability for Climate Resource FastAPI services.

Services generated from copier-python-service each carried their own copy of the logging, Sentry, tracing, metrics and profiling modules, and each service that needed WorkOS wrote its own token verifier. This package holds one version of all of it, so a fix lands everywhere with a dependency bump.

What it provides:

  • WorkOS access-token verification, with the production and staging issuers and JWKS URLs built in.
  • FastAPI dependencies for the caller, required permissions and feature flags.
  • Machine-to-machine (client credentials) tokens, allowed per client id.
  • A request-scoped log context that the caller's ids are added to.
  • The wide-event access log, correlation headers, Sentry, OpenTelemetry, Prometheus, Pyroscope and health probes.
  • A token factory that signs real tokens, so tests go through the production verifier.

Quick start

import fastapi
from fastapi.middleware.cors import CORSMiddleware

import cr_service
from cr_service.auth import CurrentPrincipal, require_permission

from my_service import __version__

SERVICE = cr_service.ServiceInfo(name="my-service", version=__version__)

# Capture startup logs before uvicorn applies its own config.
cr_service.configure_logging(SERVICE)


class Settings(cr_service.ServiceSettings):
    cors_allow_origins: tuple[str, ...] = ()


def build_app() -> fastapi.FastAPI:
    settings = Settings()
    app = fastapi.FastAPI(title="My Service", version=__version__)
    app.add_middleware(CORSMiddleware, allow_origins=settings.cors_allow_origins, allow_headers=["*"])
    cr_service.setup(app, service=SERVICE, settings=settings)

    @app.get("/v1/me")
    def me(principal: CurrentPrincipal) -> dict[str, str]:
        return {"id": principal.id}

    @app.post("/v1/things", dependencies=[fastapi.Depends(require_permission("things:write"))])
    def create_thing() -> None: ...

    return app

setup installs, in order:

  • logging and Sentry, from the settings,
  • the Pyroscope profiler, when PYROSCOPE_SERVER_ADDRESS is set,
  • the wide-event, route-tag and forwarded-prefix middleware,
  • /livez and /readyz, running any readiness_checks passed in,
  • /metrics,
  • OpenTelemetry, when OTEL_EXPORTER_OTLP_ENDPOINT is set,
  • auth, unless auth=None is passed.

Add the service's own middleware, such as CORS, before calling setup, so the wide event and the trace wrap it.

Configuration

Settings come from the environment, or from .env. Field names map straight to variable names.

Variable Default Notes
ENVIRONMENT local local, staging, preview or production.
LOG_LEVEL INFO
LOG_FORMAT json text for local development.
SENTRY_DSN unset Sentry is off when unset.
SENTRY_TRACES_SAMPLE_RATE 0.0
SENTRY_RELEASE <service>@<version> The deploy sets it to the revision.
AUTH_PROVIDER workos local lets every request in as a fixed identity. Only allowed when ENVIRONMENT=local.
AUTH_ENFORCE true false is shadow mode: refusals are logged, not enforced. Not allowed in production.
AUTH_LOCAL_PERMISSIONS empty Permissions of the local identity, comma separated.
WORKOS_CLIENT_ID unset This service's WorkOS application id. Required with AUTH_PROVIDER=workos.
WORKOS_ADDITIONAL_CLIENT_IDS empty Other applications whose user tokens are accepted, such as a CLI.
WORKOS_REQUIRED_FEATURE_FLAG unset Feature flag the user's organisation must have, such as app:bookshelf.
WORKOS_ALLOWED_ORGANIZATION_IDS empty Organisations allowed in. Empty allows any.
WORKOS_MACHINE_CLIENTS {} JSON mapping machine client ids to the permissions each is granted.
WORKOS_ENVIRONMENT from ENVIRONMENT production or staging, to override the mapping below.
WORKOS_API_KEY unset Secret. Only needed for WorkOSClient.

Every variable except WORKOS_API_KEY is public, so it belongs in the deploy config rather than in chamber.

WorkOS environments

ENVIRONMENT=production verifies tokens from the production WorkOS environment. Every other environment, local included, uses staging. The values are in cr_service/workos.py:

Production Staging
User-token issuer https://auth-api.climateresource.com.au/user_management/client_01KABZE0SFNZXEYZ337HSVBZ36 https://auth-api.climateresource.com.au/user_management/client_01KABZE0E62YS9H7BMV6YZGMD1
User-token JWKS https://auth-api.climateresource.com.au/sso/jwks/client_01KABZE0SFNZXEYZ337HSVBZ36 https://auth-api.climateresource.com.au/sso/jwks/client_01KABZE0E62YS9H7BMV6YZGMD1
Machine-token issuer https://auth.climateresource.com.au https://balanced-universe-28-staging.authkit.app

Each environment signs every application's tokens with one key, so the JWKS is the same whichever application a token was minted for. The issuer names the environment's default application, not the service's own. settings.public_auth_config() returns what a browser needs to sign in, for a /config endpoint.

Authentication

CurrentPrincipal answers 401 without a valid token. OptionalPrincipal is None without a token, and still answers 401 for an invalid one. require_permission(...) and require_feature_flag(...) answer 403. Guard on permissions, never on role names.

A verified token becomes a Principal:

  • kind is user, machine, local, or anonymous for a caller let through by shadow mode.
  • id is the WorkOS user id, or the machine client id.
  • organization_id, permissions, feature_flags, role and roles come from the token.
  • email, first_name, last_name and organization_name come from the Climate Resource JWT template.
  • claims holds every verified claim.

User tokens must be RS256, signed by the environment key, carry the environment issuer, and not be expired. When a token names the application it was minted for, that must be WORKOS_CLIENT_ID or one of WORKOS_ADDITIONAL_CLIENT_IDS. Machine tokens are only accepted from client ids listed in WORKOS_MACHINE_CLIENTS, and their permissions come from that list, never from the token.

The JWKS is cached for an hour. An unknown key id triggers one early refetch, at most once a minute. When a refetch fails the stale keys keep serving, and with no keys at all the request gets a 503 rather than a 401.

Extending auth

setup takes an AuthConfig, whose fields are all optional:

  • on_success and on_failure are callbacks run after each attempt, for example to write an audit log.
  • authenticator replaces the one built from the settings, so a service with its own token types, such as Bookshelf's agent tokens, can wrap build_authenticator(settings).
  • resource_metadata_url adds an RFC 9728 resource_metadata hint to 401 responses.
from cr_service import AuthConfig


def audit_success(request, principal):
    audit_log.info("authn_login_success", extra={"user_id": principal.id})


cr_service.setup(app, service=SERVICE, settings=settings, auth=AuthConfig(on_success=[audit_success]))

The callbacks run after the built-in ones. Those bind user_id, organization_id, auth_kind and auth_client_id to the log context, set the Sentry user id and organization_id tag, stamp enduser.id on the span, and log an auth_failed record with the reason for each refusal. Emails and names are never attached.

Logging

cr_service.bind(**fields) adds fields to the current request's log context. Every record logged while the request runs carries them, and so does the http_request wide event emitted when it finishes. The middleware opens a fresh context per request, holding its request_id. Outside a request, with cr_service.log_scope(job_id=...): opens one.

The JSON line holds ts, level, logger and message, then service, version, commit, env and instance_id, then the request context, then the record's extra. A 422's validation errors land on the wide event as validation_errors.

configure_logging(SERVICE) at import reads LOG_LEVEL, LOG_FORMAT and ENVIRONMENT through ServiceSettings. It only replaces its own root handler, so handlers added by anything else stay. A service with its own logging stack can skip configure_logging and keep what it has. The context is still available: structlog users add cr_service.logging_config.merge_request_context to their processors, and anything else can read cr_service.get_context().

Other helpers

  • cr_service.auth.workos_api.WorkOSClient fetches users and organisations with WORKOS_API_KEY, and pages through an organisation's members.
  • cr_service.tracing.instrument_sqlalchemy(engine) adds a span per statement. It needs opentelemetry-instrumentation-sqlalchemy installed.
  • cr_service.tracing.set_span_attributes(...) and current_trace_context() work without the tracing extra.

Testing a service

import pytest
from fastapi.testclient import TestClient

from cr_service.auth.testing import TokenFactory


@pytest.fixture
def tokens(settings):
    return TokenFactory.for_settings(settings)


@pytest.fixture
def client(settings, tokens):
    app = build_app()
    tokens.install(app, settings)
    return TestClient(app)


def test_create_thing(client, tokens):
    response = client.post("/v1/things", headers=tokens.headers(permissions=["things:write"]))
    assert response.status_code == 200

tokens.install swaps in a verifier that trusts the factory's key, keeping the app's AuthConfig callbacks. tokens.user_token(...) and tokens.machine_token(...) sign tokens shaped like the real ones, and tokens.sign(claims) signs anything, for malformed-token tests.

Migrating a templated service

  1. Add cr-service[tracing,profiling] to the dependencies.
  2. Delete logging_config.py, middleware.py, sentry.py, tracing.py, metrics.py, profiling.py and routes/health.py.
  3. Make Settings subclass cr_service.ServiceSettings and drop the fields it now provides.
  4. Replace the body of build_app with the quick-start shape above.
  5. Replace any hand-written WorkOS verifier with the dependencies, and move its variables to the names above.
  6. Regenerate docs/openapi.json, which gains the WorkOS bearer security scheme.

Development

make virtual-environment
make test
make checks

Each pull request adds a changelog fragment, see changelog/README.md. Releases go through the Bump version workflow, and tags publish to PyPI.

Metadata

Release files for cr-service 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cr-service 0.2.0
File Size Uploaded
cr_service-0.2.0.tar.gz 124.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for cr-service 0.2.0
File Interpreter ABI Platform
cr_service-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 163.3 kB

Release files / cr_service-0.2.0.tar.gz

Download URL cr_service-0.2.0.tar.gz
Size 124.5 kB
Tags Source
SHA-256 checksum
How to use checksums
74f5bd14d020ffeb0794d7cb400e4ffa3cc0e6f3b314dfaa33edbe6e06f8a77f
BLAKE2b-256 checksum
How to use checksums
f2ba99977da530c169232528c2bc79e0372e6a355af9c80c35af7f6d6f723eb5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / cr_service-0.2.0-py3-none-any.whl

Download URL cr_service-0.2.0-py3-none-any.whl
Size 38.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5f5024e18a9dd0ee70c097d719302ccba9088785a68515851b90f5df8eab0c9a
BLAKE2b-256 checksum
How to use checksums
b19684c03238e08d19d2d1b8aaa7258d70a8ee295bb7aa9a15786e36d38c1a2b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

0.5.0

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page