Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ jobs:
$required = @{
'epp-javascript.zip' = @('host.json', 'src/functions/SendOtp.js', 'node_modules/@azure/functions/package.json')
'epp-dotnet-source.zip' = @('host.json', 'dotnet.csproj', 'Program.cs', 'Functions/SendOtp.cs', 'Src/PhoneProviderBase.cs')
'epp-python-source.zip' = @('host.json', 'function_app.py', 'requirements.txt', 'src/dispatch.py')
'epp-python-source.zip' = @('host.json', 'function_app.py', 'requirements.txt', 'src/jwe.py', 'src/provider.py')
}
$checksums = foreach ($name in ($required.Keys | Sort-Object)) {
$path = Join-Path 'artifacts' $name
Expand Down
66 changes: 35 additions & 31 deletions docs/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,10 @@ success-looking status. Explicit `Block`/`StepUp` outcomes remain non-success re

## 3. Provider adapter contract

Each provider is one unit exposing three things:
Each provider is one unit that owns authentication requirements, request construction and response
mapping. JavaScript exposes these through:

- **`manifest`**: protocol facts only:
- **`manifest`**: protocol facts:
- `id`: provider id selected by `EPP_PROVIDER_NAME`; its complete request URL is `EPP_PROVIDER_ENDPOINT`
- `auth`: either `{ mode: 'apiKey', keyVaultSecretName, identityKeyVaultSecretName? }` or
`{ mode: 'oauth' }`; unsupported modes fail closed
Expand All @@ -236,12 +237,12 @@ Each provider is one unit exposing three things:
`providerHttpStatus`, optional `providerMessageId`, `providerStatusName`, `providerStatusCode`
and `providerStatusDescription` (snake_case attributes in Python, PascalCase in .NET).

The adapter reads its API-specific JSON and constructs a normalized `ParsedResponse` object:
[JavaScript](../javascript/src/functions/models.js), [Python](../python/src/models.py),
[.NET](../dotnet/Src/Models.cs). The engine reads named properties/attributes rather than provider JSON
or string-key response dictionaries. Optional values default to null/None; a status name takes precedence
over a code during outcome mapping, as before. Custom Python adapters must return `ParsedResponse`,
not the former dictionary.
Python and .NET use provider classes instead of a manifest-driven engine. Each class declares its
provider id and authentication mode, owns its credential secret names or OAuth acquisition, builds
its private outbound request, and maps provider JSON directly to `ProviderResult`:
[Python](../python/src/models.py), [.NET](../dotnet/Src/Models.cs). `ProviderResult` keeps `Outcome`
coarse and stable while `FailureReason` carries one fixed safe diagnostic classification. Optional
values default to null/None and raw provider JSON never enters the shared orchestration layer.

This model is internal: do not serialize it into the endpoint response or logs. The
[request logger](#application-logs) selects only the provider HTTP status, a status found in the
Expand All @@ -252,12 +253,12 @@ Provider requests are serialized only when building the outbound HTTP body; inco
is parsed once and normalized inside its adapter. No serialization framework or provider-specific
class hierarchy is required.

Adapters require registration in the chosen runtime. Consult the selected adapter and its manifest
for required credentials and options: the manifest declares authentication and protocol mappings;
the implementation reads adapter-specific options from app settings. Individual API contracts remain
Providers require registration in the chosen runtime. Consult the selected provider for required
credentials and options. JavaScript declares them in its manifest; Python and .NET declare them on
the provider implementation. Individual API contracts remain
in the adapters; the [onboarding credential naming table](ONBOARDING.md#provider-credential-names)
lists the exact manifest secret names for provisioning and authorized local tests. Keep that table
aligned with the manifests; never include secret values in documentation or the settings sample.
lists the exact secret names for provisioning and authorized local tests. Keep that table aligned
with the providers; never include secret values in documentation or the settings sample.

### Telesign EPP integration

Expand Down Expand Up @@ -306,7 +307,7 @@ Set by provisioning. **Identical names across all languages.**
| `KEY_VAULT_URL` | Key Vault URI for API-key providers |
| `AZURE_CLIENT_ID` | set for a user-assigned managed identity |

Telesign credentials live in **Key Vault**, under the names in its manifest, and are fetched via
Telesign credentials live in **Key Vault**, under the names owned by its provider implementation, and are fetched via
managed identity. Soprano exchanges an outbound managed-identity assertion for a token in the
configured provider tenant/scope. Do not put provider secrets in code or app settings.

Expand Down Expand Up @@ -345,11 +346,11 @@ when credentials are fetched, not the HTTP/nonce contract, caller authentication
selection or provider request format. The decryption-key Key Vault reference remains separate and
is still resolved by the platform; this cache does not rotate or replace JWE keys.

The selected provider's manifest determines which of two concrete cache classes is created:
The selected provider's authentication requirements determine which concrete cache is created:

| Authentication mode | Cache | Acquisition |
|---|---|---|
| `apiKey` | `ApiKeyCache` | Fetch the manifest's Key Vault secrets using managed identity. Cache the complete key/customer-ID bundle in .NET `MemoryCache`, JavaScript `lru-cache`, or Python `cachetools.TTLCache`. |
| `apiKey` | `ApiKeyCache` | Fetch the provider's Key Vault secrets using managed identity. Cache the complete key/customer-ID bundle in .NET `MemoryCache`, JavaScript `lru-cache`, or Python `cachetools.TTLCache`. |
| `oauth` | `AccessTokenCache` | Reuse Azure Identity's managed-identity and client-assertion credentials and their SDK caches. Retain only the latest usable provider token. No Key Vault access. |

Only the selected cache starts. Its credential configuration is bound on first use; app-setting
Expand Down Expand Up @@ -390,8 +391,9 @@ Per-request `providerCredentialElapsedMs` continues to measure the caller's reso
set, else system-assigned). No static credentials.
- **Privacy**: never log phone numbers, passcodes, nonce values, bearer tokens, API keys, JWE headers/payloads,
raw exceptions, provider descriptions/responses or endpoint query strings. There is no plaintext diagnostic
override. Each handler emits separate service events. JavaScript and Python also emit one
[request summary](#application-logs); .NET uses standard structured `ILogger` events and scopes instead.
override. Each handler emits separate service events. JavaScript also emits one
[request summary](#application-logs); Python and .NET use standard structured logging events and
immutable request context/scopes instead.
Generated Function IDs remain distinguished from raw Microsoft/provider support IDs. Original wire IDs
and the required nonce echo remain unchanged. Support IDs can correlate customer activity; restrict
log access and retention. Endpoint logs contain only scheme, host/port and API path, never userinfo,
Expand All @@ -414,18 +416,20 @@ Per-request `providerCredentialElapsedMs` continues to measure the caller's reso

### Application logs

JavaScript and Python emit JSON records with the shared fields described below. Service events have
`logType: "service"` and an individual `eventName`; each invocation ends with one
`logType: "request"`, `eventName: "request_completed"` summary.
JavaScript emits JSON service records and one `request_completed` request summary. Python uses the
standard `logging` pipeline: `otp_log.py` defines fixed event IDs, names, levels and fields, while an
immutable `LoggerAdapter` context supplies the Function and Microsoft trace identifiers. The
configured logging provider owns Python output formatting and export; Python does not manually
serialize a mutable request summary.

.NET uses the standard `ILogger` pipeline instead of manually serializing JSON. `OtpLog` defines
source-generated events with stable IDs and names, while `ILogger.BeginScope` supplies
Likewise, .NET uses the standard `ILogger` pipeline instead of manually serializing JSON. `OtpLog`
defines source-generated events with stable IDs and names, while `ILogger.BeginScope` supplies
`FunctionName`, `FunctionRequestId`, `FunctionInvocationId`, `MsClientRequestId`,
`MsCorrelationId` and `MsCorrelationIdSource`. The configured logging provider owns output
formatting and export. .NET emits `request_completed` as an ordinary typed event rather than a
mutable comprehensive summary.

A successful .NET live request emits:
A successful Python or .NET live request emits:

`request_received`, `payload_validated`, `delivery_context_decrypted`, `provider_selected`,
`provider_credential_resolution_started`, `provider_credential_resolved`,
Expand All @@ -445,13 +449,13 @@ bodies, decrypted delivery fields, credentials, provider response bodies, query
exception messages. Endpoint values contain only scheme, host/port and path. Evaluation omits all
provider events and emits `evaluation_completed`.

A successful JavaScript or Python live request emits these separate service events, followed by the
request summary:
A successful live request emits these separate service events. JavaScript then emits its request
summary; Python and .NET finish with the structured `request_completed` event:

| Service event | Safe information recorded |
|---|---|
| `request_received` | Function invocation and available raw Microsoft trace IDs under their `x-ms-*` names; no raw body or arbitrary headers. |
| `envelope_validated` | Allowlisted body metadata: validated `envelopeType`, normalized `channel`, `evaluation`, optional `ttlSeconds`, and `encryptedDeliveryContextPresent: true`. |
| `envelope_validated` / `payload_validated` | Allowlisted body metadata: validated payload type, normalized `channel`, `evaluation` and optional `ttlSeconds`. |
| `delivery_context_decrypted` | Decryption completed; no plaintext fields, JWE or key ID. |
| `provider_selected` | Registered provider and its authentication mode. |
| `provider_credential_resolution_started` | OAuth client-assertion or Key Vault credential source, with explicitly named raw OAuth application/identity/tenant IDs. |
Expand All @@ -460,7 +464,7 @@ request summary:
| `provider_request_built` | Allowlisted HTTP method, final endpoint scheme/host/port/API path, HTTPS and disabled redirects; no query string, authorization headers or body. |
| `provider_request_started` | The outbound send is beginning, with method, sanitized endpoint and timeout. |
| `provider_response_received` | Actual upstream HTTP status; emitted before response-body reading completes. |
| `provider_response_processed` | Mapped provider status/outcome, raw provider message/reference ID, duration and resulting Function HTTP status. |
| `provider_response_processed` | Mapped provider status/outcome, fixed failure classification and duration. Raw provider descriptions and bodies are excluded. |
| `response_prepared` | Response status and booleans indicating nonce/correlation inclusion, not their values or the response body. |

Body metadata is built from validated fields, **not** from a body dump with a few sensitive
Expand All @@ -485,9 +489,9 @@ values are represented as `other` without changing the request sent.
`response_prepared` is emitted on success **and failure** immediately before returning the handler
response. It does not claim the host has serialized/transmitted that response or Microsoft received
it; consult platform request telemetry for transport completion. Evaluation emits
`evaluation_completed` instead of provider events, then `response_prepared` and the summary,
`evaluation_completed` instead of provider events, then `response_prepared` and `request_completed`,
without resolving provider configuration, credentials or HTTP. Failures emit their own stage event,
such as `decryption_failed`, `provider_credentials_failed` or `provider_transport_failed`.
`request_failed`, with a fixed failure stage and reason.
A parsed provider rejection uses `provider_response_processed` with its non-success outcome and
fixed failure reason.

Expand Down Expand Up @@ -523,7 +527,7 @@ These are tracing fields, not authentication assertions. In particular, an incom
does not become a trusted tenant identity in logs. The existing wire correlation precedence,
provider request IDs and public responses are unchanged.

The JavaScript and Python request summary contains:
The JavaScript request summary contains:

| Fields | Purpose |
|---|---|
Expand Down
6 changes: 5 additions & 1 deletion package-python.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ try {
New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null
Copy-Item -LiteralPath $file.FullName -Destination $destination
}
if (-not (Test-Path -LiteralPath (Join-Path $stage 'src/dispatch.py'))) { throw 'Missing Python application source.' }
foreach ($name in @('function_app.py', 'src/jwe.py', 'src/provider.py')) {
if (-not (Test-Path -LiteralPath (Join-Path $stage $name))) {
throw "Missing Python application source: $name."
}
}
$zip = Join-Path $temporary 'app.zip'
[IO.Compression.ZipFile]::CreateFromDirectory($stage, $zip)
New-Item -ItemType Directory -Path (Split-Path $archive) -Force | Out-Null
Expand Down
33 changes: 17 additions & 16 deletions python/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# External Phone Provider Function: Python (v2 model)

Implements the shared [contract](../docs/CONTRACT.md) with one dispatch engine and one selected
provider per deployment. Target: Python 3.11, Azure Functions v4, Python v2 programming model.
Implements the shared [contract](../docs/CONTRACT.md) with a direct Azure Function flow and one
selected provider per deployment. Target: Python 3.11, Azure Functions v4, Python v2 programming model.

## Setup and deployment

1. Follow [customer onboarding](../docs/ONBOARDING.md). Set `EPP_PROVIDER_NAME` to the selected
adapter's registered manifest id (`<adapter-id>` is only a placeholder).
2. Consult the selected adapter and its manifest in [src/providers/](src/providers/) for required
credentials and options. Store credentials in Key Vault under the declared secret names, grant
provider id (`<adapter-id>` is only a placeholder).
2. Consult the selected provider in [src/providers/](src/providers/) for required credentials and
options. Store credentials in Key Vault under the provider-owned secret names, grant
the Function's managed identity *Key Vault Secrets User*, and configure the matching endpoint/options.
3. Base private local settings on [../docs/local.settings.sample.json](../docs/local.settings.sample.json),
replacing placeholders and selecting `FUNCTIONS_WORKER_RUNTIME=python`. Put settings at the
Expand Down Expand Up @@ -49,7 +49,7 @@ For live delivery, add `EPP_PROVIDER_NAME`, the complete selected `EPP_PROVIDER_
matching provider authentication settings to `Values`.
Add `EPP_PROVIDER_ACCOUNT_NAME` and any adapter-specific options only when required. Keep values as
strings, including optional `EPP_PROVIDER_TIMEOUT_MS: "1500"`. Replace placeholders; provider API
keys belong in the manifest-named Key Vault secrets, not this file. See the
keys belong in the provider-named Key Vault secrets, not this file. See the
[complete variable table](../README.md#configure-environment-variables).

Core Tools loads `Values` into `os.environ`. Direct Python execution and pytest do not automatically
Expand Down Expand Up @@ -90,7 +90,7 @@ six-digit numeric run that is not part of a longer number and repeats the comple

## Source

Worker initialization selects `ApiKeyCache` or `AccessTokenCache` from the provider manifest's auth mode.
Worker initialization selects `ApiKeyCache` or `AccessTokenCache` from the provider's credential specification.
Only the selected cache starts: API keys use Key Vault and `cachetools.TTLCache`; access tokens use
the MI/Entra SDKs without Key Vault. One daemon loop polls every 30 seconds. Configuration changes
require restart. Callers can stop waiting without abandoning shared reads; synchronous SDK I/O uses connect/read
Expand All @@ -101,16 +101,17 @@ local evaluation without background credential acquisition.

| Source | Purpose |
|---|---|
| [function_app.py](function_app.py) | HTTP handler and adapter registration |
| [function_app.py](function_app.py) | Typed request orchestration and direct provider selection |
| [src/config.py](src/config.py) | Shared deployment settings |
| [src/models.py](src/models.py) | Envelope, delivery-context, dispatch and normalized `ParsedResponse` dataclasses |
| [src/dispatch.py](src/dispatch.py) | Boundary validation, JWE, provider registry and outcome mapping |
| [src/credentials.py](src/credentials.py) | `ApiKeyCache`, `AccessTokenCache` and their shared refresh coordinator |
| [src/request_log.py](src/request_log.py) | Request-scoped [service events and summaries](../docs/CONTRACT.md#application-logs) with explicit ID sources |
| [src/providers/](src/providers/) | Adapter manifests and API-specific implementations |
| [src/models.py](src/models.py) | Typed Entra payload, delivery context, provider request and result dataclasses |
| [src/jwe.py](src/jwe.py) | Pinned JWE decryption and typed delivery-context conversion |
| [src/provider.py](src/provider.py) | Shared HTTPS transport, timeout handling and endpoint status mapping |
| [src/credentials.py](src/credentials.py) | `CredentialTokenService`, `ApiKeyCache` and `AccessTokenCache` |
| [src/otp_log.py](src/otp_log.py) | Fixed standard-logging event definitions and immutable request context |
| [src/providers/](src/providers/) | Provider-owned credentials, requests and response mapping |
| [src/secrets.py](src/secrets.py) | Key Vault transport; bundle caching belongs to `ApiKeyCache` |

Add and register an adapter without adding provider-specific branches to the shared pipeline.
Return `ParsedResponse` from `parse_response` using named fields; the engine reads attributes such as
`parsed.provider_status_name`. Raw provider JSON remains local to the adapter, not a shared model hierarchy.
Add a provider by subclassing `PhoneProviderBase`, declaring its credential specification, and
implementing `build_request` and `map_response`. Return `ProviderResult` with the coarse endpoint
outcome and a fixed safe failure classification. Raw provider JSON remains local to the provider.
See [production limitations](../docs/CONTRACT.md#production-limitations) before production use.
Loading
Loading