Repository navigation
feat(core): allow injected ACL scope matching #226
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { exportWorkspaceJson } from "./export.js"; | ||
| import { queryFiles } from "./query.js"; | ||
| import type { FileRow, StorageAdapter } from "./storage.js"; | ||
| import { listTree } from "./tree.js"; | ||
| import { filePermissionAllows, type TokenClaims } from "./acl.js"; | ||
|
|
||
| function claimsWith(scopes: string[]): TokenClaims { | ||
| return { | ||
| workspaceId: "ws_test", | ||
| agentName: "agent_test", | ||
| scopes: new Set(scopes), | ||
| }; | ||
| } | ||
|
|
||
| function file(path: string, permissions: string[] = []): FileRow { | ||
| return { | ||
| path, | ||
| revision: "rev_1", | ||
| contentType: "text/markdown", | ||
| content: "body", | ||
| encoding: "utf-8", | ||
| provider: "github", | ||
| lastEditedAt: "2026-05-31T00:00:00.000Z", | ||
| semantics: { | ||
| permissions, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function storage(files: FileRow[]): StorageAdapter { | ||
| const byPath = new Map(files.map((entry) => [entry.path, entry])); | ||
| return { | ||
| getFile: (path) => byPath.get(path) ?? null, | ||
| listFiles: () => files, | ||
| putFile: () => undefined, | ||
| deleteFile: () => undefined, | ||
| appendEvent: () => undefined, | ||
| listEvents: () => ({ items: [], nextCursor: null }), | ||
| getRecentEvents: () => [], | ||
| getOperation: () => null, | ||
| putOperation: () => undefined, | ||
| listOperations: () => ({ items: [], nextCursor: null }), | ||
| nextRevision: () => "rev_2", | ||
| nextOperationId: () => "op_1", | ||
| nextEventId: () => "evt_1", | ||
| enqueueWriteback: () => undefined, | ||
| getPendingWritebacks: () => [], | ||
| getWorkspaceId: () => "ws_test", | ||
| }; | ||
| } | ||
|
|
||
| describe("ACL scope matching", () => { | ||
| it("keeps exact scope matching as the default", () => { | ||
| const claims = claimsWith(["relayfile:fs:read:/github/*"]); | ||
|
|
||
| expect( | ||
| filePermissionAllows(["scope:relayfile:fs:read:/github/LAYOUT.md"], "ws_test", claims), | ||
| ).toBe(false); | ||
| expect( | ||
| filePermissionAllows(["scope:relayfile:fs:read:/github/*"], "ws_test", claims), | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it("lets callers inject path-aware scope matching for tree, query, and export", () => { | ||
| const claims = claimsWith(["relayfile:fs:read:/github/*"]); | ||
| const rows = [ | ||
| file("/.relayfile.acl", ["scope:relayfile:fs:read:/github/LAYOUT.md"]), | ||
| file("/github/LAYOUT.md"), | ||
| ]; | ||
| const repo = storage(rows); | ||
| const scopeMatches = vi.fn( | ||
| (scope: string, tokenClaims: TokenClaims | null, context) => | ||
| scope === "relayfile:fs:read:/github/LAYOUT.md" && | ||
| tokenClaims?.scopes.has("relayfile:fs:read:/github/*") === true && | ||
| context.action === "read" && | ||
| context.requestedPath === "/github/LAYOUT.md", | ||
| ); | ||
|
|
||
| expect(listTree(repo, { path: "/github", depth: 1 }, claims).entries).toEqual( | ||
| [], | ||
| ); | ||
|
|
||
| const aclOptions = { scopeMatches }; | ||
| expect( | ||
| listTree(repo, { path: "/github", depth: 1 }, claims, aclOptions).entries.map( | ||
| (entry) => entry.path, | ||
| ), | ||
| ).toEqual(["/github/LAYOUT.md"]); | ||
| expect( | ||
| queryFiles(repo, { path: "/github" }, claims, aclOptions).items.map( | ||
| (entry) => entry.path, | ||
| ), | ||
| ).toEqual(["/github/LAYOUT.md"]); | ||
| expect(exportWorkspaceJson(repo, claims, aclOptions).map((entry) => entry.path)).toEqual([ | ||
| "/github/LAYOUT.md", | ||
| ]); | ||
| expect(scopeMatches).toHaveBeenCalled(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -11,6 +11,7 @@ import type { StorageAdapter, FileRow, Paginated, PaginationOptions } from "./st | |||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||
| filePermissionAllows, | ||||||||||||||||||||||||||
| resolveFilePermissions, | ||||||||||||||||||||||||||
| type PermissionEvaluationOptions, | ||||||||||||||||||||||||||
| type TokenClaims, | ||||||||||||||||||||||||||
| } from "./acl.js"; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
@@ -39,6 +40,7 @@ export function queryFiles( | |||||||||||||||||||||||||
| storage: StorageAdapter, | ||||||||||||||||||||||||||
| options: QueryOptions, | ||||||||||||||||||||||||||
| claims: TokenClaims | null, | ||||||||||||||||||||||||||
| aclOptions: PermissionEvaluationOptions = {}, | ||||||||||||||||||||||||||
| ): Paginated<QueryResultItem> { | ||||||||||||||||||||||||||
| const base = normalizePath(options.path ?? "/"); | ||||||||||||||||||||||||||
| const provider = normalizeProvider(options.provider); | ||||||||||||||||||||||||||
|
|
@@ -84,7 +86,13 @@ export function queryFiles( | |||||||||||||||||||||||||
| if (!propertiesMatch(semantics.properties, expectedProperties)) { | ||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| if (!filePermissionAllows(effectivePermissions, workspaceId, claims)) { | ||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||
| !filePermissionAllows(effectivePermissions, workspaceId, claims, { | ||||||||||||||||||||||||||
| ...aclOptions, | ||||||||||||||||||||||||||
| action: aclOptions.action ?? "read", | ||||||||||||||||||||||||||
| requestedPath: row.path, | ||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||
|
Comment on lines
+89
to
+94
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not let query authorization switch off
Suggested fix {
...aclOptions,
- action: aclOptions.action ?? "read",
+ action: "read",
requestedPath: row.path,
})📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -13,6 +13,7 @@ import type { StorageAdapter, PaginationOptions } from "./storage.js"; | |||||||||||||||||||||
| import { | ||||||||||||||||||||||
| filePermissionAllows, | ||||||||||||||||||||||
| resolveFilePermissions, | ||||||||||||||||||||||
| type PermissionEvaluationOptions, | ||||||||||||||||||||||
| type TokenClaims, | ||||||||||||||||||||||
| } from "./acl.js"; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -43,6 +44,7 @@ export function listTree( | |||||||||||||||||||||
| storage: StorageAdapter, | ||||||||||||||||||||||
| options: ListTreeOptions, | ||||||||||||||||||||||
| claims: TokenClaims | null, | ||||||||||||||||||||||
| aclOptions: PermissionEvaluationOptions = {}, | ||||||||||||||||||||||
| ): TreeResult { | ||||||||||||||||||||||
| const base = normalizePath(options.path ?? "/"); | ||||||||||||||||||||||
| const maxDepth = options.depth && options.depth > 0 ? options.depth : 1; | ||||||||||||||||||||||
|
|
@@ -59,6 +61,11 @@ export function listTree( | |||||||||||||||||||||
| resolveFilePermissions(storage, filePath, true), | ||||||||||||||||||||||
| workspaceId, | ||||||||||||||||||||||
| claims, | ||||||||||||||||||||||
| { | ||||||||||||||||||||||
| ...aclOptions, | ||||||||||||||||||||||
| action: aclOptions.action ?? "read", | ||||||||||||||||||||||
| requestedPath: filePath, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
|
Comment on lines
+64
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Force
Suggested fix {
...aclOptions,
- action: aclOptions.action ?? "read",
+ action: "read",
requestedPath: filePath,
},📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| ) { | ||||||||||||||||||||||
| continue; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Export ACL checks should always run as
"read".This is the content-export path, so honoring a caller-provided
aclOptions.actioncan authorize exports against the wrong action.exportWorkspaceJson()should forceaction: "read"and only varyrequestedPath.Suggested fix
{ ...aclOptions, - action: aclOptions.action ?? "read", + action: "read", requestedPath: row.path, },📝 Committable suggestion
🤖 Prompt for AI Agents