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 |
|---|---|
|
|
|
|
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 |
|---|---|
|
No |
|
Passed through from the auth API. |
|
A |
|
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 |
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 |
|---|---|
|
Staff calls. Permission-checked. May use |
|
A supplier acting on themselves. Skips permission checks but is scoped to its own |
|
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_USERto the requested codes, so a super user always passes; -
an exception from the auth API sets a
500response and raisesStopExecution; -
no matching perm sets a
403response and raisesStopExecution.
Which endpoints require which codes:
| Endpoint | Codes |
|---|---|
|
|
|
|
|
|
|
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.