Enterprise edition
SaaS — Deep Reference
At a glance
Section titled “At a glance”The Enterprise SaaS module supplies the multi-tenant building blocks for a NextPDF-based service.
TenantContextis an immutable identity value object, resolved from authenticated context only.ApiKeyGeneratorandApiKeyAuthenticatorissue and validate prefixed, checksummed, hash-stored API keys.QuotaCheckergates requests against per-tenant quotas: warn at 80%, reject at 100%, deny fail-closed when usage is unknown.SidecarJwtMintermints short-lived HS256 service tokens for inter-component calls.UsageMeterandStripeMeteringSyncerpull usage events and sync them to the billing provider with deterministic idempotency.
Availability & licensing
Section titled “Availability & licensing”This capability ships in NextPDF Enterprise (nextpdf/enterprise) and activates with an Enterprise-tier license envelope. A deployment without that entitlement does not load the capability’s classes. Compare editions and get a license.
The SaaS surface is a base Enterprise capability; no separate per-feature flag exists. NextPDF Core (Apache-2.0) and NextPDF Pro have no tenancy, API-key, or quota model; this capability has no lower-tier equivalent.
composer require nextpdf/enterprise:^3Public API surface
Section titled “Public API surface”All symbols live under NextPDF\Enterprise\SaaS.
| Symbol | Parameters | Default behavior | Returns | Throws or fails with | Notes |
|---|---|---|---|---|---|
TenantContext | string $tenantId, string $source, array $scopes = ['read'] | Immutable identity value object | value object | Nothing | Sources: jwt, mtls, api_key; hasScope() / hasAnyScope() test scopes |
TenantContext::singleTenant() | none | Fixed default tenant with read, write, admin | TenantContext | Nothing | Single-tenant deployments |
ApiKeyAuthenticator::authenticate() | string $rawKey | Six-step validation, then context resolution | TenantContext | ApiKeyAuthenticationException (HTTP 401) | Context source is api_key; scopes copied from the key record |
ApiKeyAuthenticator::requireScope() | TenantContext $context, ApiKeyScope $requiredScope | Explicit scope assertion | void | ApiKeyAuthenticationException::insufficientScope() (HTTP 403) | Scope enforcement is a separate, explicit step |
ApiKeyGenerator::generateLive() / ::generateTest() | none | New key: prefix, 32-character base62 body (192-bit entropy), 4-character checksum | array{key, hash, prefix} | Nothing | Prefixes npf_live_ / npf_test_; hash is the storage digest |
ApiKeyGenerator::validateChecksum() | string $key | Prefix, length, and CRC32-checksum shape check | bool | Nothing | Typo guard before any datastore lookup; not a security control |
ApiKeyGenerator::hashKey() (static) | string $key | SHA-256 hex digest of the raw key | string | Nothing | The only stored representation of a key |
ApiKeyGenerator::isLiveKey() / ::isTestKey() | string $key | Prefix inspection | bool | Nothing | Environment visible without a lookup |
ApiKey | id, tenant, key hash, display prefix, scope mask, created/expires/revoked instants | Stored key record; plaintext never persisted | value object | Nothing | isActive(), isRevoked(), isExpired(), scopeNames() |
ApiKeyScope | backed enum: Read = 1, Write = 2, Admin = 4 | Bitmask scope model | enum | Nothing | maskFromNames(), fromName(), fullAccess(); unknown names are ignored by the mask builder |
ApiKeyRepositoryInterface | — | Storage contract; hash-only persistence | — | Implementation-defined | findByHash(), findActiveByTenant(), store(), revoke() |
SidecarJwtMinter::__construct() | string $secret, issuer, audience, int $ttlSeconds = 300 | Rejects a signing secret under 16 bytes at construction | instance | InvalidArgumentException | 128-bit key-strength floor; 32 or more random bytes recommended |
SidecarJwtMinter::mint() | TenantContext $tenant | HS256 JWT with iss, aud, sub, scope, tenant_id, iat, exp, jti | string | JsonException on claim-encoding failure | Five-minute default lifetime; jti is 16 random bytes, hex-encoded |
QuotaChecker::check() | TenantContext $tenant, TenantQuota $quota | Reads current usage; warns at 80%; rejects at 100%; denies when usage is unknown | array{allowed: bool, warning_percentage: float|null} | QuotaExceededException, QuotaUnavailableException | Alert callback invoked at both thresholds |
TenantQuota | float $maxCuPerPeriod, collections, storage bytes, concurrent jobs | Per-period limits; 80% soft-threshold constant | value object | Nothing | fromConfig() defaults: 10,000 CU, 100 collections, 10 GB, 10 jobs |
QuotaExceededException::toErrorEnvelope() | none | SPEC-QUOTA-001 error envelope | array | — | HTTP 402, not retryable; carries current, limit, and reset instant |
QuotaUnavailableException::toErrorEnvelope() | none | SPEC-QUOTA-503 error envelope | array | — | HTTP 503, retryable; reason usage_undeterminable |
UsageMeter::pullUsage() | array<string, int> $watermarks | Polls every configured usage-source host from its cursor | array{events, instance_id} | UsageMeterException when every host is unreachable | Partial outage tolerated; unreachable hosts logged and skipped |
UsageMeter::getCurrentUsage() | string $tenantId | Current-period compute-unit usage | float | UsageMeterException when usage is undeterminable | A parseable zero is authoritative; unknown usage throws |
StripeMeteringSyncer::sync() | array<string, int> $watermarks | One pull, transform, send cycle | array{watermarks, sent, failed} | Nothing; send failures route to the DLQ callback | Pull failure returns a no-op cycle preserving the cursor |
StripeAdapter::sendMeterEvent() | MeterEvent $event | POST to the provider with an idempotency header | void | StripeSyncException | HTTP 429 and 5xx retryable; other 4xx not retryable |
StripeAdapter::sendBatch() | list<MeterEvent> $events | Sends each event; collects failures | list<StripeSyncException> | Nothing | Empty list means every event succeeded |
MeterEvent | meter name, tenant, value, idempotency key, timestamp | Immutable meter-event value object | value object | Nothing | toStripePayload() serializes the provider payload |
final readonly class ApiKeyAuthenticator{ public function __construct( private ApiKeyRepositoryInterface $repository, private ApiKeyGenerator $generator, private LoggerInterface $logger, ) {}
public function authenticate(string $rawKey): TenantContext {}
public function requireScope(TenantContext $context, ApiKeyScope $requiredScope): void {}}final class QuotaChecker{ public function __construct( private readonly UsageMeterInterface $usageMeter, private readonly LoggerInterface $logger, private readonly Closure $quotaAlertCallback, ) {}
/** @return array{allowed: bool, warning_percentage: float|null} */ public function check(TenantContext $tenant, TenantQuota $quota): array {}}interface UsageMeterInterface{ /** @return array<string, mixed> */ public function pullUsage(array $watermarks): array;
public function getCurrentUsage(string $tenantId): float;}final class StripeMeteringSyncer{ public function __construct( private readonly UsageMeterInterface $usageMeter, private readonly StripeAdapterInterface $stripeAdapter, private readonly LoggerInterface $logger, private readonly Closure $dlqCallback, ) {}
/** @return array{watermarks: array<string, int>, sent: int, failed: int} */ public function sync(array $watermarks): array {}}final readonly class SidecarJwtMinter{ public function __construct( private string $secret, private string $issuer = 'nextpdf-enterprise', private string $audience = 'nextpdf-spectrum', private int $ttlSeconds = self::DEFAULT_TTL_SECONDS, ) {}
public function mint(TenantContext $tenant): string {}}Behavior contract
Section titled “Behavior contract”- Tenant identity. A tenant context is immutable: tenant identifier, resolution source, scopes. Identity is resolved from authenticated context only (
jwt,mtls,api_key) — never from a client-supplied header or query parameter. A single-tenant deployment uses the fixeddefaultcontext with full scopes. - Authentication order. API-key authentication proceeds in a fixed order: checksum, SHA-256 hash, repository lookup, revocation check, expiry check, context resolution. Unknown, revoked, and expired keys are three distinct outcomes, all HTTP 401; insufficient scope is HTTP 403.
- Key secrecy. The raw key is never stored or logged; only its SHA-256 digest is persisted and looked up. The authenticator performs no byte-wise secret comparison itself; constant-time digest lookup is the repository implementation’s contract.
- Quota thresholds. At the 80% soft limit the request proceeds, the warning percentage is returned, and the alert callback fires. At the 100% hard limit the request is rejected with
SPEC-QUOTA-001(HTTP 402) carrying the reset instant — the first day of the next month, midnight UTC. - Quota fail-closed. Undeterminable usage denies the request with
SPEC-QUOTA-503(HTTP 503, retryable). Unknown usage is never treated as zero. A genuine, parseable zero usage is authoritative and admits. - Alert deduplication. The checker does not deduplicate alerts; per-period deduplication is the callback’s responsibility.
- Metering sync. The cycle is scheduled, never on the request path. It resumes from per-source watermarks and advances each cursor to the highest successfully sent event identity. The idempotency key is deterministic — tenant, period, event identity — so a re-sent event collapses on the provider’s deduplication.
- Pull failure. A failed pull returns a no-op cycle (
sent0,failed0) that preserves the watermarks; the next cycle retries the same window rather than skipping it. - Service tokens. Tokens are HS256 with a shared secret and carry
iss,aud,sub,scope,tenant_id,iat,exp, and a uniquejti. The default lifetime is five minutes. Construction rejects a secret under 16 bytes, fail-closed.
Edge cases & failure modes
Section titled “Edge cases & failure modes”- A malformed key fails the checksum and is rejected before any datastore access. A well-formed but unknown key is rejected after lookup. Both surface as the invalid-key outcome.
- Unknown, revoked, and expired keys use distinct exception factories; the
keyExpiredflag is true only on the expired outcome. Map them to distinct client responses. QuotaChecker::check()returns only on admission; the returnedallowedis alwaystrue. Rejection and unavailability are exceptional outcomes.TenantQuota::usagePercentage()returns0.0for a non-positive quota;fromConfig()substitutes defaults for absent values and clamps integer limits to at least 1.- Watermarks are per source; a missing watermark starts from the beginning of that source’s stream (cursor
0). A multi-source deployment maintains independent watermarks. - The transform skips non-array events, events with a missing or empty operation or tenant, a non-positive value, or an unmapped operation — without failing the cycle. An event lacking a usable positive integer identity is refused with a warning: a random fallback key would defeat provider-side deduplication and could double-bill the tenant.
- Ten consecutive send failures escalate to a critical log entry; the counter resets on any successful send. Every failed event still reaches the dead-letter callback.
- A malformed JSON body from a usage-source host yields an empty event list, not a cycle failure.
pullUsage()throws only when every configured host is unreachable.
FIPS-mode behavior
Section titled “FIPS-mode behavior”- Digest and MAC primitives are SHA-256 and HMAC-SHA256 through the host PHP crypto provider. A FIPS-constrained build fails closed on a non-approved algorithm rather than downgrading; the SaaS layer adds no cryptographic policy of its own.
- Key bodies and token identifiers come from the CSPRNG (
random_int(),random_bytes()). - The CRC32 checksum is not a cryptographic control and is unaffected by FIPS mode.
Conformance
Section titled “Conformance”Statements below describe capability against the cited clauses.
| Behavior | Reference |
|---|---|
Service-token exp not-after semantics | RFC 7519 §4.1.4 |
| Service-token JWS compact serialization | RFC 7515 §3.1 |
| 16-byte HS256 secret floor; no human-memorable passwords as MAC keys | RFC 8725 §3.5 (threat: §2.2) |
| Repository digest-lookup constant-time contract | OWASP ASVS 5.0 §11.2.4 |
| API-key storage digest SHA-256 | FIPS 180-4 (code-declared) |
The FIPS 180-4, FIPS 198-1, and BSI TR-02102-1 references are code-declared in the product source (hash('sha256', …) and the minter’s documented key floor). The constant-time requirement of ASVS §11.2.4 binds the repository implementation the operator supplies, not the authenticator class itself.
Development notes
Section titled “Development notes”- Provide durable implementations of
ApiKeyRepositoryInterfaceandStripeAdapterInterface; the package ships the contracts and a PSR-18 provider client, not persistence. - Dependencies are PSR abstractions only: PSR-3 logger, PSR-18 HTTP client, PSR-17 request and stream factories. No provider SDK is required.
- Run the metering sync as a scheduled job. Persist the returned watermarks durably after each cycle.
- Surface the quota warning percentage to clients, for example as a warning header, and deduplicate quota alerts per period in the callback.
- Supply the token-minter secret from configuration as a high-entropy random value; 32 or more random bytes is recommended. Never derive it from a password.
- Key prefixes make the environment visible without a lookup; sandbox and production keys never collide because the prefix participates in the stored digest.
- Internal mechanism detail stays in the source repository’s internal documentation and is out of scope for this manual.
Publication boundary
Section titled “Publication boundary”This page documents externally observable behavior and the supported public API surface only. Internal namespace paths, helper classes, mechanism tables, runbook filenames, and ticket prefixes are out of scope.