Metadata-Version: 2.4
Name: utic-invocation-settings
Version: 0.2.1
Summary: Public library for consuming encrypted Unstructured plugin invocation settings (AES-256-GCM + RSA-OAEP-256 envelope, decrypt + cache).
Requires-Python: >=3.11
Requires-Dist: cryptography>=43.0.1
Requires-Dist: pydantic<3.0.0,>=2.12.5
Description-Content-Type: text/markdown

# utic-invocation-settings

Public library for **consuming encrypted Unstructured plugin invocation settings** — the plugin-side
half of the cellular-dataplane "settings in the invoke payload" design. It reads the v1 settings
envelope (RSA-OAEP-256-wrapped **AES-256-GCM**), decrypts **inside the plugin** at invoke time, and
caches only previously authenticated envelopes.

It is deliberately **self-contained on `cryptography` + `pydantic`** — no dependency on any
private-feed package — so it can be published to public PyPI and imported by external plugin authors.

## Why

Under the cellular dataplane, a shared pod may serve multiple tenants, so a plugin identity decrypts
settings routed to that plugin rather than a shared service handing out plaintext. Settings arrive as an
opaque ciphertext envelope; this library turns that envelope into a plain settings object, verifying
integrity and never logging secrets.

The wire format is frozen in `cellular-dataplane/docs/envelope-contract-v1.md`. The producer
(Secrets Provider / operator) emits exactly that shape; this library is the reference consumer.

## Usage

```python
from utic_invocation_settings import (
    TTLCache, decrypt_settings, dimensions, extract_context, extract_envelope,
)

_settings_cache = TTLCache(ttl_seconds=300)     # keyed by full-envelope fingerprint
_key_cache = TTLCache(ttl_seconds=3600)         # keyed by recipient + encryption-key digest

def load_private_key(kid: str):
    # Load the RSA private key for this plugin identity's certificate from the mounted secret.
    ...

def on_invoke(body: dict):
    envelope = extract_envelope(body)            # None only when the caller sent no settings
    context = extract_context(body)              # None only when the caller sent no context

    if envelope is None:
        settings = load_legacy_job_settings_file()        # transitional: older caller
    else:
        settings = decrypt_settings(                      # an envelope that arrived is the
            envelope,                                     # only settings source for this
            private_key_loader=load_private_key,          # request — never fall back here
            settings_cache=_settings_cache,
            key_cache=_key_cache,
        )

    bind_dimensions(dimensions(context))    # e.g. utic-instrumentation; {} for an older caller
    return do_work(settings)
```

The fallback belongs on the **absent** branch only. Falling back after a failed decrypt would answer
a request configured for one tenant with whatever the pod booted with.

Every failure (unknown format, missing key, RSA/GCM failure, digest mismatch) raises a subclass of
`InvocationSettingsError` — it never returns partial or unverified plaintext.

### The two reserved fields

`invocation_settings` carries *what* to configure; `invocation_context` carries *who* the invocation
is for — the identity facets a shared pod can no longer read from its process environment. Both are
reserved, out-of-schema fields of the `/invoke` body, extracted directly from the parsed body rather
than declared as handler parameters, so nothing here depends on the serving wrapper.

Both fail closed on a present-but-invalid value: only an **absent** field signals "older caller,
use the legacy path". `invocation_context.schema_version` is validated, so an incompatible producer
surfaces at the first request instead of as quietly missing telemetry.

The context deliberately carries **no filesystem paths**. Where a plugin scratches to disk is its
own implementation detail — `tempfile` or uuid-named paths under a base the plugin chooses — never
caller-supplied input. A caller-supplied write path is a path-traversal surface that then needs
bounding, symlink re-checks, and escape tests; not shipping the knob is cheaper than hardening it.

## Security and cache notes

- This is encryption and integrity for the recipient, not producer authentication. A holder of the
  recipient's public key can create an envelope, so deployments must deliver envelopes over an
  authenticated control-plane path.
- `expires_at` and `credential_version` are plaintext advisory metadata outside the authenticated
  header. Use them for scheduling/freshness hints, never authorization decisions.
- `settings_digest` is a public, unsalted SHA-256 value and can link identical settings or support
  low-entropy guess confirmation. Treat envelopes as sensitive metadata.
- A decrypted-settings cache is keyed by the full authenticated-envelope fingerprint; the AES-key
  cache is keyed by `(kid, encryption_key_digest)`. `clear()` is not a revocation barrier for work
  already decrypting concurrently.

## Develop

```bash
make install     # uv sync
make test        # unit tests + coverage
make check       # ruff
```

**Note:** published to public PyPI on merge to main (see repo README).
