backend-skeleton 1.4.0 → 1.6.0
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 +113 -8
- package/bin/bskel.mjs +331 -53
- package/contracts/emit.mjs +22 -6
- package/contracts/openapi.mjs +125 -18
- package/handles/providers/java-spring/ast-bridge.mjs +85 -1
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
- package/handles/providers/java-spring/emit.mjs +126 -6
- package/handles/providers/java-spring/plan.mjs +244 -77
- package/handles/providers/java-spring/source-splice.mjs +477 -0
- package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
- package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
- package/handles/providers/typescript-express/plan.mjs +15 -2
- package/lib/attest.mjs +59 -1
- package/lib/cli.mjs +79 -7
- package/lib/doctor.mjs +23 -0
- package/lib/exit-codes.mjs +17 -0
- package/lib/gate-definitions.mjs +65 -2
- package/lib/gate-export.mjs +199 -0
- package/lib/impact-export-graphify.mjs +145 -0
- package/lib/impact-graph.mjs +194 -0
- package/lib/impact-surface.mjs +158 -0
- package/lib/impact.mjs +286 -0
- package/lib/patch-kinds.mjs +24 -0
- package/lib/repo.mjs +46 -0
- package/lib/workflow.mjs +16 -0
- package/package.json +1 -1
- package/scanners/adapters/_java-spring-analyzer.mjs +55 -0
- package/scanners/adapters/java-spring.mjs +154 -3
- package/scanners/index.mjs +10 -0
- package/schemas/feature-contract.schema.json +12 -1
- package/schemas/gate-attestation.schema.json +6 -1
- package/schemas/gate-export.schema.json +530 -22
- package/schemas/handles-plan.schema.json +32 -0
- package/schemas/impact-baseline.schema.json +59 -0
- package/schemas/impact-graph.schema.json +53 -0
- package/schemas/impact-report.schema.json +86 -0
- package/schemas/impact-resolution.schema.json +33 -0
- package/schemas/java-source-splice.schema.json +84 -0
- package/schemas/patch-transaction.schema.json +87 -2
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import { findMappingAnnotations, findMethodParams, maskNonCode,
|
|
4
|
+
import { findMappingAnnotations, findMethodParams, maskNonCode, matchBalanced } from '../../../scanners/adapters/_java-spring-analyzer.mjs';
|
|
5
|
+
import { lineNumberAt } from '../../../scanners/text-util.mjs';
|
|
5
6
|
import { classifyDtoFields, splitTopLevelParams, extractTypeAndName } from './patch-strategy.mjs';
|
|
6
7
|
|
|
7
8
|
// The "canonical fetch" for an entity: a GET endpoint whose path is exactly
|
|
@@ -25,7 +26,13 @@ function findFetchOperation(controllers, entityClassName) {
|
|
|
25
26
|
if (ep.verb !== 'GET' || !ep.operationId) continue;
|
|
26
27
|
const suffix = ep.path.slice(controller.basePath.length);
|
|
27
28
|
if (/^\/\{[^/]+\}$/.test(suffix)) {
|
|
28
|
-
|
|
29
|
+
// X5 (D-route-expansion-provenance): threaded through so the caller can name the real
|
|
30
|
+
// cause when ep.method is null instead of a bare "method not found" message -- a 1:N
|
|
31
|
+
// framework-synthesized route (e.g. Spring Data REST) has a real operationId but no
|
|
32
|
+
// literal per-action method to correlate to. null on every endpoint in today's adapter
|
|
33
|
+
// (it never populates declarationIndex) -- forward-compatible only, not yet reachable.
|
|
34
|
+
const declaration = ep.declarationIndex != null ? (controller.declarations?.[ep.declarationIndex] ?? null) : null;
|
|
35
|
+
return { operationId: ep.operationId, method: ep.method, path: ep.path, controllerFile: controller.file, controllerClassName: controller.className, declaration };
|
|
29
36
|
}
|
|
30
37
|
}
|
|
31
38
|
}
|
|
@@ -120,21 +127,62 @@ function planPatchable({ javaSrcRoot, module: moduleName, controllers, entityCla
|
|
|
120
127
|
return { patchable: classified.fields, updateOperation, updateDtoFile, dtoTypeName, notes };
|
|
121
128
|
}
|
|
122
129
|
|
|
123
|
-
//
|
|
124
|
-
// (
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
130
|
+
// D-resolver-policy-contract (PC1/PC2/PC3): replaces the old extractPreAuthorize()/
|
|
131
|
+
// findRequiredAuthority()/methodMappingBoundaries() with an explicit refusal ladder producing a
|
|
132
|
+
// full policy RECORD (not just a scalar authority string) -- see DECISIONS.md's
|
|
133
|
+
// D-resolver-policy-contract for the WHY: a @PreAuthorize("hasRole('USER')") sitting next to a
|
|
134
|
+
// @PostAuthorize("returnObject.ownerId == authentication.name") ownership check used to
|
|
135
|
+
// auto-materialize ROLE_USER and silently ignore the ownership check entirely -- a real IDOR bskel
|
|
136
|
+
// itself would introduce. The provably-safe set (hasRole('X') / hasAuthority('X') alone, nothing
|
|
137
|
+
// else in the region) is UNCHANGED from before (O5) -- this item only adds refusal rungs in FRONT
|
|
138
|
+
// of it, so every case that materialized correctly before still does (frozen, not widened).
|
|
139
|
+
//
|
|
140
|
+
// O5 (D-resolver-authorization-action-aware, hasAuthority follow-up): hasRole('X') and
|
|
141
|
+
// hasAuthority('X') are NOT interchangeable at the Spring Security level -- hasRole('X') checks
|
|
142
|
+
// for the granted authority "ROLE_X" (an implicit prefix Spring itself applies), hasAuthority('X')
|
|
143
|
+
// checks for "X" verbatim. The returned `authority` string is the LITERAL granted-authority value
|
|
144
|
+
// the generated code must match, decided HERE (plan time), not left for the template to re-derive.
|
|
132
145
|
const HAS_ROLE_RE = /@PreAuthorize\(\s*"hasRole\('([^']+)'\)"\s*\)/;
|
|
133
146
|
const HAS_AUTHORITY_RE = /@PreAuthorize\(\s*"hasAuthority\('([^']+)'\)"\s*\)/;
|
|
134
|
-
const PRE_AUTH_RE = /@PreAuthorize\(/;
|
|
135
147
|
|
|
136
|
-
|
|
137
|
-
|
|
148
|
+
// D-resolver-policy-contract (PC3): companion annotations that can carry authorization logic
|
|
149
|
+
// @PreAuthorize's own simple-shape check can never see (ownership/SpEL/etc.) -- their mere
|
|
150
|
+
// PRESENCE in a method's or class's authorization region refuses auto-materialization outright,
|
|
151
|
+
// regardless of whether a perfectly-shaped @PreAuthorize ALSO sits in the same region. Verified
|
|
152
|
+
// live: none of these five appear anywhere in this repository today (grep, zero hits) -- so this
|
|
153
|
+
// rung changes nothing for any case this codebase currently exercises; it exists for target repos
|
|
154
|
+
// that DO use them.
|
|
155
|
+
const COMPANION_AUTHZ_RE = /@(PostAuthorize|Secured|RolesAllowed|PreFilter|PostFilter)\s*\(/g;
|
|
156
|
+
const PRE_AUTHORIZE_SCAN_RE = /@PreAuthorize\s*\(/g;
|
|
157
|
+
const EVIDENCE_TEXT_MAX = 200;
|
|
158
|
+
|
|
159
|
+
// D-resolver-policy-contract (PC2): the 8 evidence.kind values the ladder below can produce, in
|
|
160
|
+
// the exact order DECISIONS.md documents them. Exported so schemas/handles-plan.schema.json's own
|
|
161
|
+
// enum and test/handles-policy-contract.test.mjs can be asserted to never drift apart silently.
|
|
162
|
+
export const POLICY_EVIDENCE_KINDS = Object.freeze([
|
|
163
|
+
'endpoint-absent',
|
|
164
|
+
'endpoint-method-absent',
|
|
165
|
+
'companion-annotation-present',
|
|
166
|
+
'pre-authorize-ambiguous',
|
|
167
|
+
'pre-authorize-unrecognized',
|
|
168
|
+
'authorization-annotation-absent',
|
|
169
|
+
'pre-authorize-has-role',
|
|
170
|
+
'pre-authorize-has-authority',
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
// D-resolver-policy-contract (PC2 follow-up, found live): an unresolved record whose
|
|
174
|
+
// evidence.kind is 'endpoint-absent'/'endpoint-method-absent' means the underlying route
|
|
175
|
+
// STRUCTURALLY DOES NOT EXIST for this action -- e.g. a read-only resource with no PATCH/PUT
|
|
176
|
+
// endpoint at all. That is the SAME "nothing to protect" case this codebase has silently accepted
|
|
177
|
+
// via TODO_ROLE forever (planHandles()'s own `requiredAuthorityForPatch ?? 'TODO_ROLE'`, unnoted
|
|
178
|
+
// when no update endpoint exists) -- NOT the gap this item exists to close (an endpoint that DOES
|
|
179
|
+
// exist but couldn't be safely verified). Found live: without this exclusion, willGenerateResolver
|
|
180
|
+
// (which never depends on the UPDATE endpoint existing at all -- a read-only resource is a normal,
|
|
181
|
+
// common shape) would delegate/block emit for every read-only resource, a real regression this
|
|
182
|
+
// project's own real fixtures caught immediately. Exported so emit.mjs's unresolvedPolicies gate
|
|
183
|
+
// uses the exact same rule -- one place decides "does this unresolved record need a human."
|
|
184
|
+
export function policyRequiresResolution(p) {
|
|
185
|
+
return p.status === 'unresolved' && p.evidence.kind !== 'endpoint-absent' && p.evidence.kind !== 'endpoint-method-absent';
|
|
138
186
|
}
|
|
139
187
|
|
|
140
188
|
// Index just after the class body's opening brace -- the lower bound for a method-level search
|
|
@@ -147,56 +195,146 @@ function classBodyStart(text) {
|
|
|
147
195
|
return m ? m.index + m[0].length : 0;
|
|
148
196
|
}
|
|
149
197
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
// for
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
198
|
+
function normalizeEvidenceText(s) {
|
|
199
|
+
const collapsed = s.replace(/\s+/g, ' ').trim();
|
|
200
|
+
return collapsed.length > EVIDENCE_TEXT_MAX ? `${collapsed.slice(0, EVIDENCE_TEXT_MAX)}…` : collapsed;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Finds every match of a global annotation-start regex (`re`, must end in a literal `\(` so its
|
|
204
|
+
// own match always ends exactly on the opening paren) whose `@` sits within [start, end) of
|
|
205
|
+
// `masked`, resolving each one's balanced `(...)` via matchBalanced() and slicing the ORIGINAL
|
|
206
|
+
// (unmasked) `text` for `.text` -- values are never read off masked/blanked content, matching
|
|
207
|
+
// this analyzer's own established convention.
|
|
208
|
+
function collectAnnotations(masked, text, re, start, end) {
|
|
209
|
+
const found = [];
|
|
210
|
+
re.lastIndex = start;
|
|
211
|
+
let m;
|
|
212
|
+
while ((m = re.exec(masked))) {
|
|
213
|
+
if (m.index >= end) break;
|
|
214
|
+
const openParen = m.index + m[0].length - 1;
|
|
215
|
+
const close = matchBalanced(masked, openParen, '(', ')');
|
|
216
|
+
if (close === -1) continue; // malformed -- skip, don't misattribute
|
|
217
|
+
found.push({ index: m.index, text: normalizeEvidenceText(text.slice(m.index, close + 1)) });
|
|
218
|
+
}
|
|
219
|
+
return found;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function evidenceRecord({ kind, scope, file, line, symbol, text }) {
|
|
223
|
+
return { kind, scope: scope ?? null, file: file ?? null, line: line ?? null, symbol: symbol ?? null, text: text ?? null };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function policyRecord({ action, mode, status, authority, evidence, reason }) {
|
|
227
|
+
return { action, mode, status, authority: authority ?? null, evidence, reason: reason ?? null };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Runs the companion-annotation / @PreAuthorize-count / exact-shape rungs (D3's rungs 2-4) over
|
|
231
|
+
// one region ([start, end) of `masked`/`text`). Returns null (not a refusal, just "nothing here")
|
|
232
|
+
// when the region has neither a companion annotation nor any @PreAuthorize at all -- the caller
|
|
233
|
+
// tries the next scope (method -> class) before finally refusing with
|
|
234
|
+
// 'authorization-annotation-absent'.
|
|
235
|
+
function derivePolicyFromRegion({ masked, text, start, end, scope, action, controllerFile, controllerFileRel, symbol }) {
|
|
236
|
+
const companions = collectAnnotations(masked, text, COMPANION_AUTHZ_RE, start, end);
|
|
237
|
+
if (companions.length > 0) {
|
|
238
|
+
const line = lineNumberAt(text, companions[0].index);
|
|
239
|
+
const names = companions.map((c) => c.text).join(', ');
|
|
240
|
+
return policyRecord({
|
|
241
|
+
action, mode: 'delegated', status: 'unresolved',
|
|
242
|
+
evidence: evidenceRecord({ kind: 'companion-annotation-present', scope, file: controllerFileRel, line, symbol, text: companions.map((c) => c.text).join(' | ') }),
|
|
243
|
+
reason: `${controllerFileRel}:${line}: ${symbol} carries ${names} -- this scanner cannot safely verify what a companion authorization annotation enforces (ownership/tenant checks are common here), so authorization is NOT auto-materialized even though a @PreAuthorize may also be present. Implement the generated AuthorizationPolicy interface by hand, using the annotation text above as the specification.`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const preAuths = collectAnnotations(masked, text, PRE_AUTHORIZE_SCAN_RE, start, end);
|
|
247
|
+
if (preAuths.length > 1) {
|
|
248
|
+
const line = lineNumberAt(text, preAuths[0].index);
|
|
249
|
+
return policyRecord({
|
|
250
|
+
action, mode: 'delegated', status: 'unresolved',
|
|
251
|
+
evidence: evidenceRecord({ kind: 'pre-authorize-ambiguous', scope, file: controllerFileRel, line, symbol, text: preAuths.map((p) => p.text).join(' | ') }),
|
|
252
|
+
reason: `${controllerFileRel}:${line}: ${symbol} carries ${preAuths.length} @PreAuthorize annotations in the same ${scope}-level region -- ambiguous which one governs ${action}. Write authorize() by hand.`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (preAuths.length === 1) {
|
|
256
|
+
const p = preAuths[0];
|
|
257
|
+
const line = lineNumberAt(text, p.index);
|
|
258
|
+
const hasRoleMatch = p.text.match(HAS_ROLE_RE);
|
|
259
|
+
if (hasRoleMatch) {
|
|
260
|
+
return policyRecord({
|
|
261
|
+
action, mode: 'role', status: 'materialized', authority: `ROLE_${hasRoleMatch[1]}`,
|
|
262
|
+
evidence: evidenceRecord({ kind: 'pre-authorize-has-role', scope, file: controllerFileRel, line, symbol, text: p.text }),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const hasAuthorityMatch = p.text.match(HAS_AUTHORITY_RE);
|
|
266
|
+
if (hasAuthorityMatch) {
|
|
267
|
+
return policyRecord({
|
|
268
|
+
action, mode: 'role', status: 'materialized', authority: hasAuthorityMatch[1],
|
|
269
|
+
evidence: evidenceRecord({ kind: 'pre-authorize-has-authority', scope, file: controllerFileRel, line, symbol, text: p.text }),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return policyRecord({
|
|
273
|
+
action, mode: 'delegated', status: 'unresolved',
|
|
274
|
+
evidence: evidenceRecord({ kind: 'pre-authorize-unrecognized', scope, file: controllerFileRel, line, symbol, text: p.text }),
|
|
275
|
+
reason: `${controllerFileRel}:${line}: ${symbol}'s @PreAuthorize (${p.text}) is not exactly hasRole('X') or hasAuthority('X') -- this scanner does not evaluate SpEL (hasAnyRole/hasAnyAuthority/compound expressions included). Write authorize() by hand.`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
171
279
|
}
|
|
172
280
|
|
|
173
|
-
// D-
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
281
|
+
// D-resolver-policy-contract (PC3): the refusal ladder's entry point. `operation` is whatever
|
|
282
|
+
// findFetchOperation()/findUpdateOperation() returned (or null). Modeled directly on
|
|
283
|
+
// scanners/adapters/java-spring.mjs's extractRepositoryResource() -- an ordered sequence of
|
|
284
|
+
// checks, each non-matching rung returning an explicit, reasoned refusal rather than a best-effort
|
|
285
|
+
// guess. The companion-annotation rung runs BEFORE any @PreAuthorize matching, on purpose: this is
|
|
286
|
+
// the rung that closes the @PostAuthorize-ownership-check gap named in DECISIONS.md's WHY.
|
|
287
|
+
function derivePolicy({ action, operation, repoRoot }) {
|
|
288
|
+
const rel = (p) => (repoRoot && p ? path.relative(repoRoot, p) : p);
|
|
289
|
+
|
|
290
|
+
if (!operation) {
|
|
291
|
+
return policyRecord({
|
|
292
|
+
action, mode: 'delegated', status: 'unresolved',
|
|
293
|
+
evidence: evidenceRecord({ kind: 'endpoint-absent' }),
|
|
294
|
+
reason: `no ${action === 'fetch' ? 'single-resource GET' : 'single-resource PATCH/PUT'} endpoint found for this action -- write authorize() by hand once one exists`,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
if (!operation.method || !operation.controllerFile) {
|
|
298
|
+
const declNote = operation.declaration
|
|
299
|
+
? ` -- it was expanded from ${operation.declaration.label ?? operation.declaration.rule}, a framework-synthesized route with no literal per-action source method to correlate to`
|
|
300
|
+
: ' -- no literal per-action source method exists to correlate to';
|
|
301
|
+
return policyRecord({
|
|
302
|
+
action, mode: 'delegated', status: 'unresolved',
|
|
303
|
+
evidence: evidenceRecord({ kind: 'endpoint-method-absent', file: rel(operation.controllerFile) }),
|
|
304
|
+
reason: `matched endpoint (${action} ${operation.path ?? ''})${declNote}. Write authorize() by hand.`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const controllerFile = operation.controllerFile;
|
|
309
|
+
const controllerFileRel = rel(controllerFile);
|
|
310
|
+
const symbol = `${operation.controllerClassName}.${operation.method}`;
|
|
311
|
+
const text = fs.readFileSync(controllerFile, 'utf8');
|
|
312
|
+
const masked = maskNonCode(text);
|
|
313
|
+
const mappings = findMappingAnnotations(text);
|
|
314
|
+
const target = mappings.find((m) => m.methodName === operation.method);
|
|
315
|
+
if (!target) {
|
|
316
|
+
return policyRecord({
|
|
317
|
+
action, mode: 'delegated', status: 'unresolved',
|
|
318
|
+
evidence: evidenceRecord({ kind: 'endpoint-method-absent', file: controllerFileRel, symbol }),
|
|
319
|
+
reason: `${controllerFileRel}: could not re-locate ${symbol} via its own mapping annotation -- write authorize() by hand.`,
|
|
320
|
+
});
|
|
186
321
|
}
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
322
|
+
const priorSameFile = mappings.filter((m) => m.index < target.index);
|
|
323
|
+
const methodStart = priorSameFile.length > 0 ? priorSameFile[priorSameFile.length - 1].signatureIndex : classBodyStart(text);
|
|
324
|
+
const methodEnd = target.signatureIndex;
|
|
325
|
+
const classRegionEnd = mappings[0].index;
|
|
326
|
+
|
|
327
|
+
const methodResult = derivePolicyFromRegion({ masked, text, start: methodStart, end: methodEnd, scope: 'method', action, controllerFile, controllerFileRel, symbol });
|
|
328
|
+
if (methodResult) return methodResult;
|
|
329
|
+
|
|
330
|
+
const classResult = derivePolicyFromRegion({ masked, text, start: 0, end: classRegionEnd, scope: 'class', action, controllerFile, controllerFileRel, symbol });
|
|
331
|
+
if (classResult) return classResult;
|
|
332
|
+
|
|
333
|
+
return policyRecord({
|
|
334
|
+
action, mode: 'delegated', status: 'unresolved',
|
|
335
|
+
evidence: evidenceRecord({ kind: 'authorization-annotation-absent', file: controllerFileRel, symbol }),
|
|
336
|
+
reason: `no @PreAuthorize (method- or class-level) found for ${symbol} -- authorization may be enforced elsewhere (service layer, a SecurityFilterChain) that this scanner cannot see. Write authorize() by hand.`,
|
|
337
|
+
});
|
|
200
338
|
}
|
|
201
339
|
|
|
202
340
|
// Heuristic (this codebase's convention, verified for Organization -> OrganizationService, not
|
|
@@ -251,7 +389,11 @@ function countServiceMethodParams(serviceFilePath, methodName) {
|
|
|
251
389
|
return argsText === '' ? 0 : countTopLevelCommas(argsText) + 1;
|
|
252
390
|
}
|
|
253
391
|
|
|
254
|
-
|
|
392
|
+
// D-resolver-policy-contract (PC2): `repoRoot` defaults to `javaSrcRoot` so existing direct
|
|
393
|
+
// callers (test/handles-plan.test.mjs among them) keep working unmodified -- derivePolicy()'s
|
|
394
|
+
// evidence.file is only genuinely repo-relative when the real repoRoot is threaded through by
|
|
395
|
+
// plan() below; a caller that omits it still gets a (less pretty, still correct) path.
|
|
396
|
+
export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter, repoRoot = javaSrcRoot }) {
|
|
255
397
|
const targetModule = moduleName
|
|
256
398
|
? scanReport.related_modules.find((m) => m.module === moduleName)
|
|
257
399
|
: scanReport.related_modules[0];
|
|
@@ -282,8 +424,14 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
282
424
|
}
|
|
283
425
|
|
|
284
426
|
const fetchOp = findFetchOperation(targetModule.controllers, entity.className);
|
|
285
|
-
|
|
286
|
-
|
|
427
|
+
// X5 (D-route-expansion-provenance): a real, already-fail-closed case, checked explicitly
|
|
428
|
+
// instead of relying on findRequiredAuthority()/countServiceMethodParams()'s own `!methodName`
|
|
429
|
+
// guards to silently swallow it -- those guards return safely (no crash) either way, but
|
|
430
|
+
// without this check the notes below would read literally "...found for X.null" / "could not
|
|
431
|
+
// find a null(...) method", which is confusing, not honest. See D-resolver-scope.
|
|
432
|
+
const fetchOpMissingMethod = Boolean(fetchOp && !fetchOp.method);
|
|
433
|
+
const fetchPolicy = derivePolicy({ action: 'fetch', operation: fetchOp, repoRoot });
|
|
434
|
+
const requiredAuthority = fetchPolicy.authority;
|
|
287
435
|
const service = pkIsNonUuid ? null : findServiceFile(javaSrcRoot, targetModule.module, entity.className);
|
|
288
436
|
const serviceParamCount = (service && fetchOp) ? countServiceMethodParams(service.file, fetchOp.method) : null;
|
|
289
437
|
|
|
@@ -295,20 +443,26 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
295
443
|
// requiredAuthorityForPatch is meaningful even when resolver codegen itself ends up
|
|
296
444
|
// blocked, exactly like requiredAuthority already is unconditional above.
|
|
297
445
|
const updateOpForAuthority = findUpdateOperation(targetModule.controllers, entity.className);
|
|
298
|
-
const
|
|
299
|
-
const requiredAuthorityForPatch =
|
|
446
|
+
const patchPolicy = derivePolicy({ action: 'patch', operation: updateOpForAuthority, repoRoot });
|
|
447
|
+
const requiredAuthorityForPatch = patchPolicy.authority;
|
|
300
448
|
|
|
301
449
|
if (!fetchOp) {
|
|
302
450
|
notes.push(`${entity.className}: no single-resource GET endpoint found on a controller whose name contains "${entity.className}" -- fetch() will need to be hand-written`);
|
|
303
|
-
} else if (
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
451
|
+
} else if (fetchOpMissingMethod) {
|
|
452
|
+
const declaration = fetchOp.declaration;
|
|
453
|
+
const declNote = declaration
|
|
454
|
+
? ` -- it was expanded from ${declaration.label ?? declaration.rule} at ${path.relative(javaSrcRoot, fetchOp.controllerFile)}:${declaration.line} (rule: ${declaration.rule}); the framework generates this handler at runtime, so no literal per-action source method exists to correlate to`
|
|
455
|
+
: ' -- no literal per-action source method exists to correlate to';
|
|
456
|
+
notes.push(`${entity.className}: the matched endpoint (GET ${fetchOp.path})${declNote}. Resolver NOT generated -- this is a structural boundary of static-scan-based handles codegen, not a bug. See D-resolver-scope.`);
|
|
457
|
+
} else if (fetchPolicy.reason) {
|
|
458
|
+
// D-resolver-policy-contract (PC2): the ladder's own reason IS the note now -- see
|
|
459
|
+
// derivePolicy()'s per-rung wording (companion-annotation-present, pre-authorize-
|
|
460
|
+
// ambiguous/unrecognized, authorization-annotation-absent all produce their own precise
|
|
461
|
+
// explanation, replacing the old two-case unsupported/absent note here).
|
|
462
|
+
notes.push(fetchPolicy.reason);
|
|
307
463
|
}
|
|
308
|
-
if (updateOpForAuthority &&
|
|
309
|
-
notes.push(
|
|
310
|
-
} else if (updateOpForAuthority && !requiredAuthorityForPatch) {
|
|
311
|
-
notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)/hasAuthority(...)) found for ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} -- requiredAuthorityForPatch() defaults to "TODO_ROLE", fix before relying on it`);
|
|
464
|
+
if (updateOpForAuthority && patchPolicy.reason) {
|
|
465
|
+
notes.push(patchPolicy.reason);
|
|
312
466
|
}
|
|
313
467
|
if (!service) {
|
|
314
468
|
// pkIsNonUuid already explained the real reason above -- this note would be true but
|
|
@@ -316,12 +470,15 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
316
470
|
if (!pkIsNonUuid) {
|
|
317
471
|
notes.push(`${entity.className}: no ${entity.className}Service found under domain/${targetModule.module}/application/ or ${targetModule.module}/ -- resolver NOT generated for this entity (would produce a broken import). Emit it by hand once the right service is identified.`);
|
|
318
472
|
}
|
|
319
|
-
} else if (fetchOp && serviceParamCount !== 1) {
|
|
473
|
+
} else if (fetchOp && !fetchOpMissingMethod && serviceParamCount !== 1) {
|
|
320
474
|
const reason = serviceParamCount === null
|
|
321
475
|
? `could not find a ${fetchOp.method}(...) method on ${service.serviceType} to confirm its argument count`
|
|
322
476
|
: `${service.serviceType}.${fetchOp.method} takes ${serviceParamCount} argument(s), not the single resource UUID the generated resolver always passes`;
|
|
323
477
|
notes.push(`${entity.className}: ${reason} -- resolver NOT generated (would either fail to compile or silently call the wrong overload and drop a required scoping argument, e.g. an organization/cohort id). Wire it by hand -- ResourceResolver#fetch/#patchField receive the request's Authentication (D-resolver-authentication-context) for exactly this case, e.g. deriving a tenant/org id the same way the resource's own controller already does.`);
|
|
324
478
|
}
|
|
479
|
+
// X5: fetchOpMissingMethod already pushed its own single, clear note above -- suppressing
|
|
480
|
+
// this one avoids a second, confusing "could not find a null(...) method" note for the same
|
|
481
|
+
// root cause.
|
|
325
482
|
|
|
326
483
|
// A3 (D-patch-strategy): only worth computing once fetch()/the resolver itself is actually
|
|
327
484
|
// going to be generated -- an entity with no resolver has nowhere for patchField() codegen
|
|
@@ -376,6 +533,16 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
376
533
|
// endpoint's own @PreAuthorize, not copied from requiredAuthority above -- see the
|
|
377
534
|
// computation and its own notes earlier in this loop.
|
|
378
535
|
requiredAuthorityForPatch: requiredAuthorityForPatch ?? 'TODO_ROLE',
|
|
536
|
+
// D-resolver-policy-contract (PC2): the source of truth -- requiredAuthority/
|
|
537
|
+
// requiredAuthorityForPatch above are its legacy scalar PROJECTION, kept for every
|
|
538
|
+
// existing consumer (test files, templates) to keep working unmodified. Exactly two
|
|
539
|
+
// records, action 'fetch' then 'patch' -- 'recover' has no record of its own (it is
|
|
540
|
+
// governed by the 'fetch' record; HandleController#recover uses requiredAuthority()).
|
|
541
|
+
policies: [fetchPolicy, patchPolicy],
|
|
542
|
+
// D-resolver-policy-contract (PC2): one resource = one resolver bean = one mode --
|
|
543
|
+
// if EITHER action is unresolved, the whole resolver is delegated (fail-closed by
|
|
544
|
+
// construction: a resource can't be "half-delegated").
|
|
545
|
+
authorizationMode: (policyRequiresResolution(fetchPolicy) || policyRequiresResolution(patchPolicy)) ? 'delegated' : 'role',
|
|
379
546
|
service,
|
|
380
547
|
willGenerateResolver: Boolean(fetchOp && service && serviceParamCount === 1),
|
|
381
548
|
});
|
|
@@ -439,7 +606,7 @@ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter
|
|
|
439
606
|
throw new Error('could not detect the base package (no *Application.java found under src/main/java) -- is this a Spring Boot project?');
|
|
440
607
|
}
|
|
441
608
|
const javaSrcRoot = path.join(repoRoot, 'src', 'main', 'java', ...basePackage.split('.'));
|
|
442
|
-
const inner = planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter });
|
|
609
|
+
const inner = planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter, repoRoot });
|
|
443
610
|
return {
|
|
444
611
|
schema: 'sbf.handles-plan/1',
|
|
445
612
|
provider: 'java-spring',
|
|
@@ -447,7 +614,7 @@ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter
|
|
|
447
614
|
module: inner.module,
|
|
448
615
|
resources: inner.resources.map((r) => ({
|
|
449
616
|
...r,
|
|
450
|
-
readPath: (r.service && r.fetchOperation) ? `${r.service.serviceType}.${r.fetchOperation.method}()` : null,
|
|
617
|
+
readPath: (r.service && r.fetchOperation && r.fetchOperation.method) ? `${r.service.serviceType}.${r.fetchOperation.method}()` : null,
|
|
451
618
|
})),
|
|
452
619
|
notes: inner.notes,
|
|
453
620
|
};
|