Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
26b1e64
fix: bypass cache for locking document reads
abnegate Jun 5, 2026
a87f3ec
test: assert forUpdate read does not repopulate the cache
github-actions[bot] Jun 5, 2026
be6253c
fix: avoid false updates from cached documents
abnegate Jun 5, 2026
c625c37
docs(database): explain stale-cache auth bypass in updateDocument
github-actions[bot] Jun 5, 2026
000d66a
(fix): CI — testSingleDocumentDateOperations: align with new no-op up…
github-actions[bot] Jun 5, 2026
5ea4b49
fix: preserve explicit update timestamps
abnegate Jun 5, 2026
ed567e1
perf(database): defer VAR_FLOAT attribute map and clarify float-noop …
github-actions[bot] Jun 5, 2026
aa0f998
test: cover negative paths of stale-cache auth tolerance
github-actions[bot] Jun 5, 2026
88c9ebf
(fix): CI — testEmptyTenant: cast cached document to fix float/int mi…
github-actions[bot] Jun 5, 2026
329ce38
fix(database): short-circuit no-op updates before encode/cast pipeline
github-actions[bot] Jun 5, 2026
8c2bbd0
test: cover read-only no-op, unparseable timestamp, and float no-op s…
github-actions[bot] Jun 5, 2026
f3417c0
fix(database): harden stale-cache tolerance preconditions
github-actions[bot] Jun 5, 2026
b08a62f
fix(database): reset \$isNoop on transaction retry
github-actions[bot] Jun 5, 2026
cc06a4a
perf(database): drop redundant casting() on cache hits
github-actions[bot] Jun 5, 2026
fd8c063
refactor(database): derive bare-input meta keys from INTERNAL_ATTRIBUTES
github-actions[bot] Jun 5, 2026
fcbc0f0
fix(database): emit EVENT_DOCUMENT_UPDATE on no-op updates
github-actions[bot] Jun 5, 2026
b1f4ca3
test(database): widen flake margins and clarify float-noop semantics
github-actions[bot] Jun 5, 2026
0e4f936
fix(database): tighten stale-cache tolerance + cache meta-key derivation
github-actions[bot] Jun 5, 2026
0ce734f
(fix): CI — warm cache in stale-tolerance event-suppression test
github-actions[bot] Jun 5, 2026
52c33ed
fix(database): preserve ConflictException on no-op update + optimize …
github-actions[bot] Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 175 additions & 14 deletions src/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,15 @@ class Database
*/
protected static array $filters = [];

/**
* Lazy-derived list of caller-facing meta keys (`$id`, `$updatedAt`, …)
* sourced from INTERNAL_ATTRIBUTES. Cached because rebuilding it on every
* updateDocument() call is wasted work — the constant doesn't change.
*
* @var list<string>|null
*/
private static ?array $internalMetaKeys = null;

/**
* @var array<string, array{encode: callable, decode: callable, signature: string}>
*/
Expand Down Expand Up @@ -4840,11 +4849,13 @@ public function getDocument(string $collection, string $id, array $queries = [],
$selections
);

try {
$cached = $this->cache->load($documentKey, self::TTL, $hashKey);
} catch (Exception $e) {
Console::warning('Warning: Failed to get document from cache: ' . $e->getMessage());
$cached = null;
$cached = null;
if (!$forUpdate) {
try {
$cached = $this->cache->load($documentKey, self::TTL, $hashKey);
} catch (Exception $e) {
Console::warning('Warning: Failed to get document from cache: ' . $e->getMessage());
}
}

if ($cached) {
Expand Down Expand Up @@ -4917,7 +4928,7 @@ public function getDocument(string $collection, string $id, array $queries = [],
);

// Don't save to cache if it's part of a relationship
if (empty($relationships)) {
if (!$forUpdate && empty($relationships)) {
Comment thread
abnegate marked this conversation as resolved.
try {
$this->cache->save($documentKey, $document->getArrayCopy(), $hashKey);
$this->cache->save($collectionKey, 'empty', $documentKey);
Expand Down Expand Up @@ -6138,8 +6149,16 @@ public function updateDocument(string $collection, string $id, Document $documen

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] $inputKeys is captured here at line 6145 but only consumed ~180 lines later inside the auth-failure branch. The capture is load-bearing — it must happen before the array_merge on line 6166, otherwise the post-merge $document includes every key from $old and the "caller sent only $updatedAt?" signal is lost — but there is no comment explaining this. A future refactor that reorders the merge would silently break the auth-tolerance branch.

Follow-up commit adds an inline comment at the capture site documenting the invariant.

$collection = $this->silent(fn () => $this->getCollection($collection));
$newUpdatedAt = $document->getUpdatedAt();
$document = $this->withTransaction(function () use ($collection, $id, $document, $newUpdatedAt) {
$isNoop = false;
$silentNoop = false;
$document = $this->withTransaction(function () use ($collection, $id, $document, $newUpdatedAt, &$isNoop, &$silentNoop) {
$time = DateTime::now();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] $inputKeys was computed eagerly on every updateDocument call via array_keys($document->getArrayCopy()) — a full O(n) array copy — but only read inside the rare !$shouldUpdate && $updatedAtChanged branch. Deferred the computation to that branch.

// Reset on every attempt: withTransaction may retry on non-domain
// failures, and a previous attempt's flags must not bleed into a
// retry that ends up writing for real.
$isNoop = false;
$silentNoop = false;
$skipAdapterUpdate = false;
$old = $this->authorization->skip(fn () => $this->silent(
fn () => $this->getDocument($collection->getId(), $id, forUpdate: true)
));
Expand All @@ -6159,8 +6178,9 @@ public function updateDocument(string $collection, string $id, Document $documen
$skipPermissionsUpdate = ($originalPermissions === $currentPermissions);
}
$createdAt = $document->getCreatedAt();
$rawInput = $document->getArrayCopy();

$document = \array_merge($old->getArrayCopy(), $document->getArrayCopy());
$document = \array_merge($old->getArrayCopy(), $rawInput);
$document['$collection'] = $old->getAttribute('$collection'); // Make sure user doesn't switch collection ID
$document['$createdAt'] = ($createdAt === null || !$this->preserveDates) ? $old->getCreatedAt() : $createdAt;

Expand All @@ -6181,6 +6201,8 @@ public function updateDocument(string $collection, string $id, Document $documen

if ($collection->getId() !== self::METADATA) {
$documentSecurity = $collection->getAttribute('documentSecurity', false);
$updatedAtChanged = false;
$floatAttributes = null;

foreach ($relationships as $relationship) {
$relationships[$relationship->getAttribute('key')] = $relationship;
Expand Down Expand Up @@ -6272,14 +6294,94 @@ public function updateDocument(string $collection, string $id, Document $documen
continue;
}

if ($key === '$updatedAt') {
if ($value !== $old->getAttribute($key)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] (float)$value === (float)$oldValue short-circuits on numeric equality but continues without canonicalizing the merged document. A read-only caller submitting value => "5e2" (string) for a stored 500.0 matches via the float cast, the tolerance branch (or a subsequent legitimate update) then persists the string verbatim — the storage layer receives the attacker's type-coerced value.

Fixed in the follow-up commit by (a) tightening the predicate (is_float($oldValue), is_finite((float)$value)) so non-canonical encodings like "INF" are rejected, and (b) rewriting $document[$key] = $oldValue before continue so any later write under the same call uses the stored canonical value.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Correctness finding C2 — $updatedAtChanged could spuriously trip (FIXED in 46b2ec48).

The diff loop iterates the merged document, which always carries $old's $updatedAt. ArrayObject normalization through new Document(array_merge(...)) can shift the type (string vs \DateTime), so a bare !== would flag the field as changed on inputs that never supplied it. That false positive would then unlock the entire $onlyUpdatedAtChanged branch (including the tolerance path) on inputs the caller never meant to touch.

Fix: gate detection on \array_key_exists('$updatedAt', $rawInput). The branch now only fires when the caller actually submitted $updatedAt.

$updatedAtChanged = true;
}

continue;
}

$oldValue = $old->getAttribute($key);

if ($value !== $oldValue) {
// VAR_FLOAT: tolerate scalar type drift (e.g. JSON round-trip
// dropping trailing zeros so 5.0 comes back as int 5) by comparing
// as floats. Build the float-attribute map lazily — only on the
// first numeric-mismatch we encounter — so collections with no
// VAR_FLOAT attributes never pay the up-front scan cost.
if (\is_numeric($value) && \is_numeric($oldValue) && (float)$value === (float)$oldValue) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Security finding S4 — is_numeric was too permissive for float tolerance (FIXED in 46b2ec48).

The documented contract is "int↔float drift only (JSON round-trip dropping trailing zeros)". \is_numeric accepts " 5", "5e0", "+5", and ".5" — none of which a real round-trip drift would produce. A read-only caller could supply a numeric-looking string and have the diff loop silently skip it.

Fix: tighten to (\is_int($value) || \is_float($value)) && (\is_int($oldValue) || \is_float($oldValue)). Now only true scalar-type drift between int and float is tolerated.

if ($floatAttributes === null) {
$floatAttributes = [];
foreach ($attributes as $attribute) {
if ($attribute->getAttribute('type') === self::VAR_FLOAT) {
$floatAttributes[$attribute->getAttribute('key')] = true;
}
}
}
if (isset($floatAttributes[$key])) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] Correctness finding C1 — float-noop fell through to a wasted adapter write (FIXED in 46b2ec48).

When the diff loop short-circuited on int↔float drift (e.g. 5 vs 5.0), $shouldUpdate stayed false, $updatedAtChanged stayed false, so $onlyUpdatedAtChanged was false, $skipAdapterUpdate was never set, and control fell through to $this->adapter->updateDocument(...). That was a wasted write on every adapter and a potential type-corruption risk on adapters with non-trivial castingBefore.

Fix: in the READ-only else branch, if there are no real diffs AND no $updatedAt diff AND no permissions diff, treat it as a full no-op ($skipAdapterUpdate = true, $document = $old). Read-only callers reaching this short-circuit also get $silentNoop = true so they fire EVENT_DOCUMENT_READ instead of EVENT_DOCUMENT_UPDATE — same security argument as the tolerance branch.

continue;
}
}

$shouldUpdate = true;
break;
}
}

$onlyUpdatedAtChanged = !$shouldUpdate && $updatedAtChanged;
$updatedAtIsValid = false;
$inputIsBareUpdatedAt = false;

if ($onlyUpdatedAtChanged) {
$updatedAt = $document->getAttribute('$updatedAt');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] Widened \DateTime to \DateTimeInterface to also accept DateTimeImmutable instances (which the prior check would have silently rejected and routed through the strict-string path).

$updatedAtIsValid = \is_null($updatedAt) || $updatedAt instanceof \DateTime;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] This gate is trivially bypassable — $inputKeys !== ['$updatedAt'] evaluates true for any payload with an extra key ($id, $collection, etc.), so an SDK convenience like new Document(['$id' => 'x', '$updatedAt' => $stale]) satisfies the check and a read-only caller exits the auth path. The documented invariant ("an explicit updatedAt-only write still requires update permission") is not actually enforced.

Fixed in the follow-up commit by introducing hasNonSystemInputKey($inputKeys) which strips every key in INTERNAL_ATTRIBUTES. Payloads composed entirely of system metadata now still require update permission.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] Security: \DateTime($updatedAt) in the try block (line 6332) parses "now", "yesterday", "+1 year", "@0", "5e0" without throwing. An attacker who knows neither the real $updatedAt nor any prior doc state can engage tolerance just by submitting a relative time expression. The try/catch only filters truly garbage strings — \DateTime's parser is too permissive to serve as a stale-cache value check. Replaced with a strict ISO-shape regex covering both formatDb (Y-m-d H:i:s.v) and formatTz (Y-m-d\TH:i:s.vP); existing "not-a-date" test still passes, added testRelativeTimestampStringStillRequiresUpdatePermission to lock in the "now" rejection.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CORRECTNESS] instanceof \DateTime excludes \DateTimeImmutable, the standard modern-PHP date type. Widened to \DateTimeInterface in 94f0e5c0. (In practice, Document::getUpdatedAt(): ?string already gates non-string values further upstream, but the wider check is the right contract.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SECURITY-SUGGESTION] $updatedAt instanceof \DateTime is a blanket short-circuit: any DateTime instance (including new \DateTime('now'), '@0', far-future, etc.) passes regardless of value. Not exploitable today since the value is never parsed on the tolerance path, but it weakens the "strict ISO shape" contract the comment promises and would break the gate if it were reused on a write path. Consider format-then-regex-checking DateTime instances too, so all branches enforce the same shape.


// Strict ISO-shape check: \DateTime() accepts "now", "yesterday",
// "+1 year", "@0" etc. — none of which a real cached timestamp
// would carry. Constrain to formatDb ("Y-m-d H:i:s.v") and
// formatTz ("Y-m-d\TH:i:s.vP") prefixes so the tolerance branch
// can't be triggered by relative or symbolic time expressions.
if (!$updatedAtIsValid && \is_string($updatedAt) && $updatedAt !== '') {
$updatedAtIsValid = \preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}/', $updatedAt) === 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAINT-SUGGESTION] The ISO-shape regex duplicates DateTime::$formatDb ('Y-m-d H:i:s.v') and $formatTz ('Y-m-d\TH:i:s.vP') declared at src/Database/DateTime.php:9-10. Consider a public DateTime::isStrictDbOrTzString(string): bool that round-trips both formats via createFromFormat — eliminates drift if the formats ever change, and rejects malformed-but-shaped strings (2024-13-40T99:99:99) that pass the prefix regex today. Not load-bearing on the current code path (the value isn't parsed), but reduces future surface area.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Security/maintainability findings S3 + M3 — ISO regex accepted garbage (FIXED in 46b2ec48 + 59a4160b).

The regex /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}/ was unanchored and accepted 9999-99-99T99:99:99, 0000-00-00 00:00:00<long junk tail>, etc. It also duplicated the canonical format strings that already live as DateTime::$formatDb and DateTime::$formatTz — silent breakage if those ever change.

Fix: extracted DateTime::isShapedLikeStored(string): bool (commit 59a4160b) which defers to \DateTime::createFromFormat against the two canonical formats. Single source of truth, and impossible dates are now actually rejected.

}

// "Bare" = caller supplied no real attribute keys, only system
// metadata. A "real" key here must be both (a) not an internal
// meta key AND (b) actually defined on the collection schema —
// a junk/typo key like {garbageKey: null} must not be enough to
// flip the input to "non-bare", otherwise a read-only caller could
// unlock the stale-cache tolerance branch (and the event it fires)
// just by appending an unknown null-valued key.
//
// Single-pass isset lookups over $rawInput keys: prior versions
// used array_map + array_intersect + array_diff, which allocated
// two intermediate arrays and did O(N*M) value scans per call.
$schemaKeyLookup = [];
foreach ($attributes as $attr) {
$schemaKeyLookup[$attr->getAttribute('key')] = true;
}
$metaKeyLookup = \array_fill_keys(self::internalMetaKeys(), true);
$inputIsBareUpdatedAt = true;
foreach ($rawInput as $key => $_) {
if (isset($schemaKeyLookup[$key]) && !isset($metaKeyLookup[$key])) {
$inputIsBareUpdatedAt = false;
break;
}
}

if (!$inputIsBareUpdatedAt && \is_null($updatedAt)) {
// Caller nulled $updatedAt while resubmitting otherwise unchanged
// fields → treat as a silent no-op (don't touch storage).
$skipAdapterUpdate = true;
$document = $old;
} else {
// Caller explicitly set $updatedAt (or supplied only that key) →
// honor as a real update — requires UPDATE perm.
$shouldUpdate = true;
}
Comment on lines +6373 to +6382

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Read-only caller can fire EVENT_DOCUMENT_UPDATE via null $updatedAt resubmission

When !$inputIsBareUpdatedAt && is_null($updatedAt) is true, $skipAdapterUpdate is set but $silentNoop is never set to true. A caller with only READ permission passes the subsequent else-branch auth check (PERMISSION_READ), the transaction returns $old with $isNoop = true, and then — because $silentNoop remains falseEVENT_DOCUMENT_UPDATE fires at line 6503. This allows a read-only caller to inject spurious audit-log entries and probe document existence through event listeners, exactly the risk the $silentNoop flag was introduced to prevent on the $canTolerateStaleCache path. The test testNonBareNullUpdatedAtIsSilentNoopForReadOnlyCaller documents "no audit event" as the intended contract but never asserts $eventHits === 0, so the gap goes undetected.

To close it: after $skipAdapterUpdate = true, check whether the caller holds UPDATE permission and set $silentNoop = true when they don't (mirroring the $canTolerateStaleCache block at line 6396–6408).

}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test 6351


$updatePermissions = [
...$collection->getUpdate(),
...($documentSecurity ? $old->getUpdate() : [])
Expand All @@ -6292,7 +6394,29 @@ public function updateDocument(string $collection, string $id, Document $documen

if ($shouldUpdate) {
if (!$this->authorization->isValid(new Input(self::PERMISSION_UPDATE, $updatePermissions))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Load-bearing 4-AND security check inline. Extracted to a named $canTolerateStaleCache boolean so the condition is auditable, and added a comment explaining why it is safe (READ perm required, bare {$updatedAt: ...} still requires UPDATE).

throw new AuthorizationException($this->authorization->getDescription());
// Stale-cache tolerance: a caller without UPDATE perm who has READ
// perm and resubmitted a stale $updatedAt (alongside otherwise
// unchanged fields) likely didn't intend to write — treat as a
// silent no-op instead of an authorization error. Bare
// {$updatedAt: ...} is an explicit timestamp write and still

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CORRECTNESS] When this tolerance branch fires, the caller's $updatedAt diverged from storage — a strong signal that a stale cache entry is the root cause. Without purging, the same stale entry will trigger the same tolerance branch on every subsequent update. Added purgeCachedDocument in 94f0e5c0 plus a regression test that asserts the next non-locking read returns the fresh storage value.

// requires UPDATE perm.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING — Readability] Hoisted the four-AND conditional into a named $staleCacheNoopAllowed predicate with a WHY comment. A future reader (or bisect on a permission bug) needs to be able to find this branch and understand the intent in one glance, not reconstruct it from a 200-char ternary.

$canTolerateStaleCache = $onlyUpdatedAtChanged
&& !$inputIsBareUpdatedAt
&& $updatedAtIsValid
&& $this->authorization->isValid(new Input(self::PERMISSION_READ, $readPermissions));

if ($canTolerateStaleCache) {
$shouldUpdate = false;
$skipAdapterUpdate = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] When the tolerance branch above flips $shouldUpdate to false, control falls through to this $adapter->updateDocument(...) call, then purgeCachedDocument(...), then EVENT_DOCUMENT_UPDATE. The call is sold as a no-op but is not one at the storage layer — read-only callers can force exclusive row writes, evict arbitrary cache entries, and emit spoofed update events for documents they have no UPDATE permission on.

Fixed in the follow-up commit: the tolerance branch now return $olds from inside the transaction and a $tolerantNoop flag short-circuits the post-transaction relationship-population / decode / event-trigger pipeline.

// Auth-rejected → tolerated: the caller lacked UPDATE,
// so don't emit EVENT_DOCUMENT_UPDATE downstream. Firing
// the event here would let a read-only caller probe doc
// existence and inject spurious audit-log entries.
$silentNoop = true;
$document = $old;
} else {
throw new AuthorizationException($this->authorization->getDescription());
}
}
} else {
if (!$this->authorization->isValid(new Input(self::PERMISSION_READ, $readPermissions))) {
Expand All @@ -6301,16 +6425,31 @@ public function updateDocument(string $collection, string $id, Document $documen
}
}

if ($shouldUpdate) {
$document->setAttribute('$updatedAt', ($newUpdatedAt === null || !$this->preserveDates) ? $time : $newUpdatedAt);
}

// Check if document was updated after the request timestamp
// Optimistic-concurrency check runs before the no-op short-circuit:
// a caller that set $this->timestamp asserted "I last saw this doc
// at T; reject if mutated since." Letting a no-op silently return
// $old when storage was concurrently modified past T would violate
// that contract — main's pre-PR code ran this check on every call,
// including no-ops, because every call still hit the adapter.
$oldUpdatedAt = new \DateTime($old->getUpdatedAt());
if (!is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) {
throw new ConflictException('Document was updated after the request timestamp');
}

if ($skipAdapterUpdate) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL — fixed] This no-op early return $old previously skipped the ConflictException check below at line 6438. A caller using withRequestTimestamp() to assert optimistic concurrency would silently succeed even when storage was concurrently modified past the request timestamp — pre-PR main ran the check on every call (since every call hit the adapter). Moved the ConflictException check above this short-circuit so the contract holds for both real updates and no-ops. Added testNoopStillEnforcesRequestTimestampConflict to lock this in.

// No-op: storage is unchanged, so re-running encode/structure-validation/
// casting against $old (which is already decoded + relationships-populated
// by getDocument) would re-apply filters and corrupt the return shape.
// The caller expects the same shape as a real update would return; $old
// already matches that shape.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL — Security/Correctness] Without this early-return, the no-op stale-cache path still fired EVENT_DOCUMENT_UPDATE (line 6473) and ran encode() + Structure validation + relationship updates + castingBefore on $old. A read-only caller could therefore spam audit/webhook listeners with forged update events on any document they have READ access to, and any non-idempotent encode filter (e.g. encrypt/hash) would silently corrupt the returned document. Fixed: skip everything past the auth check on this path; cache is still purged so subsequent reads see the canonical row. $noopSkip is a reference flag so the trailing trigger() outside the transaction closure can be suppressed too.

$isNoop = true;
return $old;
}

if ($shouldUpdate) {
$document->setAttribute('$updatedAt', ($newUpdatedAt === null || !$this->preserveDates) ? $time : $newUpdatedAt);
}

$document = $this->encode($collection, $document);

if ($this->validate) {
Expand Down Expand Up @@ -6365,6 +6504,20 @@ public function updateDocument(string $collection, string $id, Document $documen
return $document;
}

if ($isNoop) {
// $old was returned directly from getDocument(), which already populated
// relationships, decoded filters, and applied the custom document type.
// Re-running those would corrupt the shape; skip post-processing. The
// caller still invoked updateDocument(), so by default emit the event so
// audit listeners and change-stream consumers see the attempt — except
// on the stale-cache tolerance path, where the caller lacked UPDATE perm
// and the event would leak doc existence / forge audit entries.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] Security finding S1 — silent-noop audit gap (FIXED in 46b2ec48).

The pre-fix path returned $old to a caller who had READ but not UPDATE with NO event fired, making the call invisible to audit listeners. A read-only caller could effectively read documents through updateDocument without leaving an audit trail (the regular getDocument would have fired EVENT_DOCUMENT_READ).

Fix: emit EVENT_DOCUMENT_READ on the silent-noop path (caller only had READ) and EVENT_DOCUMENT_UPDATE on the legitimate-noop path (caller has UPDATE). This closes the existence-probe / audit-bypass channel while preserving the original semantic of "no UPDATE event for a caller without UPDATE perm".

if (!$silentNoop) {
$this->trigger(self::EVENT_DOCUMENT_UPDATE, $document);
}
return $document;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING — security/observability] Suppressing EVENT_DOCUMENT_UPDATE on the no-op path breaks the API contract (the caller did invoke updateDocument()) and silently de-trains audit listeners, change-stream consumers, and intrusion-detection counters that watch update events. A read-only caller could now hammer updateDocument with stale resubmits and produce zero observable signal. Fixed by emitting the event with the unchanged document in commit 87d80f5c; subscribers that care about 'did data actually change' can diff timestamps.


if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) {
$documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth));
$document = $documents[0];
Expand Down Expand Up @@ -9460,6 +9613,14 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a
];
}

/**
* @return list<string>
*/
private static function internalMetaKeys(): array
{
return self::$internalMetaKeys ??= \array_column(self::INTERNAL_ATTRIBUTES, '$id');
}

private static function computeCallableSignature(callable $callable): string
{
if (\is_string($callable)) {
Expand Down
6 changes: 3 additions & 3 deletions tests/e2e/Adapter/Scopes/DocumentTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -6146,14 +6146,14 @@ public function testSingleDocumentDateOperations(): void
$originalCreatedAt4 = $doc4->getAttribute('$createdAt');
$originalUpdatedAt4 = $doc4->getAttribute('$updatedAt');

sleep(1); // Ensure $updatedAt differs when adapter timestamp precision is seconds

$doc4->setAttribute('$updatedAt', null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING] Maintainability finding M8 — e2e coverage gap (FIXED in 27cba2be).

Added testGetDocumentForUpdateBypassesCache to the DocumentTests trait: the headline forUpdate: true cache-bypass invariant now runs against every adapter. The unit tests use DatabaseMemory + CacheMemory which don't exercise real SELECT-FOR-UPDATE row-locking semantics, so this fills the regression gap on adapter upgrades.

$doc4->setAttribute('$createdAt', null);
$updatedDoc4 = $database->updateDocument($collection, 'doc4', document: $doc4);

// No content changed and dates were nulled, so the update is a no-op
// and both timestamps are preserved.
$this->assertEquals($originalCreatedAt4, $updatedDoc4->getAttribute('$createdAt'));
$this->assertNotEquals($originalUpdatedAt4, $updatedDoc4->getAttribute('$updatedAt'));
$this->assertEquals($originalUpdatedAt4, $updatedDoc4->getAttribute('$updatedAt'));

// Test 5: Update only updatedAt
$updatedDoc4->setAttribute('$updatedAt', $updateDate);
Expand Down
Loading
Loading