From 9bd0d6f3b065d033dda2d3c0c01c3370ff4fbc8a Mon Sep 17 00:00:00 2001 From: Serguei Gorokhov Date: Fri, 4 Sep 2026 07:04:11 +0300 Subject: [PATCH] fix: move the target-path placeholder into the bucket slot, drop the dead public root The declared-dependency target grammar now matches DIAL's real resource addressing, {type}/{bucket}/{path...}, uniformly: the current-user placeholder moves from the type slot (current-user/{type}/...) into the bucket slot ({type}/{current-user}/...), written literally with braces. Braces are collision-proof (INVALID_FILE_NAME_CHARS bans them in every path element) and fail closed if the branch is ever missed (a raw brace throws in fromAnyUrl's strict decode). GLOBAL_VIEW_ROOTS and PERSONAL_TYPED_ROOTS collapse into one DECLARABLE_TYPE_ROOTS set (segment 0, both forms) with the dead "public" entry dropped -- "public" is a bucket value, never a type, so target.path: "public/somefile" previously passed write-time validation and was silently unresolvable at every request. The old early return that let a recognized placeholder skip the root-vocabulary check is deleted, not moved: the segment-0 vocabulary check now runs first, unconditionally, for both forms -- otherwise credentials/{current-user}/... would have been accepted at write time. The same vocabulary check is now also enforced on the read side for concrete (non-placeholder) paths, closing a gap for config-file apps that bypass write-time validation. validateUserAuthored degenerates to the allowUserResourceDependencies flag check alone: shape validation already runs first and requires segment 0 to be a declarable type, so a root-level "write everything personal" declaration is not expressible under the new grammar at all. No back-compat or migration work: this branch has never shipped to a real deployment. Spec: documentation repo, offline-access-delegation/implementation-specs/pr2b-target-path-grammar.md Co-Authored-By: Claude Code --- .../ResolveResourceDependenciesFn.java | 28 +++---- .../service/ResourceDependencyValidator.java | 84 +++++++++---------- .../core/server/CustomApplicationApiTest.java | 27 +++--- .../server/PlatformAppToolsetApiTest.java | 2 +- .../server/ResourceDependencyApiTest.java | 20 ++--- .../ResourceDependencyConsentApiTest.java | 16 ++-- .../ResourceDependencyResolutionApiTest.java | 2 +- .../ResolveResourceDependenciesFnTest.java | 16 ++-- .../server/service/ConsentServiceTest.java | 44 +++++----- .../ResourceDependencyValidatorTest.java | 74 ++++++++++++---- 10 files changed, 175 insertions(+), 138 deletions(-) diff --git a/server/src/main/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFn.java b/server/src/main/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFn.java index d4f007872..c33971af5 100644 --- a/server/src/main/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFn.java +++ b/server/src/main/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFn.java @@ -190,9 +190,9 @@ private static Set requestedAccessOf(@Nullable ResourceDepen } /** - * Resolves a declared target path to a descriptor: a {@code current-user//} - * path against the originating user's own bucket, a concrete global-view path as-is. Null - * when the record is malformed — an unresolvable record, never a crash. + * Resolves a declared target path to a descriptor: a {@code {type}/{current-user}/} + * path against the originating user's own bucket, a concrete {@code {type}/{bucket}/} + * path as-is. Null when the record is malformed — an unresolvable record, never a crash. */ @Nullable private ResourceDescriptor resolveTarget(ResourceDependency dependency, AuthBucket userBucket) { @@ -206,19 +206,19 @@ private ResourceDescriptor resolveTarget(ResourceDependency dependency, AuthBuck boolean folder = path.endsWith("/"); try { String[] segments = decodedSegments(path, folder); - if (segments.length == 0) { + if (segments.length < 2) { + return null; // {type}/{bucket} is the minimum + } + if (!ResourceDependencyValidator.DECLARABLE_TYPE_ROOTS.contains(segments[0])) { + // ResourceTypes.of() also maps internal engine types (credentials, keys, models, …). + // Config-file apps bypass write-time validation, so the read side enforces the same + // closed vocabulary — for concrete paths too, not only placeholder ones. return null; } - if (ResourceDependencyValidator.CURRENT_USER_PLACEHOLDER.equals(segments[0])) { - // current-user//. Two segments (current-user/skills/) target the - // type's root folder in the user's bucket — the skill-creator shape. The type segment - // is restricted to the personal typed-root vocabulary: ResourceTypes.of() also maps - // internal engine types (credentials, keys, models, …), and a declaration like - // current-user/credentials/ must never resolve into the user's secret-bearing blobs. - if (segments.length < 2 || !ResourceDependencyValidator.PERSONAL_TYPED_ROOTS.contains(segments[1])) { - return null; - } - ResourceType type = ResourceTypes.of(segments[1]); + if (ResourceDependencyValidator.CURRENT_USER_PLACEHOLDER.equals(segments[1])) { + // {type}/{current-user}/{path…}. Two segments target the type's root folder in the + // user's bucket — the skill-creator shape. + ResourceType type = ResourceTypes.of(segments[0]); String relativePath = segments.length == 2 ? "" : String.join("/", Arrays.asList(segments).subList(2, segments.length)); if (folder && !relativePath.isEmpty()) { diff --git a/server/src/main/java/com/epam/aidial/core/server/service/ResourceDependencyValidator.java b/server/src/main/java/com/epam/aidial/core/server/service/ResourceDependencyValidator.java index 59a24d085..facc33d2f 100644 --- a/server/src/main/java/com/epam/aidial/core/server/service/ResourceDependencyValidator.java +++ b/server/src/main/java/com/epam/aidial/core/server/service/ResourceDependencyValidator.java @@ -29,19 +29,15 @@ public class ResourceDependencyValidator { /** A declaration larger than this is wrong-shaped; it should be folder-scoped links, not a file inventory. */ public static final int MAX_DECLARED_DEPENDENCIES = 100; - public static final String CURRENT_USER_PLACEHOLDER = "current-user"; - - /** Global-view roots a concrete path may address. The personal root is reachable only via the placeholder. */ - private static final Set GLOBAL_VIEW_ROOTS = - Set.of("files", "public", "prompts", "conversations", "applications", "toolsets", "skills"); + public static final String CURRENT_USER_PLACEHOLDER = "{current-user}"; /** - * Resource-type folders a {@code current-user/…} path must be rooted in for user-authored apps — - * a root-level {@code current-user/} declaration ("write everything personal") is not declarable. - * Also enforced by the resolver on the read side: ResourceTypes.of() maps internal engine types - * (credentials, keys, models, …) that must never be declarable as personal targets. + * The resource types a declaration may address — segment 0 of {type}/{bucket}/{path…}, for + * both the concrete and the placeholder form. Closed per Core version. ResourceTypes.of() + * also maps internal engine types (credentials, keys, models, …); those are never declarable, + * as a personal target or a concrete one. Enforced on both the write and the read side. */ - public static final Set PERSONAL_TYPED_ROOTS = + public static final Set DECLARABLE_TYPE_ROOTS = Set.of("files", "prompts", "conversations", "applications", "toolsets", "skills"); private final boolean allowUserResourceDependencies; @@ -56,8 +52,11 @@ public void validateShape(Application application) { /** * Governance ceiling for user-authored apps: with the flag off (the default) they may not declare - * dependencies at all; with it on, personal targets must be typed — never the personal root. - * Admin-authored writes (public bucket by an admin, the platform bucket) are not gated here. + * dependencies at all. With it on, no further per-path ceiling applies — the grammar itself already + * requires segment 0 to be a declarable type (shape validation runs first, see ResourceController), + * so a root-level "write everything personal" declaration is not expressible under + * {type}/{bucket}/… at all. Admin-authored writes (public bucket by an admin, the platform bucket) + * are not gated here. */ public void validateUserAuthored(Application application) { List section = application.getResourceDependencies(); @@ -68,17 +67,6 @@ public void validateUserAuthored(Application application) { throw new HttpException(FORBIDDEN, "User-authored applications may not declare resource dependencies (allowUserResourceDependencies is disabled)"); } - for (ResourceDependency dependency : section) { - String path = pathOf(dependency); - if (path == null) { - continue; - } - String[] segments = decodedSegments(path); - if (segments.length > 0 && CURRENT_USER_PLACEHOLDER.equals(segments[0]) && !isTypedPersonalPath(segments)) { - throw new HttpException(FORBIDDEN, "Root-level current-user dependency is not declarable: " - + "personal targets must be rooted in a resource-type folder: " + path); - } - } } /** Non-throwing form of {@link #validateShape}: the same rules, usable from any write surface. */ @@ -140,11 +128,34 @@ private static List pathIssues(String at, ResourceDependency dependency) return issues; } String root = segments[0]; - // Token rules on every segment after the root; the root itself is governed by the form checks below. - for (int i = 1; i < segments.length; i++) { + // Root vocabulary — unconditional and first, no early return for either form. Deleting the old + // placeholder early-return is deliberate: without this check running unconditionally, + // credentials/{current-user}/… would silently bypass root-vocabulary validation at write time. + if ("users".equals(root)) { + // Personal targets are declared only via the placeholder — a concrete users/… path resolves for + // no one but that user and is rejected at write time as a shape error. + issues.add(at + ": personal targets are declared as {type}/" + CURRENT_USER_PLACEHOLDER + + "/…, not as a concrete users/… path: " + path); + } else if (!DECLARABLE_TYPE_ROOTS.contains(root)) { + issues.add(at + ": target must start with a declarable resource type " + + "(files, prompts, conversations, applications, toolsets, skills): " + path); + } else if (segments.length < 2) { + // A bare type root addresses the whole global view of that type — as over-broad as the + // personal root the governance ceiling bans. Declarations must be folder- or file-scoped. + // Applies to both forms: {type}/{bucket}/… is the minimum for either. + issues.add(at + ": target must address a folder or resource within " + root + "/, not the type root: " + path); + } + // Token rules on every segment after the root, plus the placeholder-position rule covering + // segment 0 as well — both issues are collected when the placeholder sits at segment 0, since + // the root-vocabulary error above also fires there and two accurate messages beat one conditional. + for (int i = 0; i < segments.length; i++) { String segment = segments[i]; - if (CURRENT_USER_PLACEHOLDER.equals(segment)) { - issues.add(at + ": the current-user placeholder is valid only as the root segment: " + path); + if (CURRENT_USER_PLACEHOLDER.equals(segment) && i != 1) { + issues.add(at + ": the " + CURRENT_USER_PLACEHOLDER + + " placeholder is valid only as the bucket segment (the second segment): " + path); + } + if (i == 0) { + continue; } if (segment.isEmpty()) { issues.add(at + ": path must not contain empty segments: " + path); @@ -156,28 +167,9 @@ private static List pathIssues(String at, ResourceDependency dependency) issues.add(at + ": relative path segments are not allowed: " + path); } } - if (CURRENT_USER_PLACEHOLDER.equals(root)) { - // Placeholder-rooted form; the typed-root restriction is the governance ceiling's, not shape's. - return issues; - } - if ("users".equals(root)) { - // Personal targets are declared only via the placeholder — a concrete users/… path resolves for - // no one but that user and is rejected at write time as a shape error. - issues.add(at + ": personal targets must use the current-user placeholder, not a concrete users/… path: " + path); - } else if (!GLOBAL_VIEW_ROOTS.contains(root)) { - issues.add(at + ": target must be a global-view path or current-user rooted: " + path); - } else if (segments.length < 2) { - // A bare type root addresses the whole global view of that type — as over-broad as the - // personal root the governance ceiling bans. Declarations must be folder- or file-scoped. - issues.add(at + ": target must address a folder or resource within " + root + "/, not the type root: " + path); - } return issues; } - private static boolean isTypedPersonalPath(String[] segments) { - return segments.length > 1 && PERSONAL_TYPED_ROOTS.contains(segments[1]); - } - private static String pathOf(ResourceDependency dependency) { if (dependency.getTarget() == null || dependency.getTarget().getPath() == null) { return null; diff --git a/server/src/test/java/com/epam/aidial/core/server/CustomApplicationApiTest.java b/server/src/test/java/com/epam/aidial/core/server/CustomApplicationApiTest.java index 1c3e3cf5a..912b942cf 100644 --- a/server/src/test/java/com/epam/aidial/core/server/CustomApplicationApiTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/CustomApplicationApiTest.java @@ -1780,7 +1780,7 @@ void testResourceDependenciesSectionWriteTimeValidation() { // the typed-root restriction belongs to the user-authored ceiling, not to shape. Response response = send(HttpMethod.PUT, "/v1/applications/public/resource-dependency-app", null, dependencyAppBody(""" - {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "current-user/skills/"}, "access": ["write"], "required": true}, + {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "skills/{current-user}/"}, "access": ["write"], "required": true}, {"kind": "dial.resourceLink", "link_id": "lnk_policies", "target": {"path": "files/public/policies/"}, "access": ["read"]}"""), "authorization", "admin"); verify(response, 200); @@ -1788,14 +1788,14 @@ void testResourceDependenciesSectionWriteTimeValidation() { // User-authored (own bucket) with the flag off — the default: rejected wholesale. response = send(HttpMethod.PUT, "/v1/applications/" + bucket + "/resource-dependency-app", null, dependencyAppBody(""" - {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "current-user/skills/"}, "access": ["write"]}""")); + {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "skills/{current-user}/"}, "access": ["write"]}""")); verify(response, 403); // The ceiling is keyed on the author, not the destination bucket: an admin prototyping in // their own bucket authors an admin app — the section is accepted even with the flag off. response = send(HttpMethod.PUT, "/v1/applications/" + adminBucket() + "/resource-dependency-app", null, dependencyAppBody(""" - {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "current-user/skills/"}, "access": ["write"]}"""), + {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "skills/{current-user}/"}, "access": ["write"]}"""), "authorization", "admin"); verify(response, 200); @@ -1804,8 +1804,8 @@ void testResourceDependenciesSectionWriteTimeValidation() { {"wrong kind", "{\"kind\": \"dial.resource\", \"link_id\": \"lnk_1\", \"target\": {\"path\": \"files/public/f/\"}, \"access\": [\"read\"]}"}, {"concrete personal path", "{\"kind\": \"dial.resourceLink\", \"link_id\": \"lnk_1\", \"target\": {\"path\": \"users/bob/files/f/\"}, \"access\": [\"read\"]}"}, {"wildcard", "{\"kind\": \"dial.resourceLink\", \"link_id\": \"lnk_1\", \"target\": {\"path\": \"files/public/*/\"}, \"access\": [\"read\"]}"}, - {"placeholder off the root", - "{\"kind\": \"dial.resourceLink\", \"link_id\": \"lnk_1\", \"target\": {\"path\": \"files/public/current-user/f/\"}, \"access\": [\"read\"]}"}, + {"placeholder off the bucket slot", + "{\"kind\": \"dial.resourceLink\", \"link_id\": \"lnk_1\", \"target\": {\"path\": \"files/public/%7Bcurrent-user%7D/f/\"}, \"access\": [\"read\"]}"}, {"unknown root", "{\"kind\": \"dial.resourceLink\", \"link_id\": \"lnk_1\", \"target\": {\"path\": \"buckets/public/f/\"}, \"access\": [\"read\"]}"}, {"share is not a dependency right", "{\"kind\": \"dial.resourceLink\", \"link_id\": \"lnk_1\", \"target\": {\"path\": \"files/public/f/\"}, \"access\": [\"share\"]}"}, @@ -1843,8 +1843,10 @@ private String adminBucket() { } /** - * The governance flag on: user-authored apps may declare dependencies, but personal targets - * must be typed — a root-level {@code current-user/} declaration is not declarable. + * The governance flag on: user-authored apps may declare dependencies. No further per-path + * ceiling applies — the grammar itself already requires segment 0 to be a declarable type, so + * a root-level "write everything personal" declaration is not expressible under + * {type}/{bucket}/… at all (shape rejects it with 400, not the ceiling with 403). */ public static class AllowUserResourceDependenciesOn extends ResourceBaseTest { @@ -1857,18 +1859,21 @@ protected JsonObject additionalSettingsOverrides() { void testUserAuthoredDependenciesWhileFlagOn() { Response response = send(HttpMethod.PUT, "/v1/applications/" + bucket + "/resource-dependency-app", null, dependencyAppBody(""" - {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "current-user/skills/"}, "access": ["write"], "required": true}""")); + {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "skills/{current-user}/"}, "access": ["write"], "required": true}""")); verify(response, 200); + // The old root-level personal shape ({current-user}/ alone) fails shape validation now — + // 400, not the 403 ceiling: the token is out of position and segment 0 is not a type. response = send(HttpMethod.PUT, "/v1/applications/" + bucket + "/resource-dependency-app", null, dependencyAppBody(""" - {"kind": "dial.resourceLink", "link_id": "lnk_root", "target": {"path": "current-user/"}, "access": ["write"]}""")); - verify(response, 403); + {"kind": "dial.resourceLink", "link_id": "lnk_root", "target": {"path": "{current-user}/"}, "access": ["write"]}""")); + verify(response, 400); + // The old shipped spelling fails loudly too — current-user is not a declarable type. response = send(HttpMethod.PUT, "/v1/applications/" + bucket + "/resource-dependency-app", null, dependencyAppBody(""" {"kind": "dial.resourceLink", "link_id": "lnk_untyped", "target": {"path": "current-user/rootstuff/"}, "access": ["write"]}""")); - verify(response, 403); + verify(response, 400); } } diff --git a/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java b/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java index f7881edbc..f834c5a66 100644 --- a/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/PlatformAppToolsetApiTest.java @@ -444,7 +444,7 @@ void testMalformedResourceDependenciesSectionRejected() { "endpoint": "http://application1/v1/completions", "display_name": "Platform App", "resource_dependencies": [ - {"kind": "dial.resourceLink", "link_id": "lnk_1", "target": {"path": "current-user/skills/"}, "access": ["write"]} + {"kind": "dial.resourceLink", "link_id": "lnk_1", "target": {"path": "skills/{current-user}/"}, "access": ["write"]} ] } """; diff --git a/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyApiTest.java b/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyApiTest.java index 2a04df3a0..d99c61541 100644 --- a/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyApiTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyApiTest.java @@ -70,7 +70,7 @@ private Response chat(AtomicReference targetStatus, String targetPath) @Test void testRequiredDependencyWithoutConsentFailsTheCall() { verify(putDeclaringApp(""" - {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": true} + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "prompts/{current-user}/dep-smoke/"}, "access": ["write"], "required": true} """), 200); String bucket = userBucket(); @@ -84,7 +84,7 @@ void testRequiredDependencyWithoutConsentFailsTheCall() { @Test void testOptionalDependencyWithoutConsentDegradesSilently() { verify(putDeclaringApp(""" - {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": false} + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "prompts/{current-user}/dep-smoke/"}, "access": ["write"], "required": false} """), 200); String bucket = userBucket(); @@ -98,7 +98,7 @@ void testOptionalDependencyWithoutConsentDegradesSilently() { @Test void testConsentedRequiredDependencyGrantsAndFailsAfterWithdrawal() { verify(putDeclaringApp(""" - {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": true} + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "prompts/{current-user}/dep-smoke/"}, "access": ["write"], "required": true} """), 200); verify(grantConsent(), 200); String bucket = userBucket(); @@ -119,13 +119,13 @@ void testConsentedRequiredDependencyGrantsAndFailsAfterWithdrawal() { @Test void testDeclarationChangeInvalidatesTheGrant() { verify(putDeclaringApp(""" - {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": true} + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "prompts/{current-user}/dep-smoke/"}, "access": ["write"], "required": true} """), 200); verify(grantConsent(), 200); // Any declaration change re-requires the grant — content binding. verify(putDeclaringApp(""" - {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": true}, - {"kind": "dial.resourceLink", "link_id": "lnk_extra", "target": {"path": "current-user/prompts/other/"}, "access": ["read"]} + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "prompts/{current-user}/dep-smoke/"}, "access": ["write"], "required": true}, + {"kind": "dial.resourceLink", "link_id": "lnk_extra", "target": {"path": "prompts/{current-user}/other/"}, "access": ["read"]} """), 200); String bucket = userBucket(); @@ -174,7 +174,7 @@ void testChainedCallIntoDeclaringAppStaysCallableWithoutGrants() { // first revision threw — and must not bake grants it cannot evaluate the originating // user's reach for. verify(putDeclaringApp(""" - {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": true} + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "prompts/{current-user}/dep-smoke/"}, "access": ["write"], "required": true} """), 200); verify(grantConsent(), 200); String bucket = userBucket(); @@ -215,7 +215,7 @@ void testAuditEventsCarryTheDesignFields() { try { String bucket = userBucket(); verify(putDeclaringApp(""" - {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": false} + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "prompts/{current-user}/dep-smoke/"}, "access": ["write"], "required": false} """), 200); // optional, unconsented: the call succeeds and the denial is audited @@ -224,7 +224,7 @@ void testAuditEventsCarryTheDesignFields() { // required, unconsented: the call fails and the runtime failure is audited verify(putDeclaringApp(""" - {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": true} + {"kind": "dial.resourceLink", "link_id": "lnk", "target": {"path": "prompts/{current-user}/dep-smoke/"}, "access": ["write"], "required": true} """), 200); AtomicReference failedStatus = new AtomicReference<>(); assertEquals(403, chat(failedStatus, "/v1/prompts/%s/dep-smoke/prompt-by-app".formatted(bucket)).status()); @@ -237,7 +237,7 @@ void testAuditEventsCarryTheDesignFields() { String denial = events.stream().filter(e -> e.contains("event=resource_dependency_denial")).findFirst().orElse(null); assertNotNull(denial, () -> "Events: " + events); assertTrue(denial.contains("application_id=applications/public/dep-e2e-app"), denial); - assertTrue(denial.contains("targets=current-user/prompts/dep-smoke/"), denial); + assertTrue(denial.contains("targets=prompts/{current-user}/dep-smoke/"), denial); assertTrue(denial.contains("user_id=user"), denial); assertTrue(denial.contains("trace_id=") && !denial.contains("trace_id=,") && !denial.contains("trace_id=null"), denial); diff --git a/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyConsentApiTest.java b/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyConsentApiTest.java index 5d03f5ce3..9a0150806 100644 --- a/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyConsentApiTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyConsentApiTest.java @@ -29,7 +29,7 @@ public class ResourceDependencyConsentApiTest extends ResourceBaseTest { "endpoint": "http://application1/v1/completions", "display_name": "Dependency Consent App", "resource_dependencies": [ - {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "current-user/skills/"}, "access": ["write"], "required": true} + {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "skills/{current-user}/"}, "access": ["write"], "required": true} ] } """; @@ -48,7 +48,7 @@ void testAdminCanGrantAndWithdrawConsent() { // consentRequired flag anywhere — consent is never author-controlled (§6.1). Response consent = send(HttpMethod.GET, "/v1/consent/" + DECLARING_APP, null, "", "authorization", "admin"); verify(consent, 200); - assertTrue(consent.body().contains("current-user/skills/"), () -> "Body: " + consent.body()); + assertTrue(consent.body().contains("skills/{current-user}/"), () -> "Body: " + consent.body()); verify(send(HttpMethod.DELETE, "/v1/consent/" + DECLARING_APP + "/admin-consent", null, "", "authorization", "admin"), 200); @@ -101,7 +101,7 @@ void testAdminConsentStatusReadsTheTriState() { assertTrue(granted.body().contains("\"stale\":false"), () -> "Body: " + granted.body()); assertTrue(granted.body().contains("\"grantedBy\":\"admin\""), () -> "Body: " + granted.body()); assertTrue(granted.body().contains("\"grantedAt\":"), () -> "Body: " + granted.body()); - assertTrue(granted.body().contains("\"url\":\"current-user/skills/\""), () -> "Body: " + granted.body()); + assertTrue(granted.body().contains("\"url\":\"skills/{current-user}/\""), () -> "Body: " + granted.body()); // Declaration changed since the grant: not consented, stale — and the last approval stays // visible for the panel's re-approve view. @@ -110,8 +110,8 @@ void testAdminConsentStatusReadsTheTriState() { "endpoint": "http://application1/v1/completions", "display_name": "Dependency Consent App", "resource_dependencies": [ - {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "current-user/skills/"}, "access": ["write"], "required": true}, - {"kind": "dial.resourceLink", "link_id": "lnk_extra", "target": {"path": "current-user/files/dep-extra/"}, "access": ["read"]} + {"kind": "dial.resourceLink", "link_id": "lnk_skills", "target": {"path": "skills/{current-user}/"}, "access": ["write"], "required": true}, + {"kind": "dial.resourceLink", "link_id": "lnk_extra", "target": {"path": "files/{current-user}/dep-extra/"}, "access": ["read"]} ] } """, "authorization", "admin"), 200); @@ -121,7 +121,7 @@ void testAdminConsentStatusReadsTheTriState() { assertTrue(stale.body().contains("\"consented\":false"), () -> "Body: " + stale.body()); assertTrue(stale.body().contains("\"stale\":true"), () -> "Body: " + stale.body()); assertTrue(stale.body().contains("\"grantedBy\":\"admin\""), "the last approval's provenance survives the stale transition"); - assertTrue(stale.body().contains("\"url\":\"current-user/skills/\""), "the approved snapshot survives for the re-approve diff view"); + assertTrue(stale.body().contains("\"url\":\"skills/{current-user}/\""), "the approved snapshot survives for the re-approve diff view"); // Withdrawn: back to the clean never-granted shape. verify(send(HttpMethod.DELETE, "/v1/consent/" + DECLARING_APP + "/admin-consent", null, "", @@ -190,11 +190,11 @@ void testConsentDecisionsAreAudited() { assertEquals(3, events.size(), () -> "Events: " + events); assertTrue(events.get(0).contains("action=GRANT") && events.get(0).contains("outcome=SUCCESS") - && events.get(0).contains("targets=current-user/skills/"), () -> "Events: " + events); + && events.get(0).contains("targets=skills/{current-user}/"), () -> "Events: " + events); assertTrue(events.get(1).contains("action=GRANT") && events.get(1).contains("outcome=DENIED"), () -> "Events: " + events); assertTrue(events.get(2).contains("action=WITHDRAW") && events.get(2).contains("outcome=SUCCESS") - && events.get(2).contains("targets=current-user/skills/"), () -> "Events: " + events); + && events.get(2).contains("targets=skills/{current-user}/"), () -> "Events: " + events); assertFalse(events.stream().anyMatch(message -> message.contains("lnk_"))); } finally { auditLogger.detachAppender(appender); diff --git a/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyResolutionApiTest.java b/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyResolutionApiTest.java index b4f769d2b..b67eb5884 100644 --- a/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyResolutionApiTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/ResourceDependencyResolutionApiTest.java @@ -29,7 +29,7 @@ void testChatPathBakesConsentedDependencyGrantIntoThePerRequestKey() { "display_name": "Dependency Smoke App", "resource_dependencies": [ {"kind": "dial.resourceLink", "link_id": "lnk_prompts", - "target": {"path": "current-user/prompts/dep-smoke/"}, "access": ["write"], "required": true} + "target": {"path": "prompts/{current-user}/dep-smoke/"}, "access": ["write"], "required": true} ] } """, "authorization", "admin", "If-None-Match", "*"), 200); diff --git a/server/src/test/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFnTest.java b/server/src/test/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFnTest.java index b2bd47ffd..4b1d4758d 100644 --- a/server/src/test/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFnTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/function/ResolveResourceDependenciesFnTest.java @@ -125,7 +125,7 @@ void apply_skipsWhenNotTheRootUserCall() { // (an interceptor's final call back, a chained app-to-app call) are skipped, never run // and never thrown: a declaring app behind an interceptor must stay callable. when(context.getDeployment()).thenReturn(application); - application.setResourceDependencies(List.of(dependency("current-user/skills/", false))); + application.setResourceDependencies(List.of(dependency("skills/{current-user}/", false))); ApiKeyData assigned = new ApiKeyData(); assigned.setPerRequestKey("prk"); when(context.getApiKeyData()).thenReturn(assigned); @@ -138,7 +138,7 @@ void apply_skipsWhenNotTheRootUserCall() { @Test void apply_bakesGrantForConsentedReachablePlaceholderTarget() { when(context.getDeployment()).thenReturn(application); - application.setResourceDependencies(List.of(dependency("current-user/skills/", false))); + application.setResourceDependencies(List.of(dependency("skills/{current-user}/", false))); when(consentService.isAdminConsented(eq("app"), any())).thenReturn(true); when(accessService.lookupPermissions(any(), eq(context))) .thenReturn(Map.of(userSkillsFolder(), Set.of(ResourceAccessType.READ, ResourceAccessType.WRITE))); @@ -178,12 +178,14 @@ void apply_combinesGrantsForRecordsWithTheSameTarget() { @Test void apply_neverResolvesInternalEngineTypesAsPersonalTargets() { // ResourceTypes.of() maps internal engine types (credentials, keys, models…) — a - // current-user/credentials/ declaration must never reach the user's secret-bearing blobs, - // whoever authored the app (config-file apps bypass the write-time ceiling). + // credentials/{current-user}/ declaration must never reach the user's secret-bearing blobs, + // whoever authored the app (config-file apps bypass the write-time ceiling). The same + // vocabulary gates concrete paths too, not only placeholder ones. when(context.getDeployment()).thenReturn(application); application.setResourceDependencies(List.of( - dependency("current-user/credentials/", false), - dependency("current-user/keys/", false))); + dependency("credentials/{current-user}/", false), + dependency("keys/{current-user}/", false), + dependency("credentials/public/x/", false))); when(consentService.isAdminConsented(eq("app"), any())).thenReturn(true); assertFalse(fn.apply(request)); @@ -245,7 +247,7 @@ void apply_failsCallWhenRequiredTargetIsUnreachable() { @Test void apply_failsCallWhenRequiredDeclarationIsUnconsented() { when(context.getDeployment()).thenReturn(application); - application.setResourceDependencies(List.of(dependency("current-user/skills/", true))); + application.setResourceDependencies(List.of(dependency("skills/{current-user}/", true))); when(consentService.isAdminConsented(eq("app"), any())).thenReturn(false); HttpException error = assertThrows(HttpException.class, () -> fn.apply(request)); diff --git a/server/src/test/java/com/epam/aidial/core/server/service/ConsentServiceTest.java b/server/src/test/java/com/epam/aidial/core/server/service/ConsentServiceTest.java index 6ec09dc1d..644a72075 100644 --- a/server/src/test/java/com/epam/aidial/core/server/service/ConsentServiceTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/service/ConsentServiceTest.java @@ -582,7 +582,7 @@ public void testBuildConsent_IncludesDeclaredResourcesRegardlessOfConsentRequire "applications": { "A": { "resource_dependencies": [ - {"kind": "dial.resourceLink", "link_id": "lnk_1", "target": {"path": "current-user/skills/"}, "access": ["WRITE"]} + {"kind": "dial.resourceLink", "link_id": "lnk_1", "target": {"path": "skills/{current-user}/"}, "access": ["WRITE"]} ] } } @@ -607,7 +607,7 @@ public void testBuildConsent_IncludesDeclaredResourcesRegardlessOfConsentRequire }, "resources" : [ { "access" : [ "WRITE" ], - "url" : "current-user/skills/" + "url" : "skills/{current-user}/" } ] } }""", response); @@ -621,7 +621,7 @@ public void testGrantAdminConsent_StoresTheEnvelopeWithProvenanceInPublicAdminCo ConsentGrant grant = service.grantAdminConsent(context, "app"); - assertEquals(List.of(resourceEntry("current-user/skills/")), grant.getConsent().getResources()); + assertEquals(List.of(resourceEntry("skills/{current-user}/")), grant.getConsent().getResources()); assertEquals("admin-sub", grant.getGrantedBy(), "provenance is server-stamped from the authenticated admin"); assertEquals(1788394665564L, grant.getGrantedAt()); ArgumentCaptor captor = ArgumentCaptor.forClass(ResourceDescriptor.class); @@ -646,35 +646,35 @@ public void testGrantAdminConsent_RejectsApplicationWithoutDeclaration() { @Test public void testIsAdminConsented_IsContentBoundToTheDeclaration() { when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(""" - {"consent": {"resources": [{"url": "current-user/skills/", "access": ["WRITE"]}]}, + {"consent": {"resources": [{"url": "skills/{current-user}/", "access": ["WRITE"]}]}, "grantedBy": "admin-sub", "grantedAt": 1788394665564} """); - assertTrue(service.isAdminConsented("app", declaration("current-user/skills/"))); + assertTrue(service.isAdminConsented("app", declaration("skills/{current-user}/"))); // any declaration change re-requires the grant — an extra entry, a reordered section - assertFalse(service.isAdminConsented("app", declaration("current-user/skills/", "files/public/p/"))); - assertFalse(service.isAdminConsented("app", declaration("files/public/p/", "current-user/skills/"))); + assertFalse(service.isAdminConsented("app", declaration("skills/{current-user}/", "files/public/p/"))); + assertFalse(service.isAdminConsented("app", declaration("files/public/p/", "skills/{current-user}/"))); // the grant's own provenance never participates in the binding — the same snapshot under a // different who/when still stands when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(""" - {"consent": {"resources": [{"url": "current-user/skills/", "access": ["WRITE"]}]}, + {"consent": {"resources": [{"url": "skills/{current-user}/", "access": ["WRITE"]}]}, "grantedBy": "another-admin", "grantedAt": 9999999999999} """); - assertTrue(service.isAdminConsented("app", declaration("current-user/skills/"))); + assertTrue(service.isAdminConsented("app", declaration("skills/{current-user}/"))); } @Test public void testIsAdminConsented_WhenNoRecordWasEverGranted() { when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(null); - assertFalse(service.isAdminConsented("app", declaration("current-user/skills/"))); + assertFalse(service.isAdminConsented("app", declaration("skills/{current-user}/"))); } @Test public void testDescribeAdminConsent_NeverGranted() { when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(null); - AdminConsentStatus status = service.describeAdminConsent("app", declaration("current-user/skills/")); + AdminConsentStatus status = service.describeAdminConsent("app", declaration("skills/{current-user}/")); assertFalse(status.isConsented()); assertNull(status.getStale(), "stale is meaningless without a record"); @@ -686,23 +686,23 @@ public void testDescribeAdminConsent_NeverGranted() { @Test public void testDescribeAdminConsent_LiveGrant() { when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(""" - {"consent": {"resources": [{"url": "current-user/skills/", "access": ["WRITE"]}]}, + {"consent": {"resources": [{"url": "skills/{current-user}/", "access": ["WRITE"]}]}, "grantedBy": "admin-sub", "grantedAt": 1788394665564} """); - AdminConsentStatus status = service.describeAdminConsent("app", declaration("current-user/skills/")); + AdminConsentStatus status = service.describeAdminConsent("app", declaration("skills/{current-user}/")); assertTrue(status.isConsented(), "consented means live right now — exactly what the resolver enforces"); assertFalse(status.getStale()); assertEquals("admin-sub", status.getGrantedBy()); assertEquals(1788394665564L, status.getGrantedAt()); - assertEquals(List.of(resourceEntry("current-user/skills/")), status.getGrantedResources()); + assertEquals(List.of(resourceEntry("skills/{current-user}/")), status.getGrantedResources()); } @Test public void testDescribeAdminConsent_StaleGrantKeepsTheLastApproval() { when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(""" - {"consent": {"resources": [{"url": "current-user/skills/", "access": ["WRITE"]}]}, + {"consent": {"resources": [{"url": "skills/{current-user}/", "access": ["WRITE"]}]}, "grantedBy": "admin-sub", "grantedAt": 1788394665564} """); @@ -714,7 +714,7 @@ public void testDescribeAdminConsent_StaleGrantKeepsTheLastApproval() { assertTrue(status.getStale()); assertEquals("admin-sub", status.getGrantedBy()); assertEquals(1788394665564L, status.getGrantedAt()); - assertEquals(List.of(resourceEntry("current-user/skills/")), status.getGrantedResources()); + assertEquals(List.of(resourceEntry("skills/{current-user}/")), status.getGrantedResources()); } @Test @@ -738,28 +738,28 @@ public void testDescribeAdminConsent_LegacyBareConsentRecordFailsClosedWithoutTh // stale path — never a throw, which would break the resolver (400 on every user call) and // make the record un-withdrawable. when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(""" - {"resources": [{"url": "current-user/skills/", "access": ["WRITE"]}]} + {"resources": [{"url": "skills/{current-user}/", "access": ["WRITE"]}]} """); - AdminConsentStatus status = service.describeAdminConsent("app", declaration("current-user/skills/")); + AdminConsentStatus status = service.describeAdminConsent("app", declaration("skills/{current-user}/")); assertFalse(status.isConsented(), "a legacy record never reads as consented — fail closed"); assertTrue(status.getStale()); assertEquals(List.of(), status.getGrantedResources()); - assertFalse(service.isAdminConsented("app", declaration("current-user/skills/"))); + assertFalse(service.isAdminConsented("app", declaration("skills/{current-user}/"))); } @Test public void testWithdrawAdminConsent_ReturnsTheWithdrawnGrantForTheAudit() { when(deploymentService.findDeployment(eq(context), eq("app"))).thenReturn(declaringApplication()); when(resourceService.getResource(any(ResourceDescriptor.class))).thenReturn(""" - {"consent": {"resources": [{"url": "current-user/skills/", "access": ["WRITE"]}]}, + {"consent": {"resources": [{"url": "skills/{current-user}/", "access": ["WRITE"]}]}, "grantedBy": "admin-sub", "grantedAt": 1788394665564} """); ConsentGrant withdrawn = service.withdrawAdminConsent(context, "app"); - assertEquals(List.of(resourceEntry("current-user/skills/")), withdrawn.getConsent().getResources()); + assertEquals(List.of(resourceEntry("skills/{current-user}/")), withdrawn.getConsent().getResources()); assertEquals("admin-sub", withdrawn.getGrantedBy()); verify(resourceService).deleteResource(any(ResourceDescriptor.class), eq(EtagHeader.ANY)); } @@ -786,7 +786,7 @@ public void testAdminConsentRecordIsKeyedByTheResolvedApplicationsCanonicalName( private static Application declaringApplication() { Application application = new Application(); application.setName("app"); - application.setResourceDependencies(declaration("current-user/skills/")); + application.setResourceDependencies(declaration("skills/{current-user}/")); return application; } diff --git a/server/src/test/java/com/epam/aidial/core/server/service/ResourceDependencyValidatorTest.java b/server/src/test/java/com/epam/aidial/core/server/service/ResourceDependencyValidatorTest.java index 0cfdfb4c3..d844b6cca 100644 --- a/server/src/test/java/com/epam/aidial/core/server/service/ResourceDependencyValidatorTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/service/ResourceDependencyValidatorTest.java @@ -49,7 +49,7 @@ void acceptsConcreteGlobalViewFolderTarget() { void acceptsCurrentUserPlaceholderTarget() { ResourceDependencyValidator validator = new ResourceDependencyValidator(false); - assertDoesNotThrow(() -> validator.validateShape(appWith(dependency("current-user/skills/")))); + assertDoesNotThrow(() -> validator.validateShape(appWith(dependency("skills/{current-user}/")))); } @Test @@ -124,7 +124,7 @@ void rejectsConcreteUsersPathAsShapeError() { ResourceDependencyValidator validator = new ResourceDependencyValidator(false); Application application = appWith(dependency("users/someone/files/folder/")); - assertTrue(validatorShapeMessage(validator, application).contains("current-user placeholder")); + assertTrue(validatorShapeMessage(validator, application).contains("{current-user}")); } @Test @@ -132,15 +132,26 @@ void rejectsUnknownRootSegment() { ResourceDependencyValidator validator = new ResourceDependencyValidator(false); Application application = appWith(dependency("buckets/public/folder/")); - assertTrue(validatorShapeMessage(validator, application).contains("global-view path")); + assertTrue(validatorShapeMessage(validator, application).contains("declarable resource type")); } @Test - void rejectsPlaceholderOutsideTheRoot() { + void rejectsPlaceholderOutsideTheBucketSlot() { + // Under {type}/{bucket}/… the placeholder is legal only at segment 1 (the bucket slot). ResourceDependencyValidator validator = new ResourceDependencyValidator(false); - Application application = appWith(dependency("files/public/current-user/folder/")); - assertTrue(validatorShapeMessage(validator, application).contains("only as the root segment")); + // Token at i == 2 — a folder literally named {current-user} deeper in the path. + assertTrue(validatorShapeMessage(validator, appWith(dependency("files/public/{current-user}/folder/"))) + .contains("only as the bucket segment")); + + // Token at i == 0 — placeholder used where the type belongs. + assertTrue(validatorShapeMessage(validator, appWith(dependency("{current-user}/skills/"))) + .contains("only as the bucket segment")); + + // A bare word (no braces) mid-path is an ordinary, legal folder name — the ban is + // positional-and-lexical (only the braced token, only at segment 1), not a lexical ban on + // the word "current-user" anywhere in a path. + assertDoesNotThrow(() -> validator.validateShape(appWith(dependency("files/current-user/folder/")))); } @Test @@ -161,8 +172,8 @@ void rejectsPercentEncodedBannedTokens() { assertTrue(validatorShapeMessage(validator, appWith(dependency("files/public/%2e%2e/x/"))).contains("relative path segments")); assertTrue(validatorShapeMessage(validator, appWith(dependency("files/public/%2a/"))).contains("wildcards")); - assertTrue(validatorShapeMessage(validator, appWith(dependency("files/public/%63urrent-user/f/"))).contains("only as the root segment")); - assertTrue(validatorShapeMessage(validator, appWith(dependency("%75sers/bob/files/"))).contains("current-user placeholder")); + assertTrue(validatorShapeMessage(validator, appWith(dependency("files/public/%7Bcurrent-user%7D/f/"))).contains("only as the bucket segment")); + assertTrue(validatorShapeMessage(validator, appWith(dependency("%75sers/bob/files/"))).contains("{current-user}")); } @Test @@ -195,7 +206,30 @@ void rejectsBareTypeRootAsTooBroad() { ResourceDependencyValidator validator = new ResourceDependencyValidator(false); assertTrue(validatorShapeMessage(validator, appWith(dependency("files"))).contains("not the type root")); - assertTrue(validatorShapeMessage(validator, appWith(dependency("public/"))).contains("not the type root")); + + // "public" is a bucket value, never a type (D-23a) — it is rejected now by the root-vocabulary + // check, not by the removed dead "public" entry in the old GLOBAL_VIEW_ROOTS. + assertTrue(validatorShapeMessage(validator, appWith(dependency("public/"))).contains("declarable resource type")); + + // Two segments is type + bucket — the whole of one bucket's folder of that type, the shape the + // feature exists to serve (D-23c). The bar rejects one-segment paths only, for both forms. + assertDoesNotThrow(() -> validator.validateShape(appWith(dependency("skills/{current-user}/")))); + assertDoesNotThrow(() -> validator.validateShape(appWith(dependency("files/public/")))); + } + + @Test + void credentialsPlaceholderIsRejectedAtWriteTime() { + // Regression guard for the deleted early return: without the unconditional root-vocabulary + // check, credentials/{current-user}/ would have silently bypassed write-time validation and + // been accepted, naming the user's secret-bearing blobs. + ResourceDependencyValidator validator = new ResourceDependencyValidator(false); + + assertTrue(validatorShapeMessage(validator, appWith(dependency("credentials/{current-user}/"))) + .contains("declarable resource type")); + assertTrue(validatorShapeMessage(validator, appWith(dependency("keys/{current-user}/"))) + .contains("declarable resource type")); + assertTrue(validatorShapeMessage(validator, appWith(dependency("models/{current-user}/"))) + .contains("declarable resource type")); } @Test @@ -232,21 +266,25 @@ void rejectsUserAuthoredSectionWhileFlagOff() { void acceptsUserAuthoredSectionWhileFlagOn() { ResourceDependencyValidator validator = new ResourceDependencyValidator(true); - assertDoesNotThrow(() -> validator.validateUserAuthored(appWith(dependency("current-user/skills/")))); + assertDoesNotThrow(() -> validator.validateUserAuthored(appWith(dependency("skills/{current-user}/")))); } @Test - void rejectsRootLevelCurrentUserWhileFlagOn() { + void rootLevelPersonalDeclarationIsUnexpressible() { + // D-23b: the per-path ceiling is gone — the grammar itself makes "write everything personal" + // unexpressible, so both old root-level shapes now fail as shape errors (400, via + // validateShape), not as a ceiling violation (403, via validateUserAuthored). ResourceDependencyValidator validator = new ResourceDependencyValidator(true); - HttpException root = assertThrows(HttpException.class, - () -> validator.validateUserAuthored(appWith(dependency("current-user/")))); - HttpException untyped = assertThrows(HttpException.class, - () -> validator.validateUserAuthored(appWith(dependency("current-user/rootstuff/")))); + assertTrue(validatorShapeMessage(validator, appWith(dependency("{current-user}/"))) + .contains("declarable resource type")); + assertTrue(validatorShapeMessage(validator, appWith(dependency("current-user/rootstuff/"))) + .contains("declarable resource type")); - assertEquals(403, root.getStatus().getCode()); - assertEquals(403, untyped.getStatus().getCode()); - assertTrue(untyped.getMessage().contains("resource-type folder")); + // A well-shaped personal target passes validateUserAuthored with the flag on... + assertDoesNotThrow(() -> validator.validateUserAuthored(appWith(dependency("skills/{current-user}/")))); + // ...and the ceiling itself now throws for no well-shaped path — it degenerates to the flag check. + assertDoesNotThrow(() -> validator.validateUserAuthored(appWith(dependency("files/{current-user}/notes/")))); } @Test