Skip to content
getnextpdf.com

Enterprise edition

SaaS — Deep Reference

The Enterprise SaaS module supplies the multi-tenant building blocks for a NextPDF-based service.

  • TenantContext is an immutable identity value object, resolved from authenticated context only.
  • ApiKeyGenerator and ApiKeyAuthenticator issue and validate prefixed, checksummed, hash-stored API keys.
  • QuotaChecker gates requests against per-tenant quotas: warn at 80%, reject at 100%, deny fail-closed when usage is unknown.
  • SidecarJwtMinter mints short-lived HS256 service tokens for inter-component calls.
  • UsageMeter and StripeMeteringSyncer pull usage events and sync them to the billing provider with deterministic idempotency.

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.

Terminal window
composer require nextpdf/enterprise:^3

All symbols live under NextPDF\Enterprise\SaaS.

SymbolParametersDefault behaviorReturnsThrows or fails withNotes
TenantContextstring $tenantId, string $source, array $scopes = ['read']Immutable identity value objectvalue objectNothingSources: jwt, mtls, api_key; hasScope() / hasAnyScope() test scopes
TenantContext::singleTenant()noneFixed default tenant with read, write, adminTenantContextNothingSingle-tenant deployments
ApiKeyAuthenticator::authenticate()string $rawKeySix-step validation, then context resolutionTenantContextApiKeyAuthenticationException (HTTP 401)Context source is api_key; scopes copied from the key record
ApiKeyAuthenticator::requireScope()TenantContext $context, ApiKeyScope $requiredScopeExplicit scope assertionvoidApiKeyAuthenticationException::insufficientScope() (HTTP 403)Scope enforcement is a separate, explicit step
ApiKeyGenerator::generateLive() / ::generateTest()noneNew key: prefix, 32-character base62 body (192-bit entropy), 4-character checksumarray{key, hash, prefix}NothingPrefixes npf_live_ / npf_test_; hash is the storage digest
ApiKeyGenerator::validateChecksum()string $keyPrefix, length, and CRC32-checksum shape checkboolNothingTypo guard before any datastore lookup; not a security control
ApiKeyGenerator::hashKey() (static)string $keySHA-256 hex digest of the raw keystringNothingThe only stored representation of a key
ApiKeyGenerator::isLiveKey() / ::isTestKey()string $keyPrefix inspectionboolNothingEnvironment visible without a lookup
ApiKeyid, tenant, key hash, display prefix, scope mask, created/expires/revoked instantsStored key record; plaintext never persistedvalue objectNothingisActive(), isRevoked(), isExpired(), scopeNames()
ApiKeyScopebacked enum: Read = 1, Write = 2, Admin = 4Bitmask scope modelenumNothingmaskFromNames(), fromName(), fullAccess(); unknown names are ignored by the mask builder
ApiKeyRepositoryInterfaceStorage contract; hash-only persistenceImplementation-definedfindByHash(), findActiveByTenant(), store(), revoke()
SidecarJwtMinter::__construct()string $secret, issuer, audience, int $ttlSeconds = 300Rejects a signing secret under 16 bytes at constructioninstanceInvalidArgumentException128-bit key-strength floor; 32 or more random bytes recommended
SidecarJwtMinter::mint()TenantContext $tenantHS256 JWT with iss, aud, sub, scope, tenant_id, iat, exp, jtistringJsonException on claim-encoding failureFive-minute default lifetime; jti is 16 random bytes, hex-encoded
QuotaChecker::check()TenantContext $tenant, TenantQuota $quotaReads current usage; warns at 80%; rejects at 100%; denies when usage is unknownarray{allowed: bool, warning_percentage: float|null}QuotaExceededException, QuotaUnavailableExceptionAlert callback invoked at both thresholds
TenantQuotafloat $maxCuPerPeriod, collections, storage bytes, concurrent jobsPer-period limits; 80% soft-threshold constantvalue objectNothingfromConfig() defaults: 10,000 CU, 100 collections, 10 GB, 10 jobs
QuotaExceededException::toErrorEnvelope()noneSPEC-QUOTA-001 error envelopearrayHTTP 402, not retryable; carries current, limit, and reset instant
QuotaUnavailableException::toErrorEnvelope()noneSPEC-QUOTA-503 error envelopearrayHTTP 503, retryable; reason usage_undeterminable
UsageMeter::pullUsage()array<string, int> $watermarksPolls every configured usage-source host from its cursorarray{events, instance_id}UsageMeterException when every host is unreachablePartial outage tolerated; unreachable hosts logged and skipped
UsageMeter::getCurrentUsage()string $tenantIdCurrent-period compute-unit usagefloatUsageMeterException when usage is undeterminableA parseable zero is authoritative; unknown usage throws
StripeMeteringSyncer::sync()array<string, int> $watermarksOne pull, transform, send cyclearray{watermarks, sent, failed}Nothing; send failures route to the DLQ callbackPull failure returns a no-op cycle preserving the cursor
StripeAdapter::sendMeterEvent()MeterEvent $eventPOST to the provider with an idempotency headervoidStripeSyncExceptionHTTP 429 and 5xx retryable; other 4xx not retryable
StripeAdapter::sendBatch()list<MeterEvent> $eventsSends each event; collects failureslist<StripeSyncException>NothingEmpty list means every event succeeded
MeterEventmeter name, tenant, value, idempotency key, timestampImmutable meter-event value objectvalue objectNothingtoStripePayload() 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 {}
}
  • 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 fixed default context 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 (sent 0, failed 0) 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 unique jti. The default lifetime is five minutes. Construction rejects a secret under 16 bytes, fail-closed.
  • 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 keyExpired flag is true only on the expired outcome. Map them to distinct client responses.
  • QuotaChecker::check() returns only on admission; the returned allowed is always true. Rejection and unavailability are exceptional outcomes.
  • TenantQuota::usagePercentage() returns 0.0 for 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.
  • 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.

Statements below describe capability against the cited clauses.

BehaviorReference
Service-token exp not-after semanticsRFC 7519 §4.1.4
Service-token JWS compact serializationRFC 7515 §3.1
16-byte HS256 secret floor; no human-memorable passwords as MAC keysRFC 8725 §3.5 (threat: §2.2)
Repository digest-lookup constant-time contractOWASP ASVS 5.0 §11.2.4
API-key storage digest SHA-256FIPS 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.

  • Provide durable implementations of ApiKeyRepositoryInterface and StripeAdapterInterface; 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.

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.