Skip to content

Commit 20c25cd

Browse files
committed
feat(integration-v2): enforce deterministic adapter failures across MySQL, Redis, MongoDB
- Enforce immediate, non-retry failure behavior in IntegrationV2 paths - Disable all implicit retries and fallback logic - Normalize failure propagation strictly through AdapterInterface boundaries - Replace vendor-dependent exceptions with stable, explicit blueprint-defined messages - Ensure consistent failure semantics across MySQL, Redis, and MongoDB drivers
1 parent 64398aa commit 20c25cd

8 files changed

Lines changed: 1173 additions & 1402 deletions

roadmap.json

Lines changed: 723 additions & 252 deletions
Large diffs are not rendered by default.

roadmap1.0.2.json

Lines changed: 0 additions & 906 deletions
This file was deleted.

src/Drivers/AbstractSecurityGuardDriver.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,10 @@ public function isBlocked(string $ip, string $subject): bool
237237
return false;
238238
}
239239

240+
if ($block->expiresAt === 0) {
241+
return true; // permanent block
242+
}
243+
240244
return $block->expiresAt > $this->now();
241245
}
242246

src/Drivers/Mongo/MongoSecurityGuard.php

Lines changed: 180 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use MongoDB\Collection;
2525
use MongoDB\Database;
2626
use RuntimeException;
27+
use Throwable;
2728

2829
final class MongoSecurityGuard extends AbstractSecurityGuardDriver
2930
{
@@ -38,6 +39,8 @@ public function __construct(
3839
) {
3940
parent::__construct($adapter, $strategy);
4041

42+
$this->assertConnected($adapter);
43+
4144
$driver = $adapter->getDriver();
4245

4346
if (!$driver instanceof Database) {
@@ -48,7 +51,57 @@ public function __construct(
4851

4952
$this->db = $driver;
5053

51-
$this->ensureIndexes();
54+
$this->assertCollections();
55+
}
56+
57+
// -------------------------------------------------------------------------
58+
// IntegrationV2 safeguards
59+
// -------------------------------------------------------------------------
60+
61+
private function assertConnected(AdapterInterface $adapter): void
62+
{
63+
if (! $adapter->isConnected()) {
64+
throw new RuntimeException('IntegrationV2 Mongo connection failed.');
65+
}
66+
}
67+
68+
private function assertCollections(): void
69+
{
70+
$required = [$this->attempts, $this->blocks];
71+
72+
try {
73+
$existing = [];
74+
75+
foreach ($this->db->listCollections() as $collectionInfo) {
76+
if (method_exists($collectionInfo, 'getName')) {
77+
$existing[] = $collectionInfo->getName();
78+
}
79+
}
80+
81+
$missing = array_values(array_diff($required, $existing));
82+
83+
if ($missing !== []) {
84+
throw new RuntimeException('IntegrationV2 Mongo schema missing.');
85+
}
86+
} catch (RuntimeException $e) {
87+
throw $e;
88+
} catch (Throwable $e) {
89+
throw new RuntimeException('IntegrationV2 Mongo connection failed.', 0, $e);
90+
}
91+
}
92+
93+
/**
94+
* @template TReturn
95+
* @param callable():TReturn $operation
96+
* @return TReturn
97+
*/
98+
private function executeMongo(callable $operation, string $context)
99+
{
100+
try {
101+
return $operation();
102+
} catch (Throwable $e) {
103+
throw new RuntimeException('IntegrationV2 Mongo connection failed.', 0, $e);
104+
}
52105
}
53106

54107
// -------------------------------------------------------------------------
@@ -68,47 +121,30 @@ private function col(string $collection): Collection
68121
return $col;
69122
}
70123

71-
// -------------------------------------------------------------------------
72-
// Ensure Indexes
73-
// -------------------------------------------------------------------------
74-
75-
private function ensureIndexes(): void
76-
{
77-
// TTL for attempts (1 day)
78-
$this->col($this->attempts)->createIndex(
79-
['occurred_at' => 1],
80-
['expireAfterSeconds' => 86400]
81-
);
82-
83-
// TTL for blocks - expires_at > 0
84-
$this->col($this->blocks)->createIndex(
85-
['expires_at' => 1],
86-
['expireAfterSeconds' => 0]
87-
);
88-
89-
$this->col($this->attempts)->createIndex(['ip' => 1, 'subject' => 1]);
90-
$this->col($this->blocks)->createIndex(['ip' => 1, 'subject' => 1]);
91-
}
92-
93124
// -------------------------------------------------------------------------
94125
// doRecordFailure()
95126
// -------------------------------------------------------------------------
96127

97128
protected function doRecordFailure(LoginAttemptDTO $attempt): int
98129
{
99-
$this->col($this->attempts)->insertOne([
100-
'ip' => $attempt->ip,
101-
'subject' => $attempt->subject,
102-
'occurred_at' => $attempt->occurredAt,
103-
]);
104-
105-
$window = $this->now() - 3600;
106-
107-
return $this->col($this->attempts)->countDocuments([
108-
'ip' => $attempt->ip,
109-
'subject' => $attempt->subject,
110-
'occurred_at' => ['$gte' => $window],
111-
]);
130+
return $this->executeMongo(
131+
function () use ($attempt): int {
132+
$this->col($this->attempts)->insertOne([
133+
'ip' => $attempt->ip,
134+
'subject' => $attempt->subject,
135+
'occurred_at' => $attempt->occurredAt,
136+
]);
137+
138+
$window = $this->now() - 3600;
139+
140+
return $this->col($this->attempts)->countDocuments([
141+
'ip' => $attempt->ip,
142+
'subject' => $attempt->subject,
143+
'occurred_at' => ['$gte' => $window],
144+
]);
145+
},
146+
'recordFailure'
147+
);
112148
}
113149

114150
// -------------------------------------------------------------------------
@@ -117,10 +153,15 @@ protected function doRecordFailure(LoginAttemptDTO $attempt): int
117153

118154
protected function doResetAttempts(string $ip, string $subject): void
119155
{
120-
$this->col($this->attempts)->deleteMany([
121-
'ip' => $ip,
122-
'subject' => $subject,
123-
]);
156+
$this->executeMongo(
157+
function () use ($ip, $subject): void {
158+
$this->col($this->attempts)->deleteMany([
159+
'ip' => $ip,
160+
'subject' => $subject,
161+
]);
162+
},
163+
'resetAttempts'
164+
);
124165
}
125166

126167
// -------------------------------------------------------------------------
@@ -129,38 +170,43 @@ protected function doResetAttempts(string $ip, string $subject): void
129170

130171
protected function doGetActiveBlock(string $ip, string $subject): ?SecurityBlockDTO
131172
{
132-
$now = $this->now();
133-
134-
/** @var array{
135-
* type:string,
136-
* expires_at:int,
137-
* created_at:int
138-
* }|null $row
139-
*/
140-
$row = $this->col($this->blocks)->findOne([
141-
'ip' => $ip,
142-
'subject' => $subject,
143-
'$or' => [
144-
['expires_at' => 0],
145-
['expires_at' => ['$gt' => $now]],
146-
],
147-
]);
148-
149-
if ($row === null) {
150-
return null;
151-
}
152-
153-
$type = BlockTypeEnum::tryFrom($row['type']);
154-
if ($type === null) {
155-
return null;
156-
}
157-
158-
return new SecurityBlockDTO(
159-
ip: $ip,
160-
subject: $subject,
161-
type: $type,
162-
expiresAt: (int)$row['expires_at'],
163-
createdAt: (int)$row['created_at'],
173+
return $this->executeMongo(
174+
function () use ($ip, $subject): ?SecurityBlockDTO {
175+
$now = $this->now();
176+
177+
/** @var array{
178+
* type:string,
179+
* expires_at:int,
180+
* created_at:int
181+
* }|null $row
182+
*/
183+
$row = $this->col($this->blocks)->findOne([
184+
'ip' => $ip,
185+
'subject' => $subject,
186+
'$or' => [
187+
['expires_at' => 0],
188+
['expires_at' => ['$gt' => $now]],
189+
],
190+
]);
191+
192+
if ($row === null) {
193+
return null;
194+
}
195+
196+
$type = BlockTypeEnum::tryFrom($row['type']);
197+
if ($type === null) {
198+
return null;
199+
}
200+
201+
return new SecurityBlockDTO(
202+
ip: $ip,
203+
subject: $subject,
204+
type: $type,
205+
expiresAt: (int)$row['expires_at'],
206+
createdAt: (int)$row['created_at'],
207+
);
208+
},
209+
'getActiveBlock'
164210
);
165211
}
166212

@@ -170,17 +216,22 @@ protected function doGetActiveBlock(string $ip, string $subject): ?SecurityBlock
170216

171217
protected function doGetRemainingBlockSeconds(string $ip, string $subject): ?int
172218
{
173-
$block = $this->doGetActiveBlock($ip, $subject);
219+
return $this->executeMongo(
220+
function () use ($ip, $subject): ?int {
221+
$block = $this->doGetActiveBlock($ip, $subject);
174222

175-
if ($block === null) {
176-
return null;
177-
}
223+
if ($block === null) {
224+
return null;
225+
}
178226

179-
if ($block->expiresAt === 0) {
180-
return null; // Permanent block
181-
}
227+
if ($block->expiresAt === 0) {
228+
return null; // Permanent block
229+
}
182230

183-
return max(0, $block->expiresAt - $this->now());
231+
return max(0, $block->expiresAt - $this->now());
232+
},
233+
'getRemainingBlockSeconds'
234+
);
184235
}
185236

186237
// -------------------------------------------------------------------------
@@ -189,16 +240,21 @@ protected function doGetRemainingBlockSeconds(string $ip, string $subject): ?int
189240

190241
protected function doBlock(SecurityBlockDTO $block): void
191242
{
192-
$this->col($this->blocks)->updateOne(
193-
['ip' => $block->ip, 'subject' => $block->subject],
194-
[
195-
'$set' => [
196-
'type' => $block->type->value,
197-
'expires_at' => $block->expiresAt,
198-
'created_at' => $block->createdAt,
199-
],
200-
],
201-
['upsert' => true]
243+
$this->executeMongo(
244+
function () use ($block): void {
245+
$this->col($this->blocks)->updateOne(
246+
['ip' => $block->ip, 'subject' => $block->subject],
247+
[
248+
'$set' => [
249+
'type' => $block->type->value,
250+
'expires_at' => $block->expiresAt,
251+
'created_at' => $block->createdAt,
252+
],
253+
],
254+
['upsert' => true]
255+
);
256+
},
257+
'block'
202258
);
203259
}
204260

@@ -208,10 +264,15 @@ protected function doBlock(SecurityBlockDTO $block): void
208264

209265
protected function doUnblock(string $ip, string $subject): void
210266
{
211-
$this->col($this->blocks)->deleteOne([
212-
'ip' => $ip,
213-
'subject' => $subject,
214-
]);
267+
$this->executeMongo(
268+
function () use ($ip, $subject): void {
269+
$this->col($this->blocks)->deleteOne([
270+
'ip' => $ip,
271+
'subject' => $subject,
272+
]);
273+
},
274+
'unblock'
275+
);
215276
}
216277

217278
// -------------------------------------------------------------------------
@@ -220,14 +281,19 @@ protected function doUnblock(string $ip, string $subject): void
220281

221282
protected function doCleanup(): void
222283
{
223-
$now = $this->now();
224-
225-
// Remove expired blocks (except permanent)
226-
$this->col($this->blocks)->deleteMany([
227-
'expires_at' => ['$ne' => 0, '$lte' => $now],
228-
]);
229-
230-
// Attempts cleaned automatically via TTL index
284+
$this->executeMongo(
285+
function (): void {
286+
$now = $this->now();
287+
288+
// Remove expired blocks (except permanent)
289+
$this->col($this->blocks)->deleteMany([
290+
'expires_at' => ['$ne' => 0, '$lte' => $now],
291+
]);
292+
293+
// Attempts cleaned automatically via TTL index
294+
},
295+
'cleanup'
296+
);
231297
}
232298

233299
// -------------------------------------------------------------------------
@@ -236,12 +302,17 @@ protected function doCleanup(): void
236302

237303
protected function doGetStats(): array
238304
{
239-
$attempts = $this->col($this->attempts)->countDocuments();
240-
$blocks = $this->col($this->blocks)->countDocuments();
241-
242-
return [
243-
'attempts' => $attempts,
244-
'blocks' => $blocks,
245-
];
305+
return $this->executeMongo(
306+
function (): array {
307+
$attempts = $this->col($this->attempts)->countDocuments();
308+
$blocks = $this->col($this->blocks)->countDocuments();
309+
310+
return [
311+
'attempts' => $attempts,
312+
'blocks' => $blocks,
313+
];
314+
},
315+
'getStats'
316+
);
246317
}
247318
}

0 commit comments

Comments
 (0)