Skip to content

Partner API Middleware

Partner API middleware handles versioning, access control, and audit logging for third-party integrations. Partners access data through versioned endpoints with OAuth scope-based permissions.

AliasClassPurpose
partner.requests_loggerPartner\LogRequestsAudit logging for modifications
partner.access_permissionPartner\CheckAccessPermissionPartner access validation
partner.supported_versionsPartner\CheckEndpointVersionAPI version compatibility

All partner middleware is in app/Http/Middleware/Partner/:

  • LogRequests.php
  • CheckAccessPermission.php
  • CheckEndpointVersion.php

File: app/Http/Middleware/Partner/CheckEndpointVersion.php

Validates that the partner is using a supported API version for the endpoint.

final class CheckEndpointVersion
{
public function handle(Request $request, Closure $next, string ...$supported_versions): Response
{
$request_version = PartnerApiUtils::validateAndGetRequestVersion($request);
if (!in_array($request_version, $supported_versions, true)) {
$supported_versions_string = implode(', ', $supported_versions);
throw new UnprocessableEntityHttpException(
"The rc-version [{$request_version}] is not available for this endpoint. Supported versions: [{$supported_versions_string}]"
);
}
return $next($request);
}
}
  • Uses PartnerApiUtils::validateAndGetRequestVersion($request) to get and validate version
  • Throws UnprocessableEntityHttpException (422) for unsupported versions
  • Compares against provided $supported_versions using strict comparison
app/Support/PartnerApiUtils.php
final class PartnerApiUtils
{
public const string VERSION_20210416 = '2021-04-16';
public const string VERSION_20230516 = '2023-05-16';
public const array AVAILABLE_VERSIONS = [
self::VERSION_20210416,
self::VERSION_20230516,
];
private const string API_VERSION_LABEL = 'rc-version';
public static function validateAndGetRequestVersion(Request $request): string
{
// Validates rc-version header format and value
$validator = Validator::make($request->headers->all(), [
self::API_VERSION_LABEL => 'date_format:Y-m-d|in:' . implode(',', self::AVAILABLE_VERSIONS),
]);
// Returns validated version or throws
}
}
rc-version: 2023-05-16
// All supported versions
Route::group([
'middleware' => 'partner.supported_versions:' . implode(',', PartnerApiUtils::AVAILABLE_VERSIONS)
], function () {
Route::get('households', [PartnerHouseholdController::class, 'index']);
});
// Specific version only
Route::group([
'middleware' => 'partner.supported_versions:' . PartnerApiUtils::VERSION_20230516
], function () {
Route::get('households/{household}/details', [PartnerHouseholdController::class, 'details']);
});
{
"message": "The rc-version [2020-01-01] is not available for this endpoint. Supported versions: [2021-04-16, 2023-05-16]"
}

HTTP Status: 422 Unprocessable Entity


File: app/Http/Middleware/Partner/CheckAccessPermission.php

Validates that the partner has permission to access the requested resource via OAuth client.

final class CheckAccessPermission
{
public function handle(Request $request, Closure $next): Response
{
$user = Auth::getUserOrFail();
$locality_advisor = LocalityManager::getAdvisorOrFail();
// Check if locality advisor matches authenticated user
if ($locality_advisor->id !== $user->id) {
return response(status: Response::HTTP_NOT_FOUND);
}
$oauth_access_token = OauthAccessToken::findOrFail($user->token()->id);
$oauth_client = $oauth_access_token->client;
// Check if advisor can use this OAuth client
if (!$user->advisor->canUseOauthClient($oauth_client)) {
throw new AccessDeniedHttpException("You don't have permission to use the API.");
}
return $next($request);
}
}
  • Uses Auth::getUserOrFail() to get authenticated user
  • Uses LocalityManager::getAdvisorOrFail() to get locality advisor
  • Returns 404 Not Found (not 403) if locality advisor doesn’t match user
  • Gets OAuth client from OauthAccessToken model
  • Checks $user->advisor->canUseOauthClient($oauth_client) for permission
Route::group([
'middleware' => ['auth:api', 'partner.access_permission']
], function () {
Route::get('advisors/{advisor}', [PartnerAdvisorController::class, 'show']);
Route::get('households/{household}', [PartnerHouseholdController::class, 'show']);
});
// OAuth client not allowed
{
"message": "You don't have permission to use the API."
}

HTTP Status: 403 Forbidden

For locality mismatch: Empty response with 404 Not Found


File: app/Http/Middleware/Partner/LogRequests.php

Logs partner API requests for audit purposes using FileLog package.

final class LogRequests
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Only log for partner host AND write operations
if (str_starts_with($request->getHost(), 'partner')
&& in_array($request->method(), [Request::METHOD_POST, Request::METHOD_PUT, Request::METHOD_DELETE], true)) {
self::logRequest($request);
}
return $response;
}
private static function logRequest(Request $request): void
{
$bearer_token = $request->bearerToken();
if ($bearer_token === null) {
throw new UnauthorizedHttpException('Bearer');
}
// Decode JWT to get user claims
$user_claims = \Safe\json_decode(
JWT::urlsafeB64Decode(explode('.', $bearer_token)[1]),
true
);
$log_data = [
'advisor_id' => $user_claims['sub'],
'body' => $request->json()->all(),
'oauth_client_id' => $user_claims['aud'],
'method' => $request->method(),
'route_name' => $request->route()->getName(),
'version' => $request->header('rc-version'),
];
FileLog::setLocation('audit');
FileLog::write(
['partner_api', Crypt::decryptId($log_data['advisor_id']), $log_data['oauth_client_id'], Date::now()->format('Y-m-d')],
$log_data['method'],
$log_data,
);
FileLog::resetLocation();
}
}
  • Only logs when host starts with ‘partner’ AND method is POST/PUT/DELETE
  • Decodes JWT to extract user claims (sub, aud) - does NOT use $request->user()
  • Uses FileLog package (not database model) for audit logging
  • File path: audit/partner_api/{decrypted_advisor_id}/{oauth_client_id}/{date}.log
  • Throws UnauthorizedHttpException if no bearer token
FieldSourceDescription
advisor_idJWT sub claimEncrypted advisor ID
body$request->json()->all()Request body (raw, not sanitized)
oauth_client_idJWT aud claimOAuth client ID
method$request->method()HTTP method
route_name$request->route()->getName()Named route
versionrc-version headerAPI version
Route::group([
'middleware' => ['auth:api', 'partner.requests_logger']
], function () {
Route::post('households', [PartnerHouseholdController::class, 'store']);
Route::put('households/{household}', [PartnerHouseholdController::class, 'update']);
Route::delete('households/{household}', [PartnerHouseholdController::class, 'destroy']);
});

routes/partner.php
Route::prefix('partner/v1')
->middleware(['api', 'auth:api'])
->group(function () {
// Standard endpoints with version check
Route::group([
'middleware' => 'partner.supported_versions:' . implode(',', PartnerApiUtils::AVAILABLE_VERSIONS)
], function () {
// Read endpoints
Route::middleware('scope:household:read')->group(function () {
Route::get('households', [PartnerHouseholdController::class, 'index']);
Route::get('households/{household}', [PartnerHouseholdController::class, 'show']);
});
// Write endpoints (with logging and access check)
Route::middleware([
'scope:household:write',
'partner.access_permission',
'partner.requests_logger'
])->group(function () {
Route::post('households', [PartnerHouseholdController::class, 'store']);
Route::put('households/{household}', [PartnerHouseholdController::class, 'update']);
});
});
});

Partners use OAuth scopes to define access levels:

ScopeDescription
household:readRead household data
household:writeCreate/update households
advisor:readRead advisor data
account:readRead account data
Route::middleware(['scope:household:read'])->group(function () {
Route::get('households', [PartnerHouseholdController::class, 'index']);
});
Route::middleware(['scopes:household:read,account:read'])->group(function () {
// Requires BOTH scopes
Route::get('households/{household}/full', [PartnerHouseholdController::class, 'showFull']);
});

Request with rc-version header
┌────────────────────────┐
│ CheckEndpointVersion │────Invalid Version────▶ 422
│ (PartnerApiUtils) │
└────────────────────────┘
┌────────────────────────┐
│ OAuth Scope Check │────Missing Scope────▶ 403
└────────────────────────┘
┌────────────────────────┐
│ CheckAccessPermission │────Mismatch────▶ 404
│ (locality + OAuth) │────No Permission────▶ 403
└────────────────────────┘
┌────────────────────────┐
│ LogRequests │ (POST/PUT/DELETE + partner host)
│ (FileLog + JWT decode) │
└────────────────────────┘
Controller

MiddlewarePurposeError CodeNotes
partner.supported_versionsValidate API version422Uses PartnerApiUtils
partner.access_permissionValidate OAuth client access403/404LocalityManager + canUseOauthClient
partner.requests_loggerAudit logging (writes)-FileLog, JWT decode, partner host only