From f998b16a3f6b11ea6a397faa038e2bc59189f702 Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Sun, 29 Mar 2026 18:04:24 -0500 Subject: [PATCH] Add traverse access level for folder navigation without file access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a permission is set on a nested folder, traverse is automatically granted on all ancestor folders so users can navigate to it. Traverse only shows subfolders in listings — files are hidden. This prevents orphaned permissions where a user has access to a nested folder but can't reach it. Hierarchy: traverse < view < edit < admin --- .../storage/folder-permissions-dialog.tsx | 1 + packages/admin/src/types/storage.ts | 4 +- .../0024_add_traverse_access_level.sql | 3 ++ .../src/db/migrations/meta/_journal.json | 7 ++++ packages/backend/src/db/schema/storage.ts | 2 +- packages/backend/src/routes/v1/storage.ts | 7 ++-- packages/backend/src/routes/webdav/index.ts | 18 ++++---- .../backend/src/services/storage.service.ts | 42 ++++++++++++++++++- .../backend/src/services/webdav.service.ts | 6 +-- 9 files changed, 72 insertions(+), 18 deletions(-) create mode 100644 packages/backend/src/db/migrations/0024_add_traverse_access_level.sql diff --git a/packages/admin/src/components/storage/folder-permissions-dialog.tsx b/packages/admin/src/components/storage/folder-permissions-dialog.tsx index 095a61a..bfb3cd1 100644 --- a/packages/admin/src/components/storage/folder-permissions-dialog.tsx +++ b/packages/admin/src/components/storage/folder-permissions-dialog.tsx @@ -28,6 +28,7 @@ interface FolderPermissionsDialogProps { } const ACCESS_LEVELS = [ + { value: 'traverse', label: 'Traverse', variant: 'outline' as const }, { value: 'view', label: 'View', variant: 'secondary' as const }, { value: 'edit', label: 'Edit', variant: 'default' as const }, { value: 'admin', label: 'Admin', variant: 'destructive' as const }, diff --git a/packages/admin/src/types/storage.ts b/packages/admin/src/types/storage.ts index 8543966..ea6eae7 100644 --- a/packages/admin/src/types/storage.ts +++ b/packages/admin/src/types/storage.ts @@ -8,7 +8,7 @@ export interface StorageFolder { createdAt: string updatedAt: string breadcrumbs?: { id: string; name: string }[] - accessLevel?: 'view' | 'edit' | 'admin' | null + accessLevel?: 'traverse' | 'view' | 'edit' | 'admin' | null } export interface StorageFolderPermission { @@ -16,7 +16,7 @@ export interface StorageFolderPermission { folderId: string roleId: string | null userId: string | null - accessLevel: 'view' | 'edit' | 'admin' + accessLevel: 'traverse' | 'view' | 'edit' | 'admin' createdAt: string } diff --git a/packages/backend/src/db/migrations/0024_add_traverse_access_level.sql b/packages/backend/src/db/migrations/0024_add_traverse_access_level.sql new file mode 100644 index 0000000..9d8a741 --- /dev/null +++ b/packages/backend/src/db/migrations/0024_add_traverse_access_level.sql @@ -0,0 +1,3 @@ +-- Add 'traverse' access level to storage_folder_access enum +-- traverse: allows folder to appear in listings but does not grant file access +ALTER TYPE storage_folder_access ADD VALUE IF NOT EXISTS 'traverse' BEFORE 'view'; diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index fe711bd..2388a3c 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1774830000000, "tag": "0023_store_settings", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1774840000000, + "tag": "0024_add_traverse_access_level", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/storage.ts b/packages/backend/src/db/schema/storage.ts index d123c4e..b70bc57 100644 --- a/packages/backend/src/db/schema/storage.ts +++ b/packages/backend/src/db/schema/storage.ts @@ -11,7 +11,7 @@ import { import { users } from './users.js' import { roles } from './rbac.js' -export const storageFolderAccessEnum = pgEnum('storage_folder_access', ['view', 'edit', 'admin']) +export const storageFolderAccessEnum = pgEnum('storage_folder_access', ['traverse', 'view', 'edit', 'admin']) export const storageFolders = pgTable('storage_folder', { id: uuid('id').primaryKey().defaultRandom(), diff --git a/packages/backend/src/routes/v1/storage.ts b/packages/backend/src/routes/v1/storage.ts index 7d3cfd4..1d96ba2 100644 --- a/packages/backend/src/routes/v1/storage.ts +++ b/packages/backend/src/routes/v1/storage.ts @@ -97,7 +97,7 @@ export const storageRoutes: FastifyPluginAsync = async (app) => { } if (!roleId && !userId) throw new ValidationError('Either roleId or userId is required') - if (!accessLevel || !['view', 'edit', 'admin'].includes(accessLevel)) throw new ValidationError('accessLevel must be view, edit, or admin') + if (!accessLevel || !['traverse', 'view', 'edit', 'admin'].includes(accessLevel)) throw new ValidationError('accessLevel must be traverse, view, edit, or admin') const perm = await StoragePermissionService.setPermission(app.db, id, roleId, userId, accessLevel) return reply.status(201).send(perm) @@ -139,8 +139,9 @@ export const storageRoutes: FastifyPluginAsync = async (app) => { app.get('/storage/folders/:folderId/files', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => { const { folderId } = request.params as { folderId: string } - const hasAccess = await StoragePermissionService.canAccess(app.db, folderId, request.user.id) - if (!hasAccess) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } }) + // traverse only allows navigating folders, not viewing files + const accessLevel = await StoragePermissionService.getAccessLevel(app.db, folderId, request.user.id) + if (!accessLevel || accessLevel === 'traverse') return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } }) const params = PaginationSchema.parse(request.query) const result = await StorageFileService.listByFolder(app.db, folderId, params) diff --git a/packages/backend/src/routes/webdav/index.ts b/packages/backend/src/routes/webdav/index.ts index cf4b3bc..eb7c5f0 100644 --- a/packages/backend/src/routes/webdav/index.ts +++ b/packages/backend/src/routes/webdav/index.ts @@ -88,9 +88,9 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { resources.push(...children) } } else if (resolved.type === 'folder' && resolved.folder) { - // Check access - const hasAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id) - if (!hasAccess) return reply.status(403).send('Access Denied') + // Check access — traverse or higher lets you see the folder + const accessLevel = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id) + if (!accessLevel) return reply.status(403).send('Access Denied') resources.push({ href: basePath.slice(0, -1) + '/', @@ -100,12 +100,14 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { createdAt: resolved.folder.createdAt ? new Date(resolved.folder.createdAt) : undefined, }) if (depth !== '0') { - const children = await WebDavService.listChildren(app.db, resolved.folder.id, basePath) + // traverse: show subfolders only, no files. view+: show everything + const children = await WebDavService.listChildren(app.db, resolved.folder.id, basePath, accessLevel === 'traverse') resources.push(...children) } } else if (resolved.type === 'file' && resolved.file && resolved.folder) { - const hasAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id) - if (!hasAccess) return reply.status(403).send('Access Denied') + // File access requires at least view (not traverse) + const accessLevel = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id) + if (!accessLevel || accessLevel === 'traverse') return reply.status(403).send('Access Denied') resources.push({ href: `${WEBDAV_PREFIX}${resourcePath}`, @@ -159,8 +161,8 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => { const resolved = await WebDavService.resolvePath(app.db, resourcePath) if (resolved.type === 'file' && resolved.file && resolved.folder) { - const hasAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id) - if (!hasAccess) return reply.status(403).send('Access Denied') + const accessLevel = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id) + if (!accessLevel || accessLevel === 'traverse') return reply.status(403).send('Access Denied') const data = await app.storage.get(resolved.file.path) return reply diff --git a/packages/backend/src/services/storage.service.ts b/packages/backend/src/services/storage.service.ts index c3e2aee..9b97614 100644 --- a/packages/backend/src/services/storage.service.ts +++ b/packages/backend/src/services/storage.service.ts @@ -54,7 +54,7 @@ export const StoragePermissionService = { return false }, - async getAccessLevel(db: PostgresJsDatabase, folderId: string, userId: string): Promise<'admin' | 'edit' | 'view' | null> { + async getAccessLevel(db: PostgresJsDatabase, folderId: string, userId: string): Promise<'admin' | 'edit' | 'view' | 'traverse' | null> { const [folder] = await db.select({ isPublic: storageFolders.isPublic, createdBy: storageFolders.createdBy }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1) if (!folder) return null @@ -108,9 +108,49 @@ export const StoragePermissionService = { userId: userId ?? null, accessLevel: accessLevel as any, }).returning() + + // Auto-grant 'traverse' up the ancestor chain so the user can navigate to this folder + if (accessLevel !== 'traverse') { + await this.ensureTraverseAncestors(db, folderId, roleId, userId) + } + return perm }, + /** + * Ensure all ancestor folders have at least 'traverse' access for this role/user. + * Never upgrades — only adds traverse where there's no existing permission. + */ + async ensureTraverseAncestors(db: PostgresJsDatabase, folderId: string, roleId: string | undefined, userId: string | undefined) { + const [folder] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1) + if (!folder?.parentId) return + + let currentParentId: string | null = folder.parentId + while (currentParentId) { + // Check if this role/user already has any permission on this ancestor + const whereClause = roleId + ? and(eq(storageFolderPermissions.folderId, currentParentId), eq(storageFolderPermissions.roleId, roleId)) + : and(eq(storageFolderPermissions.folderId, currentParentId), eq(storageFolderPermissions.userId, userId!)) + + const [existing] = await db.select({ id: storageFolderPermissions.id }).from(storageFolderPermissions).where(whereClause).limit(1) + + if (!existing) { + // No permission — add traverse + await db.insert(storageFolderPermissions).values({ + folderId: currentParentId, + roleId: roleId ?? null, + userId: userId ?? null, + accessLevel: 'traverse' as any, + }) + } + // If they already have any permission (even traverse), don't touch it + + // Walk up + const [parent] = await db.select({ parentId: storageFolders.parentId }).from(storageFolders).where(eq(storageFolders.id, currentParentId)).limit(1) + currentParentId = parent?.parentId ?? null + } + }, + async removePermission(db: PostgresJsDatabase, permissionId: string) { const [perm] = await db.delete(storageFolderPermissions).where(eq(storageFolderPermissions.id, permissionId)).returning() return perm ?? null diff --git a/packages/backend/src/services/webdav.service.ts b/packages/backend/src/services/webdav.service.ts index 3cd5bd3..b8b56d2 100644 --- a/packages/backend/src/services/webdav.service.ts +++ b/packages/backend/src/services/webdav.service.ts @@ -66,7 +66,7 @@ export const WebDavService = { /** * List children of a folder (or root) as DAV resources. */ - async listChildren(db: PostgresJsDatabase, folderId: string | null, basePath: string): Promise { + async listChildren(db: PostgresJsDatabase, folderId: string | null, basePath: string, foldersOnly = false): Promise { const resources: DavResource[] = [] // Sub-folders @@ -85,8 +85,8 @@ export const WebDavService = { }) } - // Files (only if we're inside a folder, not root) - if (folderId) { + // Files (only if we're inside a folder, not root, and not traverse-only) + if (folderId && !foldersOnly) { const files = await db.select().from(storageFiles) .where(eq(storageFiles.folderId, folderId)) .orderBy(storageFiles.filename)