Auth & permissions

Three mechanisms establish trust: a session token for humans and services, a process source that says where a request came from, and an HMAC signature for the vendor webhook.

components/auth/payability_auth_service.py

Method Call

validate_auth_token(auth_token)

GET {PAYABILITY_INTERNAL_API_URL}/internal/token/validate with X-Session-Token; returns the auth payload (login_id, source, optional supplier_key).

generate_auth_token()

GET /internal/auth/login with the Basic PAYABILITY_AUTH_TOKEN; returns a token. (Service login; not used on the request path.)

components/auth/jwt_authorizer.py

The FastAPI dependency guarding every authenticated route.

class JWTAuthorizer:
    def __init__(self, auth_token: str = Security(APIKeyHeader(name='x-session-token', auto_error=False))):
        self._auth_service = PayabilityAuthService()
        self._auth_token = auth_token

        self.auth_details = {}
        self.data = self.verify_jwt()

Flow: validate the token against the auth API → resolve ProcessSource from the source claim → decode the JWT with verify_signature: False to read login_key → return an AuthValidateResponse.

Accessors: get_source(), get_login_key(), get_login_id(), get_supplier_key().

Error mapping:

Status Cause

400

No source in the auth response.

401, 404 (and 400)

Passed through from the auth API.

409

A KeyError reading the auth payload.

500

Anything else, including auth-API 5xx and unexpected errors.

The JWT signature is deliberately not verified here — authenticity comes from the auth API round trip; the local decode only extracts login_key. The same decode helper exists on BaseEndpointHandler.decode_jwt.

components/process_source.py

class ProcessSource(Enum):
    SUPPLIER_DASHBOARD = 'supplier_dashboard'
    INTERNAL = 'internal'
    ONBOARDING = 'onboarding'

The source is carried on the token, persisted on TaskExecutionTable rows, and drives two business rules:

Source Treatment

internal

Staff calls. Permission-checked. May use force_new_execution, read results, purge results, override final statuses, and create Entrust sessions for anyone. Unlimited Entrust sessions.

supplier_dashboard

A supplier acting on themselves. Skips permission checks but is scoped to its own supplier_key; capped at 3 process executions per supplier + process and 3 Entrust sessions per person; blocked from the internal-only endpoints.

onboarding

Self-service origin used by the supplier onboarding flow, added for Entrust. No permissions required, must name the target supplier and owner, and has its own 3-sessions-per-person budget so onboarding cannot exhaust a supplier’s later attempts.

components/permissions/permissions.py

class Permissions:
    class PermCode:
        DEV = 'DEV'
        FINANCE = 'FINANCE'
        REST_UW_ACCESS = 'REST_UW_ACCESS'
        UNDERWRITING = 'UNDERWRITING'
        INT_SUPER_USER = 'INT_SUPER_USER'

    def check_permissions(self, login_key: str, perm_codes: List[str]) -> bool:
        user_perms = self.payability_internal_api.get_user_perms(login_key=login_key)

        return any(user_perm['permCode'] in perm_codes for user_perm in user_perms)

BaseEndpointHandler.check_permissions(login_key, perm_codes, process_source) wraps it:

  • returns immediately for SUPPLIER_DASHBOARD;

  • appends INT_SUPER_USER to the requested codes, so a super user always passes;

  • an exception from the auth API sets a 500 response and raises StopExecution;

  • no matching perm sets a 403 response and raises StopExecution.

Which endpoints require which codes:

Endpoint Codes

POST /tasks/execute

DEV, FINANCE (+ INT_SUPER_USER)

POST /entrust/sessions (internal source only)

DEV, FINANCE (+ INT_SUPER_USER)

GET /tasks/result?detailed=true

DEV, FINANCE, REST_UW_ACCESS (+ INT_SUPER_USER); without them CLEAR ID payloads are masked and other processes are refused

GET /tools, GET /tasks/{id}, DELETE /tasks/result, PUT …/final_statuses

no perm check — gated by source instead

UNDERWRITING is defined but not currently used by any endpoint.

Webhook signature

POST /entrust/webhook cannot present a Payability token, so it lives on a router without the JWTAuthorizer dependency and verifies an HMAC instead:

expected_signature = hmac.new(
    key=webhook_token.encode('utf-8'),
    msg=self.__raw_body,
    digestmod=hashlib.sha256
).hexdigest()

return hmac.compare_digest(expected_signature, self.__signature)

The secret is ENTRUST_WEBHOOK_TOKEN. An unset secret is treated as a verification failure (and logged as a configuration error), never as a bypass. The HMAC must be computed over the exact received bytes, which is why the route reads the raw body. See api/entrust.adoc#webhook.

StopExecution

interface/web_api/endpoint_handling/base.py defines StopExecution, a control-flow exception: a handler sets self.response and raises it, and handle() catches it to return that response. It is not an error type — it is how the handlers implement "answer now, skip the rest", including the dedup short-circuit in the Entrust session endpoint.