fix(knowledge-fs): honor capability grants in fs handlers

This commit is contained in:
Jyong 2026-08-04 02:55:19 -04:00
parent 28b3306867
commit c944ffe558
4 changed files with 105 additions and 2 deletions

View File

@ -1,6 +1,6 @@
{
"schemaVersion": 5,
"subtreeTree": "ae826da4bed6a57241dbed9da2faef4594a2dea5",
"subtreeTree": "6be868e420fae6cc28cd832d21eccd505eedbc46",
"openapiSha256": "189c98cd2535829d75b090a70c22dac720a9e9349b8b2cde1602abc31d14f8b8",
"capabilityV2AuthManifestSha256": "fc0a47e23cce12544882f0298522b4933002e892b84ce1815df7e81d36a7a0c7",
"capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3",

View File

@ -0,0 +1,50 @@
# KnowledgeFS Capability v2 CLI authorization
## What changed
- Passed the authenticated Capability v2 grant into candidate-content authorization for all
KnowledgeFS command handlers: `ls`, `tree`, `grep`, `find`, `diff`, `open_node`, `cat`, `stat`,
`write`, and `append`.
- Added a handler regression covering Capability v2 requests without the legacy authorization
decision and verifying that the grant's normalized content scope reaches every command.
## Why
The Capability v2 gateway had already authenticated and authorized CLI requests, but the command
handlers only consulted the legacy authorization decision. Capability v2 requests therefore lost
their candidate scope at the handler boundary and were rejected with `403 Knowledge space access
denied`; Dify surfaced that product response as `503 knowledge_fs_unavailable` for every CLI
filesystem command.
## Performance and safety
- The fix only forwards the sanitized grant already stored on the request context; it adds no
database, model, or network calls.
- `currentCandidateGrants` continues to bind the grant to the exact tenant, subject, and knowledge
space and to fail closed for malformed or mismatched scopes.
- Legacy authorization decisions remain supported as the fallback path.
## Verification
- RED: the new focused regression returned 403 for Capability v2 `ls` before the implementation.
- `pnpm --filter @knowledge/api exec vitest run src/knowledge-fs-handlers-branch-coverage.test.ts`:
passed, 16 tests.
- `pnpm --filter @knowledge/api typecheck`: passed.
- Targeted Biome check for both changed TypeScript files: passed.
- `CI=1 pnpm check`: passed, including the full workspace test suite, coverage gates,
retrieval evaluations, migration checks, and deployment/static guards.
- `CI=1 pnpm build`: passed for all 12 KnowledgeFS packages.
- `CI=1 pnpm lint:backend`: passed, 980 files checked.
- `CI=1 pnpm lint`: attempted but remains blocked by 10 pre-existing, unrelated
whole-workspace Admin/generated-contract findings. These include formatting in Admin files
outside this change and the checked-in 1.5 MiB OpenAPI artifact exceeding Biome's 1 MiB limit.
- `uv run --project api python api/dev/generate_knowledge_fs_contract.py --check`: passed after
intentionally refreshing the staged KnowledgeFS subtree lock; OpenAPI and Capability v2
contract digests were unchanged.
## Risks and follow-up
- The change affects authorization plumbing for ten command handlers but does not broaden access:
the existing subject, tenant, resource, and content-scope checks are unchanged.
- Dify's translation of upstream authorization failures into the generic
`knowledge_fs_unavailable` response is separate from this root-cause fix.

View File

@ -63,6 +63,31 @@ describe("KnowledgeFS handler branch coverage", () => {
}
});
it("executes every command with a Capability v2 candidate scope", async () => {
const fixture = handlersFixture({
capability: capabilityGrant(),
decision: undefined,
});
for (const [route, command] of ROUTES) {
const response = await fixture.invoke(route);
expect(response).toEqual({ body: { command }, status: 200 });
}
expect(fixture.execute).toHaveBeenCalledTimes(ROUTES.length);
for (const [, command] of ROUTES) {
expect(fixture.execute).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
candidatePermissionScope: ["scope:a", "scope:b"],
knowledgeSpaceId: SPACE_ID,
}),
name: command,
}),
);
}
});
it("returns not found before executing every command when the space is absent", async () => {
const fixture = handlersFixture({ space: null });
@ -149,6 +174,7 @@ describe("KnowledgeFS handler branch coverage", () => {
});
interface FixtureOptions {
readonly capability?: unknown;
readonly decision?: unknown;
readonly executeError?: Error;
readonly space?: unknown;
@ -183,6 +209,7 @@ function handlersFixture(options: FixtureOptions = {}) {
if (!callback) throw new Error("route was not registered");
return callback(
context({
capability: options.capability,
decision:
"decision" in options
? options.decision
@ -200,9 +227,16 @@ function handlersFixture(options: FixtureOptions = {}) {
};
}
function context({ decision }: { readonly decision: unknown }) {
function context({
capability,
decision,
}: {
readonly capability: unknown;
readonly decision: unknown;
}) {
const values = new Map<string, unknown>([
["authorizationDecision", decision],
["capabilityV2Grant", capability],
["subject", SUBJECT],
["traceId", "trace-1"],
]);
@ -218,3 +252,12 @@ function context({ decision }: { readonly decision: unknown }) {
},
};
}
function capabilityGrant() {
return {
contentScopeIds: ["scope:b", "scope:a", "scope:a"],
namespaceId: SUBJECT.tenantId,
resource: { id: SPACE_ID, parent_id: null, type: "knowledge_space" },
subject: SUBJECT.subjectId,
};
}

View File

@ -65,6 +65,7 @@ export function registerKnowledgeFsHandlers({
}
const candidatePermissionScope = currentCandidateGrants({
capabilityGrant: context.get("capabilityV2Grant"),
decision: context.get("authorizationDecision"),
knowledgeSpaceId: params.id,
subject,
@ -125,6 +126,7 @@ export function registerKnowledgeFsHandlers({
}
const candidatePermissionScope = currentCandidateGrants({
capabilityGrant: context.get("capabilityV2Grant"),
decision: context.get("authorizationDecision"),
knowledgeSpaceId: params.id,
subject,
@ -185,6 +187,7 @@ export function registerKnowledgeFsHandlers({
}
const candidatePermissionScope = currentCandidateGrants({
capabilityGrant: context.get("capabilityV2Grant"),
decision: context.get("authorizationDecision"),
knowledgeSpaceId: params.id,
subject,
@ -245,6 +248,7 @@ export function registerKnowledgeFsHandlers({
}
const candidatePermissionScope = currentCandidateGrants({
capabilityGrant: context.get("capabilityV2Grant"),
decision: context.get("authorizationDecision"),
knowledgeSpaceId: params.id,
subject,
@ -305,6 +309,7 @@ export function registerKnowledgeFsHandlers({
}
const candidatePermissionScope = currentCandidateGrants({
capabilityGrant: context.get("capabilityV2Grant"),
decision: context.get("authorizationDecision"),
knowledgeSpaceId: params.id,
subject,
@ -355,6 +360,7 @@ export function registerKnowledgeFsHandlers({
}
const candidatePermissionScope = currentCandidateGrants({
capabilityGrant: context.get("capabilityV2Grant"),
decision: context.get("authorizationDecision"),
knowledgeSpaceId: params.id,
subject,
@ -402,6 +408,7 @@ export function registerKnowledgeFsHandlers({
}
const candidatePermissionScope = currentCandidateGrants({
capabilityGrant: context.get("capabilityV2Grant"),
decision: context.get("authorizationDecision"),
knowledgeSpaceId: params.id,
subject,
@ -449,6 +456,7 @@ export function registerKnowledgeFsHandlers({
}
const candidatePermissionScope = currentCandidateGrants({
capabilityGrant: context.get("capabilityV2Grant"),
decision: context.get("authorizationDecision"),
knowledgeSpaceId: params.id,
subject,
@ -496,6 +504,7 @@ export function registerKnowledgeFsHandlers({
}
const candidatePermissionScope = currentCandidateGrants({
capabilityGrant: context.get("capabilityV2Grant"),
decision: context.get("authorizationDecision"),
knowledgeSpaceId: params.id,
subject,
@ -560,6 +569,7 @@ export function registerKnowledgeFsHandlers({
}
const candidatePermissionScope = currentCandidateGrants({
capabilityGrant: context.get("capabilityV2Grant"),
decision: context.get("authorizationDecision"),
knowledgeSpaceId: params.id,
subject,