AuthzDispatcher
in package
Transparent REST/gRPC authz transport selection (CONTRACT.md §1, D-03, SC#3).
checkAccess()/can()/batchCheck() ALWAYS work: with no grpc PECL extension (or
restOnly: true) they route over POST /api/v1/authz/check[/batch] via
AuthzRestClient (FND-04). When the extension IS present and restOnly is
false, the SAME public methods transparently upgrade to
AuthzGrpcClient — callers never need to know which transport
actually ran.
PITFALL 4 / T-22-16 (high severity, mitigate): \Axiam\Sdk\Grpc\AuthzGrpcClient
(which extends \Grpc\BaseStub) is referenced ONLY inside the
!$this->restOnly && extension_loaded('grpc') branches below and inside
self::grpcClient(), which those branches are the only callers of. On a
REST-only runtime (no grpc PECL extension — the actual condition in this SDK's own
CI/dev sandbox) that class is never autoloaded, so no "Class 'Grpc\BaseStub' not
found" fatal is possible — AuthzDispatcherFallbackTest proves
this empirically. The typed property self::$grpcClient and
self::grpcClient()'s return type both name AuthzGrpcClient, but PHP resolves
class names used only in type declarations lazily (confirmed empirically: a typed
property/return-type naming a nonexistent class does not fatal a class that never
assigns/invokes it) — the guard is what matters, not merely avoiding the class name in
source text.
D-06: the gRPC path shares Session's single-flight refresh/token —
$tokenAccessor is expected to be Session::accessToken(...) (or an equivalent live
reader), never a second independent refresh mechanism.
Table of Contents
Methods
- __construct() : mixed
- batchCheck() : array<int, bool>
- `batchCheck` (CONTRACT.md §1) — results preserve input order, mirroring {@see AuthzRestClient::batchCheck()}'s contract exactly regardless of transport.
- can() : bool
- `can` (CONTRACT.md §1) — the browser/UI-scenario alias for {@see self::checkAccess()}.
- checkAccess() : bool
- `checkAccess` (CONTRACT.md §1).
- checkAccessDecision() : AccessDecision
- `checkAccess`, returning the **full** decision including CONTRACT.md §11 rule 9's `reason_code` — the transport-agnostic counterpart of {@see \Axiam\Sdk\Rest\AuthzRestClient::checkAccessDecision()}, dispatched over whichever transport {@see self::checkAccess()} itself would have used.
- getUserInfo() : UserInfo
- `getUserInfo` (CONTRACT.md §1.1) — the gRPC-ONLY OIDC-style userinfo operation, the low-latency counterpart of the server's REST `GET /oauth2/userinfo`. Unlike {@see self::checkAccess()}/{@see self::batchCheck()} it has NO REST fallback: §1.1.6 explicitly forbids substituting the REST endpoint, so on a runtime without the `grpc` PECL extension (or with `restOnly: true`) this raises a {@see NetworkError} rather than silently degrading.
- introspectToken() : TokenIntrospection
- `introspectToken` (CONTRACT.md §1.1.1/§10.3, contract 1.51) — the RFC 7662 set, plus the confirmation. Wraps `axiam.v1.TokenService/IntrospectToken`. See {@see self::validateToken()} for the shared rules; this is its RFC-7662 sibling.
- validateToken() : TokenValidation
- `validateToken` (CONTRACT.md §1.1.1/§10.3, contract 1.51) — signature + expiry, plus the confirmation §10.3 exists for. Wraps `axiam.v1.TokenService/ValidateToken`.
Methods
__construct()
public
__construct(AuthzRestClient $restClient[, bool $restOnly = false ][, string|null $grpcTarget = null ][, string|null $tenantId = null ][, callable(): Array $tokenAccessor = null ][, callable(): string $subjectIdAccessor = null ][, string|null $customCaPem = null ][, string|null $clientCertPem = null ][, Sensitive|null $clientKey = null ][, callable(): void $refreshAccessor = null ][, callable(): bool $canRefreshAccessor = null ]) : mixed
Parameters
- $restClient : AuthzRestClient
- $restOnly : bool = false
- $grpcTarget : string|null = null
- $tenantId : string|null = null
- $tokenAccessor : callable(): Array = null
-
Reads the CURRENT access token live (D-06) — required only for the gRPC path; ignored entirely when
restOnlyis true or thegrpcextension is absent. - $subjectIdAccessor : callable(): string = null
-
Reads the current authenticated user's subject UUID — the gRPC wire contract (unlike REST) requires
subject_idexplicitly, cross-validated server-side against the verified JWT (SEC-003); required only for the gRPC path. - $customCaPem : string|null = null
- $clientCertPem : string|null = null
-
§6.1 (mTLS): PEM client-certificate chain the gRPC channel presents for mutual TLS;
nullleaves the channel using bearer-token auth only. Ignored on the REST path (Guzzle receives its own file-based copy). - $clientKey : Sensitive|null = null
-
§6.1/§7 (mTLS): the matching private key, wrapped in Sensitive so it never leaks; revealed only when building the gRPC channel credentials. Must be present iff
$clientCertPemis. - $refreshAccessor : callable(): void = null
-
Drives the shared §9 single-flight token refresh (expected to be
Session::refreshIfNeeded()->wait()or equivalent) — used by self::getUserInfo()/self::validateToken()/ self::introspectToken() to refresh-and-retry once on a gRPCUNAUTHENTICATED(CONTRACT.md §1.1.4), exactly as REST does on a 401. Never a second, independent refresh mechanism (D-06);nulldisables the retry (the UNAUTHENTICATEDAuthErrorthen surfaces directly). - $canRefreshAccessor : callable(): bool = null
-
CONTRACT.md §6.1 rule 11 / C-12 N4.5: "never refreshed, on either transport" — reads whether the CALLER's current credential can be refreshed at all (expected to be
Session::canRefresh()or equivalent). A device credential, or an adopted client-credentials/device-grant token, has no refresh token behind it: on anUNAUTHENTICATED, this is consulted BEFORE$refreshAccessoris ever invoked, so such a credential's failure surfaces as-is instead of driving a/api/v1/auth/refreshcall that could never have succeeded for it.null(the default) means "always refreshable" — the pre-N4.5 behavior, unchanged for a caller that never wires this in.
batchCheck()
`batchCheck` (CONTRACT.md §1) — results preserve input order, mirroring {@see AuthzRestClient::batchCheck()}'s contract exactly regardless of transport.
public
batchCheck(array<int, array{action: string, resourceId: string, scope?: string|null}> $checks) : array<int, bool>
Parameters
- $checks : array<int, array{action: string, resourceId: string, scope?: string|null}>
Return values
array<int, bool>can()
`can` (CONTRACT.md §1) — the browser/UI-scenario alias for {@see self::checkAccess()}.
public
can(string $resource, string $action) : bool
Parameters
- $resource : string
- $action : string
Return values
boolcheckAccess()
`checkAccess` (CONTRACT.md §1).
public
checkAccess(string $action, string $resourceId[, string|null $scope = null ][, string|null $subjectId = null ]) : bool
Parameters
- $action : string
- $resourceId : string
- $scope : string|null = null
- $subjectId : string|null = null
-
Additive, optional (CONTRACT.md §11.2.2 — declarative authorization helpers): when given, the check is evaluated for THIS subject rather than whichever identity the dispatcher's own configured session represents.
null(the default) preserves the pre-§11 behavior exactly on both transports — REST omitssubject_idfrom the wire body (server derives it from the verified JWT) and gRPC falls back to self::currentSubjectId() (the dispatcher's own session subject), exactly as before this parameter was added.
Return values
boolcheckAccessDecision()
`checkAccess`, returning the **full** decision including CONTRACT.md §11 rule 9's `reason_code` — the transport-agnostic counterpart of {@see \Axiam\Sdk\Rest\AuthzRestClient::checkAccessDecision()}, dispatched over whichever transport {@see self::checkAccess()} itself would have used.
public
checkAccessDecision(string $action, string $resourceId[, string|null $scope = null ][, string|null $subjectId = null ]) : AccessDecision
Exists for AccessEnforcer, which needs reasonCode to decide
whether a CONTRACT.md §28.5 rule 5 insufficient_scope challenge applies to a
denial — a distinction self::checkAccess()'s bare bool cannot carry.
Parameters
- $action : string
- $resourceId : string
- $scope : string|null = null
- $subjectId : string|null = null
-
See self::checkAccess()'s own docblock.
Return values
AccessDecisiongetUserInfo()
`getUserInfo` (CONTRACT.md §1.1) — the gRPC-ONLY OIDC-style userinfo operation, the low-latency counterpart of the server's REST `GET /oauth2/userinfo`. Unlike {@see self::checkAccess()}/{@see self::batchCheck()} it has NO REST fallback: §1.1.6 explicitly forbids substituting the REST endpoint, so on a runtime without the `grpc` PECL extension (or with `restOnly: true`) this raises a {@see NetworkError} rather than silently degrading.
public
getUserInfo() : UserInfo
Behavior (mirrors the checkAccess gRPC path + the REST 401 refresh path exactly):
- §1.1.3 precondition: with no current access token, raises AuthError client-side WITHOUT any wire call.
- §1.1.2 metadata:
authorization: Bearer <token>+x-tenant-idon the RPC, via the same UserInfoGrpcClient channel machinery AuthzGrpcClient uses. - §1.1.4 auth-failure: a gRPC
UNAUTHENTICATED(surfaced as AuthError) drives the shared §9 single-flight refresh ($refreshAccessor) and retries the RPC exactly once; a second failure propagates (no loop, §9.3). - §1.1.5 return shape: a typed UserInfo —
sub/tenantId/orgIdalways set,email/preferredUsernamepresent only when the token carried the "email"/"profile" scope (the server gates them; an absent optional is surfaced asnull).
Return values
UserInfointrospectToken()
`introspectToken` (CONTRACT.md §1.1.1/§10.3, contract 1.51) — the RFC 7662 set, plus the confirmation. Wraps `axiam.v1.TokenService/IntrospectToken`. See {@see self::validateToken()} for the shared rules; this is its RFC-7662 sibling.
public
introspectToken(Sensitive|string $inspectedAccessToken) : TokenIntrospection
Parameters
- $inspectedAccessToken : Sensitive|string
Return values
TokenIntrospectionvalidateToken()
`validateToken` (CONTRACT.md §1.1.1/§10.3, contract 1.51) — signature + expiry, plus the confirmation §10.3 exists for. Wraps `axiam.v1.TokenService/ValidateToken`.
public
validateToken(Sensitive|string $inspectedAccessToken) : TokenValidation
The CALLER'S own token authenticates the RPC (via the same channel machinery every
other gRPC call here uses); $inspectedAccessToken — a DIFFERENT credential — is
the one being asked about (§1.1.1 rule 1). Behaviour otherwise mirrors
self::getUserInfo() exactly: §1.1 rule 3's client-side precondition (no
caller token -> AuthError, zero wire calls), no REST substitution (§1.1.1
rule 7), and a gRPC UNAUTHENTICATED on the CALLER'S credential drives the shared
§9 single-flight refresh and retries once.
Parameters
- $inspectedAccessToken : Sensitive|string