pyric 0.1.0-alpha.19 → 0.1.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -89
- package/dist/ai/blocked.d.ts +52 -0
- package/dist/ai/blocked.d.ts.map +1 -0
- package/dist/ai/blocked.js +83 -0
- package/dist/ai/blocked.js.map +1 -0
- package/dist/ai/broker/broker.d.ts +50 -0
- package/dist/ai/broker/broker.d.ts.map +1 -1
- package/dist/ai/broker/broker.js +157 -21
- package/dist/ai/broker/broker.js.map +1 -1
- package/dist/ai/broker/gemini-engine.d.ts +56 -0
- package/dist/ai/broker/gemini-engine.d.ts.map +1 -0
- package/dist/ai/broker/gemini-engine.js +235 -0
- package/dist/ai/broker/gemini-engine.js.map +1 -0
- package/dist/ai/broker/index.d.ts +2 -1
- package/dist/ai/broker/index.d.ts.map +1 -1
- package/dist/ai/broker/index.js +2 -1
- package/dist/ai/broker/index.js.map +1 -1
- package/dist/ai/broker/openai-engine.d.ts +10 -1
- package/dist/ai/broker/openai-engine.d.ts.map +1 -1
- package/dist/ai/broker/openai-engine.js +26 -4
- package/dist/ai/broker/openai-engine.js.map +1 -1
- package/dist/ai/broker/synthesizer.d.ts +14 -0
- package/dist/ai/broker/synthesizer.d.ts.map +1 -1
- package/dist/ai/broker/synthesizer.js +31 -0
- package/dist/ai/broker/synthesizer.js.map +1 -1
- package/dist/ai/broker/types.d.ts +29 -0
- package/dist/ai/broker/types.d.ts.map +1 -1
- package/dist/ai/instances.js +1 -1
- package/dist/ai/instances.js.map +1 -1
- package/dist/ai/internal.d.ts +5 -0
- package/dist/ai/internal.d.ts.map +1 -1
- package/dist/ai/internal.js +5 -0
- package/dist/ai/internal.js.map +1 -1
- package/dist/ai/response-helpers.d.ts.map +1 -1
- package/dist/ai/response-helpers.js +6 -24
- package/dist/ai/response-helpers.js.map +1 -1
- package/dist/analytics/index.d.ts +40 -0
- package/dist/analytics/index.d.ts.map +1 -0
- package/dist/analytics/index.js +25 -0
- package/dist/analytics/index.js.map +1 -0
- package/dist/app-check/index.d.ts +31 -0
- package/dist/app-check/index.d.ts.map +1 -0
- package/dist/app-check/index.js +20 -0
- package/dist/app-check/index.js.map +1 -0
- package/dist/auth/instances.js +1 -1
- package/dist/auth/instances.js.map +1 -1
- package/dist/database/controls.d.ts +1 -1
- package/dist/database/controls.js +1 -1
- package/dist/database/instances.js +1 -1
- package/dist/database/instances.js.map +1 -1
- package/dist/database/sandbox/backend.d.ts +1 -0
- package/dist/database/sandbox/backend.d.ts.map +1 -1
- package/dist/database/sandbox/backend.js +1 -0
- package/dist/database/sandbox/backend.js.map +1 -1
- package/dist/database/sandbox/rules-eval.d.ts +6 -2
- package/dist/database/sandbox/rules-eval.d.ts.map +1 -1
- package/dist/database/sandbox/rules-eval.js +15 -2
- package/dist/database/sandbox/rules-eval.js.map +1 -1
- package/dist/database/sandbox/write-plane.d.ts +1 -0
- package/dist/database/sandbox/write-plane.d.ts.map +1 -1
- package/dist/database/sandbox/write-plane.js +7 -2
- package/dist/database/sandbox/write-plane.js.map +1 -1
- package/dist/database/sandbox-controls.d.ts +12 -5
- package/dist/database/sandbox-controls.d.ts.map +1 -1
- package/dist/database/sandbox-controls.js +20 -9
- package/dist/database/sandbox-controls.js.map +1 -1
- package/dist/database/sandbox-namespace.d.ts +19 -1
- package/dist/database/sandbox-namespace.d.ts.map +1 -1
- package/dist/database/sandbox-namespace.js +24 -1
- package/dist/database/sandbox-namespace.js.map +1 -1
- package/dist/deferred/entry.d.ts +75 -0
- package/dist/deferred/entry.d.ts.map +1 -0
- package/dist/deferred/entry.js +143 -0
- package/dist/deferred/entry.js.map +1 -0
- package/dist/firestore/instances.js +1 -1
- package/dist/firestore/instances.js.map +1 -1
- package/dist/firestore/lite.d.ts +72 -0
- package/dist/firestore/lite.d.ts.map +1 -0
- package/dist/firestore/lite.js +22 -0
- package/dist/firestore/lite.js.map +1 -0
- package/dist/firestore/persistence.d.ts +2 -2
- package/dist/firestore/persistence.js +1 -1
- package/dist/firestore/sandbox-controls.d.ts +2 -0
- package/dist/firestore/sandbox-controls.d.ts.map +1 -1
- package/dist/firestore/sandbox-controls.js +8 -0
- package/dist/firestore/sandbox-controls.js.map +1 -1
- package/dist/functions/index.d.ts +29 -0
- package/dist/functions/index.d.ts.map +1 -0
- package/dist/functions/index.js +20 -0
- package/dist/functions/index.js.map +1 -0
- package/dist/messaging/client.d.ts +2 -1
- package/dist/messaging/client.d.ts.map +1 -1
- package/dist/messaging/client.js +2 -2
- package/dist/messaging/client.js.map +1 -1
- package/dist/messaging/instance.d.ts +2 -2
- package/dist/messaging/instance.d.ts.map +1 -1
- package/dist/messaging/instance.js +40 -6
- package/dist/messaging/instance.js.map +1 -1
- package/dist/messaging/sw.d.ts +2 -1
- package/dist/messaging/sw.d.ts.map +1 -1
- package/dist/messaging/sw.js +2 -2
- package/dist/messaging/sw.js.map +1 -1
- package/dist/performance/index.d.ts +22 -0
- package/dist/performance/index.d.ts.map +1 -0
- package/dist/performance/index.js +19 -0
- package/dist/performance/index.js.map +1 -0
- package/dist/remote-config/index.d.ts +40 -0
- package/dist/remote-config/index.d.ts.map +1 -0
- package/dist/remote-config/index.js +25 -0
- package/dist/remote-config/index.js.map +1 -0
- package/dist/rules/grammar/FirestoreValidator.d.ts.map +1 -1
- package/dist/rules/grammar/FirestoreValidator.js +43 -27
- package/dist/rules/grammar/FirestoreValidator.js.map +1 -1
- package/dist/rules/grammar/document-access-count.d.ts +24 -0
- package/dist/rules/grammar/document-access-count.d.ts.map +1 -0
- package/dist/rules/grammar/document-access-count.js +100 -0
- package/dist/rules/grammar/document-access-count.js.map +1 -0
- package/dist/rules/linter/ast-utils.d.ts +2 -6
- package/dist/rules/linter/ast-utils.d.ts.map +1 -1
- package/dist/rules/linter/ast-utils.js +2 -63
- package/dist/rules/linter/ast-utils.js.map +1 -1
- package/dist/rules/linter/hallucinations.d.ts +8 -0
- package/dist/rules/linter/hallucinations.d.ts.map +1 -1
- package/dist/rules/linter/hallucinations.js +121 -31
- package/dist/rules/linter/hallucinations.js.map +1 -1
- package/dist/rules/linter/linter.d.ts +10 -1
- package/dist/rules/linter/linter.d.ts.map +1 -1
- package/dist/rules/linter/linter.js +23 -9
- package/dist/rules/linter/linter.js.map +1 -1
- package/dist/rules/rtdb/grammar/RtdbExpr.ohm +5 -2
- package/dist/rules/rtdb/grammar/RtdbExpr.ohm.generated.d.ts +1 -1
- package/dist/rules/rtdb/grammar/RtdbExpr.ohm.generated.d.ts.map +1 -1
- package/dist/rules/rtdb/grammar/RtdbExpr.ohm.generated.js +5 -2
- package/dist/rules/rtdb/grammar/RtdbExpr.ohm.generated.js.map +1 -1
- package/dist/rules/rtdb/grammar/simulator.d.ts.map +1 -1
- package/dist/rules/rtdb/grammar/simulator.js +61 -15
- package/dist/rules/rtdb/grammar/simulator.js.map +1 -1
- package/dist/rules/rtdb/simulation/handler.d.ts.map +1 -1
- package/dist/rules/rtdb/simulation/handler.js +96 -35
- package/dist/rules/rtdb/simulation/handler.js.map +1 -1
- package/dist/rules/simulator/document-lookups.d.ts +4 -0
- package/dist/rules/simulator/document-lookups.d.ts.map +1 -1
- package/dist/rules/simulator/document-lookups.js +42 -3
- package/dist/rules/simulator/document-lookups.js.map +1 -1
- package/dist/rules/simulator/eval-error.d.ts +11 -0
- package/dist/rules/simulator/eval-error.d.ts.map +1 -1
- package/dist/rules/simulator/eval-error.js +14 -0
- package/dist/rules/simulator/eval-error.js.map +1 -1
- package/dist/rules/simulator/evaluation-builtins.d.ts.map +1 -1
- package/dist/rules/simulator/evaluation-builtins.js +33 -8
- package/dist/rules/simulator/evaluation-builtins.js.map +1 -1
- package/dist/rules/simulator/evaluation-context.d.ts +12 -0
- package/dist/rules/simulator/evaluation-context.d.ts.map +1 -1
- package/dist/rules/simulator/evaluator.d.ts +1 -1
- package/dist/rules/simulator/evaluator.d.ts.map +1 -1
- package/dist/rules/simulator/evaluator.js +16 -4
- package/dist/rules/simulator/evaluator.js.map +1 -1
- package/dist/rules/simulator/handler.d.ts.map +1 -1
- package/dist/rules/simulator/handler.js +52 -5
- package/dist/rules/simulator/handler.js.map +1 -1
- package/dist/rules/simulator/lookup-budget.d.ts +37 -0
- package/dist/rules/simulator/lookup-budget.d.ts.map +1 -0
- package/dist/rules/simulator/lookup-budget.js +96 -0
- package/dist/rules/simulator/lookup-budget.js.map +1 -0
- package/dist/rules/stdlib-modules.d.ts +7 -0
- package/dist/rules/stdlib-modules.d.ts.map +1 -1
- package/dist/rules/stdlib-modules.js +11 -11
- package/dist/rules/stdlib-modules.js.map +1 -1
- package/dist/sandbox/admin-firestore/get-firestore.d.ts +6 -0
- package/dist/sandbox/admin-firestore/get-firestore.d.ts.map +1 -1
- package/dist/sandbox/admin-firestore/get-firestore.js +22 -3
- package/dist/sandbox/admin-firestore/get-firestore.js.map +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.d.ts.map +1 -1
- package/dist/sandbox/index.js +1 -1
- package/dist/sandbox/index.js.map +1 -1
- package/dist/sandbox/persistence/types.d.ts +1 -1
- package/dist/sandbox/remote.d.ts +2 -2
- package/dist/sandbox/sandbox-context.d.ts +8 -1
- package/dist/sandbox/sandbox-context.d.ts.map +1 -1
- package/dist/sandbox/sandbox-context.js +59 -8
- package/dist/sandbox/sandbox-context.js.map +1 -1
- package/dist/sandbox/types/auth-state.d.ts +1 -0
- package/dist/sandbox/types/auth-state.d.ts.map +1 -1
- package/dist/sandbox/types/events.d.ts +2 -1
- package/dist/sandbox/types/events.d.ts.map +1 -1
- package/dist/sandbox/types/operation.d.ts +1 -0
- package/dist/sandbox/types/operation.d.ts.map +1 -1
- package/dist/storage/enforce.d.ts +20 -1
- package/dist/storage/enforce.d.ts.map +1 -1
- package/dist/storage/enforce.js +47 -14
- package/dist/storage/enforce.js.map +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/list.js +1 -1
- package/dist/storage/list.js.map +1 -1
- package/dist/storage/persistence.d.ts.map +1 -1
- package/dist/storage/persistence.js +66 -14
- package/dist/storage/persistence.js.map +1 -1
- package/dist/storage/sandbox/rules-evaluation-error.d.ts +25 -0
- package/dist/storage/sandbox/rules-evaluation-error.d.ts.map +1 -1
- package/dist/storage/sandbox/rules-evaluation-error.js +29 -0
- package/dist/storage/sandbox/rules-evaluation-error.js.map +1 -1
- package/dist/storage/sandbox/rules-evaluator.d.ts +10 -5
- package/dist/storage/sandbox/rules-evaluator.d.ts.map +1 -1
- package/dist/storage/sandbox/rules-evaluator.js +105 -33
- package/dist/storage/sandbox/rules-evaluator.js.map +1 -1
- package/dist/storage/sandbox/rules-methods.d.ts.map +1 -1
- package/dist/storage/sandbox/rules-methods.js +17 -7
- package/dist/storage/sandbox/rules-methods.js.map +1 -1
- package/dist/storage/service.d.ts +23 -2
- package/dist/storage/service.d.ts.map +1 -1
- package/dist/storage/service.js +37 -26
- package/dist/storage/service.js.map +1 -1
- package/package.json +42 -2
- package/src/ai/blocked.ts +97 -0
- package/src/ai/broker/broker.ts +153 -22
- package/src/ai/broker/gemini-engine.ts +311 -0
- package/src/ai/broker/index.ts +2 -0
- package/src/ai/broker/openai-engine.ts +31 -3
- package/src/ai/broker/synthesizer.ts +34 -0
- package/src/ai/broker/types.ts +27 -1
- package/src/ai/instances.ts +1 -1
- package/src/ai/internal.ts +5 -0
- package/src/ai/response-helpers.ts +6 -25
- package/src/analytics/index.ts +53 -0
- package/src/app/dispatch.test.ts +3 -1
- package/src/app-check/index.ts +41 -0
- package/src/auth/instances.ts +1 -1
- package/src/database/controls.ts +1 -1
- package/src/database/instances.ts +1 -1
- package/src/database/sandbox/backend.ts +1 -0
- package/src/database/sandbox/rules-eval.ts +18 -2
- package/src/database/sandbox/write-plane.ts +8 -2
- package/src/database/sandbox-controls.ts +29 -9
- package/src/database/sandbox-namespace.ts +28 -1
- package/src/deferred/entry.ts +167 -0
- package/src/firestore/instances.ts +1 -1
- package/src/firestore/lite.ts +91 -0
- package/src/firestore/persistence.ts +2 -2
- package/src/firestore/sandbox-controls.ts +10 -0
- package/src/functions/index.ts +38 -0
- package/src/messaging/client.ts +3 -2
- package/src/messaging/instance.ts +44 -7
- package/src/messaging/sw.ts +3 -2
- package/src/performance/index.ts +31 -0
- package/src/remote-config/index.ts +53 -0
- package/src/rules/grammar/FirestoreValidator.ts +44 -28
- package/src/rules/grammar/document-access-count.ts +75 -0
- package/src/rules/linter/ast-utils.ts +2 -36
- package/src/rules/linter/hallucinations.ts +123 -25
- package/src/rules/linter/linter.ts +32 -10
- package/src/rules/rtdb/grammar/RtdbExpr.ohm +5 -2
- package/src/rules/rtdb/grammar/RtdbExpr.ohm.generated.ts +5 -2
- package/src/rules/rtdb/grammar/simulator.ts +41 -15
- package/src/rules/rtdb/simulation/handler.ts +149 -46
- package/src/rules/simulator/document-lookups.ts +46 -3
- package/src/rules/simulator/eval-error.ts +16 -0
- package/src/rules/simulator/evaluation-builtins.ts +35 -8
- package/src/rules/simulator/evaluation-context.ts +12 -0
- package/src/rules/simulator/evaluator.ts +14 -3
- package/src/rules/simulator/handler.ts +68 -4
- package/src/rules/simulator/lookup-budget.ts +101 -0
- package/src/rules/stdlib-modules.ts +19 -11
- package/src/sandbox/admin-firestore/get-firestore.ts +19 -3
- package/src/sandbox/index.ts +5 -1
- package/src/sandbox/persistence/types.ts +1 -1
- package/src/sandbox/remote.ts +2 -2
- package/src/sandbox/sandbox-context.ts +69 -9
- package/src/sandbox/types/auth-state.ts +1 -1
- package/src/sandbox/types/events.ts +2 -1
- package/src/sandbox/types/operation.ts +1 -1
- package/src/storage/enforce.ts +53 -14
- package/src/storage/index.ts +1 -1
- package/src/storage/list.ts +1 -1
- package/src/storage/persistence.ts +74 -16
- package/src/storage/sandbox/rules-evaluation-error.ts +32 -0
- package/src/storage/sandbox/rules-evaluator.ts +109 -32
- package/src/storage/sandbox/rules-methods.ts +21 -7
- package/src/storage/service.ts +93 -27
- package/README.md.orig +0 -53
|
@@ -24,6 +24,8 @@ import { evaluate, UnsupportedError, TraceRecorder, type SimulationContext } fro
|
|
|
24
24
|
|
|
25
25
|
import { Timestamp } from './wrappers/timestamp.js';
|
|
26
26
|
import { Path } from './wrappers/path.js';
|
|
27
|
+
import { LookupBudget } from './lookup-budget.js';
|
|
28
|
+
import { ResourceLimitError } from './eval-error.js';
|
|
27
29
|
import { projectAfterState } from './project-after-state.js';
|
|
28
30
|
import {
|
|
29
31
|
requestQuery,
|
|
@@ -75,12 +77,27 @@ function methodToOperations(method: string): string[] {
|
|
|
75
77
|
|
|
76
78
|
// ═══ Rule evaluation ═══
|
|
77
79
|
|
|
80
|
+
/**
|
|
81
|
+
* One match block's contribution to the request verdict. `resourceLimit`
|
|
82
|
+
* carries a per-request resource limit (the document access budget) that
|
|
83
|
+
* the block ran into: it is not this block's private failure, so the caller
|
|
84
|
+
* stops evaluating siblings and denies the whole request.
|
|
85
|
+
*/
|
|
86
|
+
interface RuleBlockOutcome {
|
|
87
|
+
decision: Decision;
|
|
88
|
+
trace: RuleEvaluation[];
|
|
89
|
+
notes: string[];
|
|
90
|
+
resourceLimit?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
78
93
|
/**
|
|
79
94
|
* Evaluate all allow rules in a match block for a given operation.
|
|
80
95
|
* Uses OR semantics — if any matching rule allows, access is granted.
|
|
81
96
|
*
|
|
82
97
|
* Decision logic:
|
|
83
98
|
* - any rule ALLOW → ALLOW (short-circuit)
|
|
99
|
+
* - else if any rule hit a per-request resource limit → DENY, and the
|
|
100
|
+
* caller stops: the limit ends the request, not just this block
|
|
84
101
|
* - else if any rule threw UnsupportedError → UNSUPPORTED (sim abstains)
|
|
85
102
|
* - else → DENY
|
|
86
103
|
*
|
|
@@ -93,7 +110,7 @@ function evaluateRules(
|
|
|
93
110
|
operation: string,
|
|
94
111
|
ctx: SimulationContext,
|
|
95
112
|
source?: string,
|
|
96
|
-
):
|
|
113
|
+
): RuleBlockOutcome {
|
|
97
114
|
const ops = methodToOperations(operation);
|
|
98
115
|
const trace: RuleEvaluation[] = [];
|
|
99
116
|
const notes: string[] = [];
|
|
@@ -140,6 +157,27 @@ function evaluateRules(
|
|
|
140
157
|
trace.push(entry);
|
|
141
158
|
} catch (e) {
|
|
142
159
|
entry.expressionTrace = recorder.entries;
|
|
160
|
+
// A per-request resource limit (the document access budget) is not a
|
|
161
|
+
// failure of THIS rule: production stops the whole request there, so
|
|
162
|
+
// no sibling allow rule and no other match block gets a chance to
|
|
163
|
+
// grant. Abandon the loop and report the limit up to the caller.
|
|
164
|
+
const isResourceLimit = e instanceof ResourceLimitError;
|
|
165
|
+
if (isResourceLimit) {
|
|
166
|
+
entry.verdict = 'ERROR';
|
|
167
|
+
entry.message = (e as ResourceLimitError).message;
|
|
168
|
+
trace.push(entry);
|
|
169
|
+
ctx.trace = priorRecorder;
|
|
170
|
+
notes.push(
|
|
171
|
+
`Request denied by a rules resource limit: ${(e as ResourceLimitError).message}. `
|
|
172
|
+
+ 'The limit is per request, so no other allow rule or match block was evaluated.',
|
|
173
|
+
);
|
|
174
|
+
return {
|
|
175
|
+
decision: 'DENY',
|
|
176
|
+
trace,
|
|
177
|
+
notes,
|
|
178
|
+
resourceLimit: (e as ResourceLimitError).message,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
143
181
|
const isUnsupported = e instanceof UnsupportedError;
|
|
144
182
|
if (isUnsupported) {
|
|
145
183
|
sawUnsupported = true;
|
|
@@ -220,6 +258,7 @@ function buildContext(
|
|
|
220
258
|
pathVariables: Record<string, string>,
|
|
221
259
|
getDoc?: (path: string) => Record<string, unknown> | null,
|
|
222
260
|
batchProjection?: Map<string, Record<string, unknown> | null>,
|
|
261
|
+
lookupBudget?: LookupBudget,
|
|
223
262
|
): SimulationContext {
|
|
224
263
|
const fnMap = new Map<string, FunctionDef>();
|
|
225
264
|
for (const fn of functions) fnMap.set(fn.name, fn);
|
|
@@ -366,6 +405,12 @@ function buildContext(
|
|
|
366
405
|
// getafter-batch fix — shared batch/transaction projection, when the
|
|
367
406
|
// caller supplied one. Absent for single-op evaluation.
|
|
368
407
|
...(batchProjection ? { batchProjection } : {}),
|
|
408
|
+
// Per-request document access budget (10 distinct lookups). The SAME
|
|
409
|
+
// instance is threaded into every match block's context for one test
|
|
410
|
+
// case, because production's budget spans overlapping match blocks and
|
|
411
|
+
// OR'd allow rules within one request evaluation. An absent budget and
|
|
412
|
+
// an `undefined` one mean the same thing here: no budget is enforced.
|
|
413
|
+
lookupBudget,
|
|
369
414
|
};
|
|
370
415
|
}
|
|
371
416
|
|
|
@@ -538,9 +583,18 @@ export class SimulateFirestoreRulesHandler {
|
|
|
538
583
|
const notes: string[] = [];
|
|
539
584
|
let sawUnsupported = false;
|
|
540
585
|
let grantingBlockPath: string | undefined;
|
|
586
|
+
let hitResourceLimit = false;
|
|
587
|
+
// One lookup budget per test case (one request evaluation), shared
|
|
588
|
+
// across every matching block below and reset here between requests.
|
|
589
|
+
// Production's single-request budget is 10 distinct document
|
|
590
|
+
// accesses; transactions and batched writes additionally get a
|
|
591
|
+
// 20-access aggregate that is NOT modeled, because each per-op
|
|
592
|
+
// simulate() call in a batch (see WriteRuntime.buildBatchProjection)
|
|
593
|
+
// gets its own fresh per-op budget of 10.
|
|
594
|
+
const lookupBudget = new LookupBudget();
|
|
541
595
|
for (const match of matches) {
|
|
542
596
|
const pathVars = { ...rootBindings, ...match.pathVariables };
|
|
543
|
-
const ctx = buildContext(tc, match.functions, pathVars, opts?.getDoc, opts?.batchProjection);
|
|
597
|
+
const ctx = buildContext(tc, match.functions, pathVars, opts?.getDoc, opts?.batchProjection, lookupBudget);
|
|
544
598
|
|
|
545
599
|
const blockPath = renderMatchBlockPath(match.block);
|
|
546
600
|
const res = evaluateRules(match.block, tc.method, ctx, source);
|
|
@@ -550,6 +604,14 @@ export class SimulateFirestoreRulesHandler {
|
|
|
550
604
|
trace.push(...res.trace);
|
|
551
605
|
notes.push(...res.notes);
|
|
552
606
|
|
|
607
|
+
// A per-request resource limit ends the request: production stops
|
|
608
|
+
// the whole evaluation, so a later match block cannot grant.
|
|
609
|
+
const isResourceLimit = res.resourceLimit !== undefined;
|
|
610
|
+
if (isResourceLimit) {
|
|
611
|
+
decision = 'DENY';
|
|
612
|
+
hitResourceLimit = true;
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
553
615
|
const isResAllow = res.decision === 'ALLOW';
|
|
554
616
|
if (isResAllow) {
|
|
555
617
|
decision = 'ALLOW';
|
|
@@ -561,8 +623,10 @@ export class SimulateFirestoreRulesHandler {
|
|
|
561
623
|
sawUnsupported = true;
|
|
562
624
|
}
|
|
563
625
|
}
|
|
564
|
-
|
|
565
|
-
|
|
626
|
+
// A resource limit is a definite production DENY, so it outranks an
|
|
627
|
+
// UNSUPPORTED abstention recorded by an earlier block.
|
|
628
|
+
const isEscalatable = decision !== 'ALLOW' && !hitResourceLimit;
|
|
629
|
+
if (isEscalatable) {
|
|
566
630
|
if (sawUnsupported) {
|
|
567
631
|
decision = 'UNSUPPORTED';
|
|
568
632
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { SimulationContext } from './evaluation-context.js';
|
|
2
|
+
import { ResourceLimitError } from './eval-error.js';
|
|
3
|
+
import { isDocumentPath, normalizeDocumentPath } from './document-lookups.js';
|
|
4
|
+
|
|
5
|
+
// ═══ Per-request document-lookup budget ═══
|
|
6
|
+
//
|
|
7
|
+
// Production hard-limits security-rule document access calls, get(),
|
|
8
|
+
// exists(), getAfter() and existsAfter(), to 10 per single-document request
|
|
9
|
+
// or query evaluation. Multi-document transactions and batched writes get
|
|
10
|
+
// an AGGREGATE budget of 20 on top of a per-operation 10; only the per-
|
|
11
|
+
// operation 10 is modeled here (each batched op's simulate() call gets its
|
|
12
|
+
// own fresh LookupBudget, mirroring how WriteRuntime.buildBatchProjection
|
|
13
|
+
// shares one projection map across per-op simulate() calls but each op is
|
|
14
|
+
// its own rules evaluation).
|
|
15
|
+
//
|
|
16
|
+
// Counting semantics: distinct accesses, not raw calls. In-repo production
|
|
17
|
+
// evidence:
|
|
18
|
+
// - site-docs secure/firestore-rules-limits.md ("More than 10 document
|
|
19
|
+
// access calls"): "Repeated reads of the same path are cached; reads of
|
|
20
|
+
// different paths are not."
|
|
21
|
+
// - rules/stdlib-modules.ts spaces guidance: "get() is cached per request,
|
|
22
|
+
// so all rules on the request share a single read of the 10-get budget."
|
|
23
|
+
// - The Storage sibling limit (storage/sandbox/rules-methods.ts) uses a
|
|
24
|
+
// per-request distinct-path set, verified by real-resource capture.
|
|
25
|
+
//
|
|
26
|
+
// What is implemented, exactly: the budget keys on (kind, path), where kind
|
|
27
|
+
// is 'doc' for pre-write reads (get/exists) and 'after' for post-write
|
|
28
|
+
// projections (getAfter/existsAfter). So get() and exists() on one path
|
|
29
|
+
// share a slot, getAfter() and existsAfter() on one path share a slot, and
|
|
30
|
+
// a path read by BOTH a pre-write and a post-write call charges TWO slots.
|
|
31
|
+
// The two calls read different snapshots, so charging them separately is the
|
|
32
|
+
// fail-closed direction while production's cache granularity is uncaptured.
|
|
33
|
+
//
|
|
34
|
+
// TODO(unverified): a credentialed production capture is needed to pin
|
|
35
|
+
// (a) whether get() and exists() on one path really share one cache slot,
|
|
36
|
+
// (b) whether getAfter() on a path already read by get() charges a second
|
|
37
|
+
// slot or reuses the first, and (c) whether exhausting the budget is
|
|
38
|
+
// absorbable by a determining CEL operand (modeled: not absorbable). The
|
|
39
|
+
// capture is a ruleset whose condition performs 10 lookups and then an
|
|
40
|
+
// 11th, deployed and probed against a live project. The Rules Test API does
|
|
41
|
+
// not enforce the budget, so it cannot produce this evidence.
|
|
42
|
+
|
|
43
|
+
/** Production's per-evaluation document access budget (single request). */
|
|
44
|
+
const DOCUMENT_LOOKUP_LIMIT = 10;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Budget exhaustion. A {@link ResourceLimitError}, not a plain EvalError,
|
|
48
|
+
* because it fails the WHOLE request closed: CEL error absorption
|
|
49
|
+
* (`error || true`, `error && false`) must not swallow it into an ALLOW,
|
|
50
|
+
* and a sibling allow rule or match block must not evaluate past it.
|
|
51
|
+
*/
|
|
52
|
+
export class LookupBudgetError extends ResourceLimitError {
|
|
53
|
+
constructor(message: string) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = 'LookupBudgetError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Per-request lookup counter. The handler creates ONE per test case
|
|
61
|
+
* (request evaluation) and every match block and allow rule evaluated for
|
|
62
|
+
* that request shares it: production's budget spans OR'd rules and
|
|
63
|
+
* overlapping match blocks alike (the linter's SHARED_GATE "cross-rule
|
|
64
|
+
* budget exhaustion" hazard).
|
|
65
|
+
*/
|
|
66
|
+
export class LookupBudget {
|
|
67
|
+
private readonly accessed = new Set<string>();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Charge one document access. `kind` is 'doc' for pre-write reads
|
|
71
|
+
* (get/exists) and 'after' for post-write projections (getAfter/
|
|
72
|
+
* existsAfter). A repeat access to the same (kind, path) is cached and
|
|
73
|
+
* free; the 11th DISTINCT access throws {@link LookupBudgetError}, which
|
|
74
|
+
* fails the request closed.
|
|
75
|
+
*/
|
|
76
|
+
charge(kind: 'doc' | 'after', normalizedPath: string): void {
|
|
77
|
+
const key = `${kind}:${normalizedPath}`;
|
|
78
|
+
if (this.accessed.has(key)) return; // cached, does not count against the budget
|
|
79
|
+
if (this.accessed.size >= DOCUMENT_LOOKUP_LIMIT) {
|
|
80
|
+
throw new LookupBudgetError(
|
|
81
|
+
`document access limit exceeded: this request already read ${DOCUMENT_LOOKUP_LIMIT} distinct documents `
|
|
82
|
+
+ `via get()/exists()/getAfter()/existsAfter() (production allows 10 per request; `
|
|
83
|
+
+ `the 11th distinct access fails the evaluation)`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
this.accessed.add(key);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The single charging seam for all four document access builtins. Charges
|
|
92
|
+
* the budget for a raw (unnormalized) lookup path. Malformed paths charge
|
|
93
|
+
* nothing: no document read happens, and the caller's own validation
|
|
94
|
+
* produces the error or false result.
|
|
95
|
+
*/
|
|
96
|
+
export function chargeLookup(ctx: SimulationContext, kind: 'doc' | 'after', rawPath: string): void {
|
|
97
|
+
if (!ctx.lookupBudget) return;
|
|
98
|
+
const normalized = normalizeDocumentPath(rawPath);
|
|
99
|
+
if (!isDocumentPath(normalized)) return;
|
|
100
|
+
ctx.lookupBudget.charge(kind, normalized);
|
|
101
|
+
}
|
|
@@ -46,6 +46,13 @@ export type RulesService = 'firestore' | 'storage';
|
|
|
46
46
|
export interface StdlibEntry {
|
|
47
47
|
/** Surface signature, e.g. `math.ceil(x: number): int`. */
|
|
48
48
|
signature: string;
|
|
49
|
+
/**
|
|
50
|
+
* Whether production accepts a ruleset that calls this entry. Omitted
|
|
51
|
+
* means accepted. `'rejected'` documents a name that Firebase reference
|
|
52
|
+
* material lists but production's compiler refuses, so the catalog can
|
|
53
|
+
* carry the correction while the signature field stays a signature.
|
|
54
|
+
*/
|
|
55
|
+
acceptance?: 'rejected';
|
|
49
56
|
/** One-line summary the agent reads first. */
|
|
50
57
|
description: string;
|
|
51
58
|
/** ~1–3 lines of real rule fragments. Optional. */
|
|
@@ -85,9 +92,9 @@ const BUILTINS_MODULE: StdlibModuleDefinition = {
|
|
|
85
92
|
key: 'builtins',
|
|
86
93
|
kind: 'language-namespace',
|
|
87
94
|
description:
|
|
88
|
-
'Top-level functions always in scope inside rules
|
|
95
|
+
'Top-level functions always in scope inside rules: get, exists, getAfter. Called bare, no namespace prefix. (debug() is docs-only: production rejects it at compile.)',
|
|
89
96
|
purpose:
|
|
90
|
-
'The
|
|
97
|
+
'The built-in functions Firestore Rules ships with at the top level. They are NOT inside a namespace: call `get(path)`, not `firestore.get(path)`. Used to read other documents (cross-document gates), check existence, and peek at post-write state.',
|
|
91
98
|
whenToUse:
|
|
92
99
|
'Reach for `builtins` any time a rule needs information that isn\'t already on `request` or `resource`. Most common: `get(path)` to read another document\'s fields inside a predicate.',
|
|
93
100
|
entries: [
|
|
@@ -117,10 +124,11 @@ const BUILTINS_MODULE: StdlibModuleDefinition = {
|
|
|
117
124
|
},
|
|
118
125
|
{
|
|
119
126
|
signature: 'debug(value: any): bool',
|
|
127
|
+
acceptance: 'rejected',
|
|
120
128
|
description:
|
|
121
|
-
'
|
|
129
|
+
'DO NOT USE. Although Firebase reference docs list `debug()`, production Firestore REJECTS rulesets that call it at compile time (`Function not found error: Name: [debug]`). The linter and local simulator reject it for the same reason.',
|
|
122
130
|
notes:
|
|
123
|
-
'
|
|
131
|
+
'Remove any debug() wrapper before deploying and evaluate the inner expression directly. There is no deployable logging primitive in rules.',
|
|
124
132
|
},
|
|
125
133
|
],
|
|
126
134
|
relatedKeys: ['request', 'resource', 'path'],
|
|
@@ -132,11 +140,11 @@ const MATH: StdlibModuleDefinition = {
|
|
|
132
140
|
key: 'math',
|
|
133
141
|
kind: 'language-namespace',
|
|
134
142
|
description:
|
|
135
|
-
'Numeric helpers
|
|
143
|
+
'Numeric helpers: abs, ceil, floor, round, sqrt, pow, isNaN. Use when rules need to coerce or bound numbers.',
|
|
136
144
|
purpose:
|
|
137
145
|
'The `math` namespace exposes a small set of numeric utilities for use inside rule predicates. Functions are called as `math.<fn>(...)`. Always in scope; no import needed.',
|
|
138
146
|
whenToUse:
|
|
139
|
-
'Reach for `math` whenever a rule needs to round, bound, or guard against
|
|
147
|
+
'Reach for `math` whenever a rule needs to round, bound, or guard against NaN in a user-supplied field.',
|
|
140
148
|
entries: [
|
|
141
149
|
{ signature: 'math.abs(n: number): number', description: 'Absolute value of `n`.' },
|
|
142
150
|
{ signature: 'math.ceil(n: number): int', description: 'Smallest integer >= `n`.' },
|
|
@@ -145,15 +153,15 @@ const MATH: StdlibModuleDefinition = {
|
|
|
145
153
|
{ signature: 'math.sqrt(n: number): number', description: 'Square root of `n`. Negative input returns NaN.' },
|
|
146
154
|
{ signature: 'math.pow(base: number, exp: number): number', description: '`base` raised to `exp`.' },
|
|
147
155
|
{
|
|
148
|
-
signature: 'math.
|
|
149
|
-
description: 'True if `n` is
|
|
150
|
-
notes:
|
|
156
|
+
signature: 'math.isNaN(n: number): bool',
|
|
157
|
+
description: 'True if `n` is NaN.',
|
|
158
|
+
notes:
|
|
159
|
+
'There is NO isInfinite function in the math namespace. Production Firestore rejects it at compile time (`Function not found error: Name: [math.isInfinite]`), despite it appearing in some Firebase reference material.',
|
|
151
160
|
},
|
|
152
|
-
{ signature: 'math.isNaN(n: number): bool', description: 'True if `n` is NaN.' },
|
|
153
161
|
],
|
|
154
162
|
examples: [
|
|
155
163
|
`allow update: if math.ceil(request.resource.data.score) <= 100;`,
|
|
156
|
-
`allow create: if !math.isNaN(request.resource.data.lat)
|
|
164
|
+
`allow create: if !math.isNaN(request.resource.data.lat);`,
|
|
157
165
|
],
|
|
158
166
|
relatedKeys: ['hashing'],
|
|
159
167
|
};
|
|
@@ -67,7 +67,23 @@ export function getFirestore(ctx: SandboxContext): SandboxFirestore {
|
|
|
67
67
|
* arm pins on every op/sub. Never absent — see {@link getFirestore}. */
|
|
68
68
|
export function lensForAuth(auth: AuthState): AuthLens {
|
|
69
69
|
if (auth === null || auth === undefined) return { mode: 'anon' };
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
const lens: Extract<AuthLens, { mode: 'as' }> = { mode: 'as', uid: auth.uid };
|
|
71
|
+
if (auth.token !== undefined) lens.token = auth.token;
|
|
72
|
+
if (auth.tenant !== undefined) lens.tenant = auth.tenant;
|
|
73
|
+
return lens;
|
|
73
74
|
}
|
|
75
|
+
|
|
76
|
+
export function authStateForLens(actAs: Extract<AuthLens, { mode: 'as' }>): AuthState {
|
|
77
|
+
const state: Extract<AuthState, { uid: string }> = { uid: actAs.uid };
|
|
78
|
+
if (actAs.token !== undefined) state.token = actAs.token;
|
|
79
|
+
if (actAs.tenant !== undefined) state.tenant = actAs.tenant;
|
|
80
|
+
return state;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function lensCacheKey(actAs: Extract<AuthLens, { mode: 'as' }>): string {
|
|
84
|
+
const parts = [actAs.uid];
|
|
85
|
+
if (actAs.tenant !== undefined) parts.push(`tenant:${actAs.tenant}`);
|
|
86
|
+
if (actAs.token !== undefined) parts.push(JSON.stringify(actAs.token));
|
|
87
|
+
return parts.join(':');
|
|
88
|
+
}
|
|
89
|
+
|
package/src/sandbox/index.ts
CHANGED
|
@@ -51,7 +51,11 @@ export type {
|
|
|
51
51
|
WriteSandboxEvent,
|
|
52
52
|
} from './types/index.js';
|
|
53
53
|
export { SandboxError } from './types/index.js';
|
|
54
|
-
export {
|
|
54
|
+
export {
|
|
55
|
+
SandboxContextImpl,
|
|
56
|
+
normalizeAuthState,
|
|
57
|
+
validateAuthState,
|
|
58
|
+
} from './sandbox-context.js';
|
|
55
59
|
export {
|
|
56
60
|
isOperationEvent,
|
|
57
61
|
operationContextFor,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
/**
|
|
16
16
|
* Minimal web-storage-like contract the session persistence controller
|
|
17
17
|
* reads/writes. Matches the `localStorage` / `sessionStorage` browser
|
|
18
|
-
* API subset that `pyric
|
|
18
|
+
* API subset that `pyric sandbox`'s `SessionStore` already uses, so
|
|
19
19
|
* browsers pass real storages and tests pass in-memory Map-backed fakes.
|
|
20
20
|
*
|
|
21
21
|
* Why the minimal subset (get/set/remove) instead of the full
|
package/src/sandbox/remote.ts
CHANGED
|
@@ -78,7 +78,7 @@ export interface RemoteSandboxChannel {
|
|
|
78
78
|
*/
|
|
79
79
|
export interface RemoteSandbox extends Sandbox {
|
|
80
80
|
readonly [REMOTE_SANDBOX]: true;
|
|
81
|
-
/** Base URL of the `pyric
|
|
81
|
+
/** Base URL of the `pyric sandbox` this handle is attached to (used in
|
|
82
82
|
* error guidance: "open <serveUrl> in a browser and retry"). */
|
|
83
83
|
readonly serveUrl: string;
|
|
84
84
|
/** The raw worker op/sub relay channel. */
|
|
@@ -100,7 +100,7 @@ export const REMOTE_SANDBOX_FACTORY = Symbol.for('pyric.remote.sandboxFactory');
|
|
|
100
100
|
|
|
101
101
|
/** Options accepted by the ambient remote-sandbox factory. */
|
|
102
102
|
export interface RemoteSandboxFactoryOptions {
|
|
103
|
-
/** Explicit `pyric
|
|
103
|
+
/** Explicit `pyric sandbox` base URL (from `PYRIC_SANDBOX=remote:<url>`).
|
|
104
104
|
* When omitted the factory discovers the running host itself (the
|
|
105
105
|
* `.pyric/serve.json` locator protocol). */
|
|
106
106
|
url?: string;
|
|
@@ -27,32 +27,84 @@ import { SandboxError } from './types/errors.js';
|
|
|
27
27
|
import type { Sandbox } from './types/service.js';
|
|
28
28
|
import { immutableOperationContext } from './operation-record.js';
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Normalize AuthState during SandboxContextImpl context assembly.
|
|
32
|
+
* Automatically projects top-level `tenant` into `token.firebase.tenant`
|
|
33
|
+
* while preserving custom claims and explicit nested token overrides.
|
|
34
|
+
* Never mutates original input.
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeAuthState(auth: AuthState): AuthState {
|
|
37
|
+
if (auth === null) return null;
|
|
38
|
+
|
|
39
|
+
if (auth.tenant === undefined) {
|
|
40
|
+
if (auth.token === undefined) {
|
|
41
|
+
return { uid: auth.uid };
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
uid: auth.uid,
|
|
45
|
+
token: structuredClone(auth.token),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const token = auth.token !== undefined ? structuredClone(auth.token) : {};
|
|
50
|
+
const existingFirebase = token.firebase;
|
|
51
|
+
|
|
52
|
+
let normalizedFirebase: Record<string, unknown>;
|
|
53
|
+
if (
|
|
54
|
+
typeof existingFirebase === 'object' &&
|
|
55
|
+
existingFirebase !== null &&
|
|
56
|
+
!Array.isArray(existingFirebase)
|
|
57
|
+
) {
|
|
58
|
+
const fbObj = existingFirebase as Record<string, unknown>;
|
|
59
|
+
normalizedFirebase = {
|
|
60
|
+
...fbObj,
|
|
61
|
+
tenant: fbObj.tenant !== undefined ? fbObj.tenant : auth.tenant,
|
|
62
|
+
};
|
|
63
|
+
} else {
|
|
64
|
+
normalizedFirebase = { tenant: auth.tenant };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
token.firebase = normalizedFirebase;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
uid: auth.uid,
|
|
71
|
+
tenant: auth.tenant,
|
|
72
|
+
token,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
30
76
|
function authLensFor(auth: AuthState): AuthLens {
|
|
31
77
|
if (auth === null) return { mode: 'anon' };
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
78
|
+
const lens: Extract<AuthLens, { mode: 'as' }> = { mode: 'as', uid: auth.uid };
|
|
79
|
+
if (auth.token !== undefined) lens.token = auth.token;
|
|
80
|
+
if (auth.tenant !== undefined) lens.tenant = auth.tenant;
|
|
81
|
+
return lens;
|
|
35
82
|
}
|
|
36
83
|
|
|
37
84
|
export class SandboxContextImpl implements SandboxContext {
|
|
85
|
+
public readonly auth: AuthState;
|
|
86
|
+
public readonly operationContext: OperationContext;
|
|
87
|
+
|
|
38
88
|
constructor(
|
|
39
89
|
public readonly sandbox: Sandbox,
|
|
40
|
-
|
|
90
|
+
auth: AuthState,
|
|
41
91
|
operationContext?: OperationContext,
|
|
42
92
|
) {
|
|
93
|
+
validateAuthState(auth);
|
|
94
|
+
const normalized = normalizeAuthState(auth);
|
|
95
|
+
this.auth = normalized;
|
|
43
96
|
this.operationContext = immutableOperationContext(operationContext ?? {
|
|
44
97
|
source: { kind: 'unattributed' },
|
|
45
|
-
authLens: authLensFor(
|
|
98
|
+
authLens: authLensFor(normalized),
|
|
46
99
|
});
|
|
47
100
|
}
|
|
48
101
|
|
|
49
|
-
public readonly operationContext: OperationContext;
|
|
50
|
-
|
|
51
102
|
withAuth(auth: AuthState): SandboxContext {
|
|
52
103
|
validateAuthState(auth);
|
|
53
|
-
|
|
104
|
+
const normalized = normalizeAuthState(auth);
|
|
105
|
+
return new SandboxContextImpl(this.sandbox, normalized, {
|
|
54
106
|
source: this.operationContext.source,
|
|
55
|
-
authLens: authLensFor(
|
|
107
|
+
authLens: authLensFor(normalized),
|
|
56
108
|
...(this.operationContext.planId === undefined
|
|
57
109
|
? {}
|
|
58
110
|
: { planId: this.operationContext.planId }),
|
|
@@ -106,6 +158,14 @@ export function validateAuthState(auth: unknown): asserts auth is AuthState {
|
|
|
106
158
|
'withAuth() requires `uid` to be a non-empty string.',
|
|
107
159
|
);
|
|
108
160
|
}
|
|
161
|
+
if ('tenant' in obj && obj.tenant !== undefined) {
|
|
162
|
+
if (typeof obj.tenant !== 'string' || obj.tenant.length === 0) {
|
|
163
|
+
throw new SandboxError(
|
|
164
|
+
'invalid-argument',
|
|
165
|
+
'withAuth() requires `tenant` (when present) to be a non-empty string.',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
109
169
|
if ('token' in obj && obj.token !== undefined) {
|
|
110
170
|
if (obj.token === null || typeof obj.token !== 'object' || Array.isArray(obj.token)) {
|
|
111
171
|
throw new SandboxError(
|
|
@@ -379,7 +379,8 @@ export interface ServiceMutationEvent {
|
|
|
379
379
|
* - storage: `object_put` | `object_delete` | `metadata_update`
|
|
380
380
|
* - rtdb: `set` | `update` | `remove` | `transaction`
|
|
381
381
|
* - ai: `generate_content` | `stream_generate_content` |
|
|
382
|
-
* `count_tokens` | `request_rejected`
|
|
382
|
+
* `count_tokens` | `request_rejected` | `response_blocked` |
|
|
383
|
+
* `model_substituted`
|
|
383
384
|
* New ops can be added without a breaking change (consumers switch with a
|
|
384
385
|
* default branch).
|
|
385
386
|
*/
|
|
@@ -15,7 +15,7 @@ export type EventActor =
|
|
|
15
15
|
/** The identity/rules lens an operation actually ran under. */
|
|
16
16
|
export type AuthLens =
|
|
17
17
|
| { mode: 'admin' }
|
|
18
|
-
| { mode: 'as'; uid: string; token?: Record<string, unknown> }
|
|
18
|
+
| { mode: 'as'; uid: string; tenant?: string; token?: Record<string, unknown> }
|
|
19
19
|
| { mode: 'app-session' }
|
|
20
20
|
| { mode: 'anon' };
|
|
21
21
|
|
package/src/storage/enforce.ts
CHANGED
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
* grow their own copy of the same dispatch.
|
|
5
5
|
*
|
|
6
6
|
* Behavior:
|
|
7
|
-
* - No rules configured →
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* pre-Slice-8 surface.
|
|
7
|
+
* - No rules configured → deny. Matches Firebase production fail-closed
|
|
8
|
+
* security invariants: bare `getStorage` with no `rules` option
|
|
9
|
+
* rejects client operations with `storage/unauthorized`.
|
|
11
10
|
* - Rules configured → call `evaluateStorageRules`. Throw
|
|
12
11
|
* `storage/unauthorized` with the evaluator's `reasons` joined
|
|
13
12
|
* into the message on denial.
|
|
@@ -16,9 +15,8 @@
|
|
|
16
15
|
* permission governs both download and list, so `list.ts` enforces a
|
|
17
16
|
* `read` check on the scanned prefix path. A previous v1 scope build
|
|
18
17
|
* silently bypassed list — that contradicted the rules-enforcement
|
|
19
|
-
* contract (a denied tree was still enumerable).
|
|
20
|
-
* configured the check
|
|
21
|
-
* session-archive demo is unaffected.
|
|
18
|
+
* contract (a denied tree was still enumerable). When no rules are
|
|
19
|
+
* configured the check fails closed (default-deny).
|
|
22
20
|
*
|
|
23
21
|
* Studio observability (storage-denial-events): every enforcement
|
|
24
22
|
* decision — allow, deny, AND admin-plane bypass — lands on the
|
|
@@ -44,10 +42,12 @@ import { emitSandboxEvent, makeSandboxOperationEvent } from 'pyric/sandbox/inter
|
|
|
44
42
|
import type { EventProvenance } from 'pyric/sandbox';
|
|
45
43
|
import {
|
|
46
44
|
storageOperationProvenance,
|
|
45
|
+
type CrossServiceIam,
|
|
47
46
|
type StorageService,
|
|
48
47
|
type Target,
|
|
49
48
|
} from './service.js';
|
|
50
49
|
import { evaluateStorageRules } from './sandbox/rules-evaluator.js';
|
|
50
|
+
import { RuleEvalError } from './sandbox/rules-evaluation-error.js';
|
|
51
51
|
import type { EvaluationInput, FirestoreLookup } from './sandbox/rules.js';
|
|
52
52
|
import { unauthorized } from './errors.js';
|
|
53
53
|
|
|
@@ -71,18 +71,16 @@ export function enforceRules(
|
|
|
71
71
|
return;
|
|
72
72
|
}
|
|
73
73
|
if (!service.rules) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
emitOperation(target, input, 'allow', undefined, 'user', false, boundProvenance);
|
|
78
|
-
return;
|
|
74
|
+
const reasons = ['No Storage rules configured; default deny.'];
|
|
75
|
+
emitOperation(target, input, 'deny', reasons, 'user', false, boundProvenance);
|
|
76
|
+
throw unauthorized(input.request.method, input.request.path, ' — No Storage rules configured; default deny.');
|
|
79
77
|
}
|
|
80
78
|
const evaluationInput = target ? withCanonicalRulesPath(input, target.bucket) : input;
|
|
81
79
|
const result = evaluateStorageRules(
|
|
82
80
|
service.rules,
|
|
83
81
|
evaluationInput,
|
|
84
82
|
undefined,
|
|
85
|
-
firestoreLookupFor(target),
|
|
83
|
+
firestoreLookupFor(target, service.crossServiceIam),
|
|
86
84
|
);
|
|
87
85
|
if (!result.allowed) {
|
|
88
86
|
emitOperation(target, input, 'deny', result.reasons, 'user', true, boundProvenance);
|
|
@@ -164,12 +162,53 @@ function emitOperation(
|
|
|
164
162
|
*
|
|
165
163
|
* Calls without a target get no lookup — a rule that reaches for
|
|
166
164
|
* `firestore.*` there denies "unsupported" rather than false-allowing.
|
|
165
|
+
*
|
|
166
|
+
* `crossServiceIam: 'denied'` models the production project state WITHOUT
|
|
167
|
+
* `roles/firebaserules.firestoreServiceAgent` on the Storage service agent:
|
|
168
|
+
* the lookup capability is still injected (so path validation and laziness
|
|
169
|
+
* behave identically), but every EXECUTED get/exists fails. See
|
|
170
|
+
* {@link crossServiceIamDeniedLookup}.
|
|
167
171
|
*/
|
|
168
|
-
function firestoreLookupFor(
|
|
172
|
+
function firestoreLookupFor(
|
|
173
|
+
target: Target | undefined,
|
|
174
|
+
crossServiceIam: CrossServiceIam,
|
|
175
|
+
): FirestoreLookup | undefined {
|
|
169
176
|
if (!target) return undefined;
|
|
177
|
+
if (crossServiceIam === 'denied') return crossServiceIamDeniedLookup();
|
|
170
178
|
const admin = target.sandbox.admin;
|
|
171
179
|
return {
|
|
172
180
|
get: (path) => admin.getDocument(path) as Record<string, unknown> | null,
|
|
173
181
|
exists: (path) => admin.getDocument(path) !== null,
|
|
174
182
|
};
|
|
175
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The `crossServiceIam: 'denied'` lookup: every executed
|
|
187
|
+
* `firestore.get()/exists()` throws a {@link RuleEvalError} naming the
|
|
188
|
+
* missing service-agent role, so the rule denies with that reason,
|
|
189
|
+
* mirroring production's IAM-disabled boundary captured by conformance
|
|
190
|
+
* observation `stdlib-realstorage-p3-lookup-budget` (row storage-rules#134):
|
|
191
|
+
* every lookup-executing family DENIES while a short-circuited lookup is
|
|
192
|
+
* never invoked and its rule still ALLOWS. The capture pins only that
|
|
193
|
+
* executed-lookup/short-circuit boundary; how an IAM failure interacts
|
|
194
|
+
* with CEL `&&`/`||` error absorption is NOT pinned by it (no absorption
|
|
195
|
+
* family ran IAM-disabled), so this uses the same absorbable
|
|
196
|
+
* {@link RuleEvalError} class as the existing no-capability deny path.
|
|
197
|
+
*
|
|
198
|
+
* Exported for the conformance replay test
|
|
199
|
+
* (`packages/conformance/test/src/storage-stdlib-real-replay.test.ts`),
|
|
200
|
+
* which runs the captured IAM-disabled matrix against the evaluator with
|
|
201
|
+
* THIS production lookup, not a hand-rolled twin.
|
|
202
|
+
*/
|
|
203
|
+
export function crossServiceIamDeniedLookup(): FirestoreLookup {
|
|
204
|
+
const fail = (method: 'get' | 'exists'): never => {
|
|
205
|
+
throw new RuleEvalError(
|
|
206
|
+
`firestore.${method}() failed: cross-service Firestore access is not authorized: ` +
|
|
207
|
+
"the Storage service agent lacks roles/firebaserules.firestoreServiceAgent (crossServiceIam: 'denied')",
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
return {
|
|
211
|
+
get: () => fail('get'),
|
|
212
|
+
exists: () => fail('exists'),
|
|
213
|
+
};
|
|
214
|
+
}
|
package/src/storage/index.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
export { getStorageSandbox, TARGET_SYMBOL } from './service.js';
|
|
25
|
-
export type { FirebaseStorage, StorageOptions, Target, SandboxTarget } from './service.js';
|
|
25
|
+
export type { CrossServiceIam, FirebaseStorage, StorageOptions, Target, SandboxTarget } from './service.js';
|
|
26
26
|
export { getStorage, connectStorageEmulator } from './instances.js';
|
|
27
27
|
|
|
28
28
|
export { StorageError } from './errors.js';
|