backend-skeleton 1.5.0 → 1.7.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 +549 -63
- package/contracts/completeness.mjs +12 -1
- 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 +220 -74
- 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/lib/attest.mjs +59 -1
- package/lib/cli.mjs +125 -7
- package/lib/decision-log.mjs +58 -0
- 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 +250 -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 +334 -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 +6 -0
- package/schemas/decision-event.schema.json +46 -0
- package/schemas/gate-attestation.schema.json +6 -1
- package/schemas/gate-export.schema.json +606 -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
|
|
@@ -126,21 +127,62 @@ function planPatchable({ javaSrcRoot, module: moduleName, controllers, entityCla
|
|
|
126
127
|
return { patchable: classified.fields, updateOperation, updateDtoFile, dtoTypeName, notes };
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
//
|
|
130
|
-
// (
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
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.
|
|
138
145
|
const HAS_ROLE_RE = /@PreAuthorize\(\s*"hasRole\('([^']+)'\)"\s*\)/;
|
|
139
146
|
const HAS_AUTHORITY_RE = /@PreAuthorize\(\s*"hasAuthority\('([^']+)'\)"\s*\)/;
|
|
140
|
-
const PRE_AUTH_RE = /@PreAuthorize\(/;
|
|
141
147
|
|
|
142
|
-
|
|
143
|
-
|
|
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';
|
|
144
186
|
}
|
|
145
187
|
|
|
146
188
|
// Index just after the class body's opening brace -- the lower bound for a method-level search
|
|
@@ -153,56 +195,146 @@ function classBodyStart(text) {
|
|
|
153
195
|
return m ? m.index + m[0].length : 0;
|
|
154
196
|
}
|
|
155
197
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// for
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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;
|
|
177
279
|
}
|
|
178
280
|
|
|
179
|
-
// D-
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
});
|
|
192
321
|
}
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
+
});
|
|
206
338
|
}
|
|
207
339
|
|
|
208
340
|
// Heuristic (this codebase's convention, verified for Organization -> OrganizationService, not
|
|
@@ -257,7 +389,11 @@ function countServiceMethodParams(serviceFilePath, methodName) {
|
|
|
257
389
|
return argsText === '' ? 0 : countTopLevelCommas(argsText) + 1;
|
|
258
390
|
}
|
|
259
391
|
|
|
260
|
-
|
|
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 }) {
|
|
261
397
|
const targetModule = moduleName
|
|
262
398
|
? scanReport.related_modules.find((m) => m.module === moduleName)
|
|
263
399
|
: scanReport.related_modules[0];
|
|
@@ -294,8 +430,8 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
294
430
|
// without this check the notes below would read literally "...found for X.null" / "could not
|
|
295
431
|
// find a null(...) method", which is confusing, not honest. See D-resolver-scope.
|
|
296
432
|
const fetchOpMissingMethod = Boolean(fetchOp && !fetchOp.method);
|
|
297
|
-
const
|
|
298
|
-
const requiredAuthority =
|
|
433
|
+
const fetchPolicy = derivePolicy({ action: 'fetch', operation: fetchOp, repoRoot });
|
|
434
|
+
const requiredAuthority = fetchPolicy.authority;
|
|
299
435
|
const service = pkIsNonUuid ? null : findServiceFile(javaSrcRoot, targetModule.module, entity.className);
|
|
300
436
|
const serviceParamCount = (service && fetchOp) ? countServiceMethodParams(service.file, fetchOp.method) : null;
|
|
301
437
|
|
|
@@ -307,8 +443,8 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
307
443
|
// requiredAuthorityForPatch is meaningful even when resolver codegen itself ends up
|
|
308
444
|
// blocked, exactly like requiredAuthority already is unconditional above.
|
|
309
445
|
const updateOpForAuthority = findUpdateOperation(targetModule.controllers, entity.className);
|
|
310
|
-
const
|
|
311
|
-
const requiredAuthorityForPatch =
|
|
446
|
+
const patchPolicy = derivePolicy({ action: 'patch', operation: updateOpForAuthority, repoRoot });
|
|
447
|
+
const requiredAuthorityForPatch = patchPolicy.authority;
|
|
312
448
|
|
|
313
449
|
if (!fetchOp) {
|
|
314
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`);
|
|
@@ -318,15 +454,15 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
318
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`
|
|
319
455
|
: ' -- no literal per-action source method exists to correlate to';
|
|
320
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.`);
|
|
321
|
-
} else if (
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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);
|
|
325
463
|
}
|
|
326
|
-
if (updateOpForAuthority &&
|
|
327
|
-
notes.push(
|
|
328
|
-
} else if (updateOpForAuthority && !requiredAuthorityForPatch) {
|
|
329
|
-
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);
|
|
330
466
|
}
|
|
331
467
|
if (!service) {
|
|
332
468
|
// pkIsNonUuid already explained the real reason above -- this note would be true but
|
|
@@ -397,6 +533,16 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
397
533
|
// endpoint's own @PreAuthorize, not copied from requiredAuthority above -- see the
|
|
398
534
|
// computation and its own notes earlier in this loop.
|
|
399
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',
|
|
400
546
|
service,
|
|
401
547
|
willGenerateResolver: Boolean(fetchOp && service && serviceParamCount === 1),
|
|
402
548
|
});
|
|
@@ -460,7 +606,7 @@ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter
|
|
|
460
606
|
throw new Error('could not detect the base package (no *Application.java found under src/main/java) -- is this a Spring Boot project?');
|
|
461
607
|
}
|
|
462
608
|
const javaSrcRoot = path.join(repoRoot, 'src', 'main', 'java', ...basePackage.split('.'));
|
|
463
|
-
const inner = planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter });
|
|
609
|
+
const inner = planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter, repoRoot });
|
|
464
610
|
return {
|
|
465
611
|
schema: 'sbf.handles-plan/1',
|
|
466
612
|
provider: 'java-spring',
|