Add traverse access level for folder navigation without file access
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
This commit is contained in:
@@ -28,6 +28,7 @@ interface FolderPermissionsDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ACCESS_LEVELS = [
|
const ACCESS_LEVELS = [
|
||||||
|
{ value: 'traverse', label: 'Traverse', variant: 'outline' as const },
|
||||||
{ value: 'view', label: 'View', variant: 'secondary' as const },
|
{ value: 'view', label: 'View', variant: 'secondary' as const },
|
||||||
{ value: 'edit', label: 'Edit', variant: 'default' as const },
|
{ value: 'edit', label: 'Edit', variant: 'default' as const },
|
||||||
{ value: 'admin', label: 'Admin', variant: 'destructive' as const },
|
{ value: 'admin', label: 'Admin', variant: 'destructive' as const },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface StorageFolder {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
breadcrumbs?: { id: string; name: string }[]
|
breadcrumbs?: { id: string; name: string }[]
|
||||||
accessLevel?: 'view' | 'edit' | 'admin' | null
|
accessLevel?: 'traverse' | 'view' | 'edit' | 'admin' | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StorageFolderPermission {
|
export interface StorageFolderPermission {
|
||||||
@@ -16,7 +16,7 @@ export interface StorageFolderPermission {
|
|||||||
folderId: string
|
folderId: string
|
||||||
roleId: string | null
|
roleId: string | null
|
||||||
userId: string | null
|
userId: string | null
|
||||||
accessLevel: 'view' | 'edit' | 'admin'
|
accessLevel: 'traverse' | 'view' | 'edit' | 'admin'
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -169,6 +169,13 @@
|
|||||||
"when": 1774830000000,
|
"when": 1774830000000,
|
||||||
"tag": "0023_store_settings",
|
"tag": "0023_store_settings",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774840000000,
|
||||||
|
"tag": "0024_add_traverse_access_level",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { users } from './users.js'
|
import { users } from './users.js'
|
||||||
import { roles } from './rbac.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', {
|
export const storageFolders = pgTable('storage_folder', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export const storageRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!roleId && !userId) throw new ValidationError('Either roleId or userId is required')
|
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)
|
const perm = await StoragePermissionService.setPermission(app.db, id, roleId, userId, accessLevel)
|
||||||
return reply.status(201).send(perm)
|
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) => {
|
app.get('/storage/folders/:folderId/files', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
|
||||||
const { folderId } = request.params as { folderId: string }
|
const { folderId } = request.params as { folderId: string }
|
||||||
|
|
||||||
const hasAccess = await StoragePermissionService.canAccess(app.db, folderId, request.user.id)
|
// traverse only allows navigating folders, not viewing files
|
||||||
if (!hasAccess) return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
|
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 params = PaginationSchema.parse(request.query)
|
||||||
const result = await StorageFileService.listByFolder(app.db, folderId, params)
|
const result = await StorageFileService.listByFolder(app.db, folderId, params)
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
resources.push(...children)
|
resources.push(...children)
|
||||||
}
|
}
|
||||||
} else if (resolved.type === 'folder' && resolved.folder) {
|
} else if (resolved.type === 'folder' && resolved.folder) {
|
||||||
// Check access
|
// Check access — traverse or higher lets you see the folder
|
||||||
const hasAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id)
|
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
|
||||||
if (!hasAccess) return reply.status(403).send('Access Denied')
|
if (!accessLevel) return reply.status(403).send('Access Denied')
|
||||||
|
|
||||||
resources.push({
|
resources.push({
|
||||||
href: basePath.slice(0, -1) + '/',
|
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,
|
createdAt: resolved.folder.createdAt ? new Date(resolved.folder.createdAt) : undefined,
|
||||||
})
|
})
|
||||||
if (depth !== '0') {
|
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)
|
resources.push(...children)
|
||||||
}
|
}
|
||||||
} else if (resolved.type === 'file' && resolved.file && resolved.folder) {
|
} else if (resolved.type === 'file' && resolved.file && resolved.folder) {
|
||||||
const hasAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id)
|
// File access requires at least view (not traverse)
|
||||||
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')
|
||||||
|
|
||||||
resources.push({
|
resources.push({
|
||||||
href: `${WEBDAV_PREFIX}${resourcePath}`,
|
href: `${WEBDAV_PREFIX}${resourcePath}`,
|
||||||
@@ -159,8 +161,8 @@ export const webdavRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
|
const resolved = await WebDavService.resolvePath(app.db, resourcePath)
|
||||||
|
|
||||||
if (resolved.type === 'file' && resolved.file && resolved.folder) {
|
if (resolved.type === 'file' && resolved.file && resolved.folder) {
|
||||||
const hasAccess = await StoragePermissionService.canAccess(app.db, resolved.folder.id, request.user.id)
|
const accessLevel = await StoragePermissionService.getAccessLevel(app.db, resolved.folder.id, request.user.id)
|
||||||
if (!hasAccess) return reply.status(403).send('Access Denied')
|
if (!accessLevel || accessLevel === 'traverse') return reply.status(403).send('Access Denied')
|
||||||
|
|
||||||
const data = await app.storage.get(resolved.file.path)
|
const data = await app.storage.get(resolved.file.path)
|
||||||
return reply
|
return reply
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const StoragePermissionService = {
|
|||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAccessLevel(db: PostgresJsDatabase<any>, folderId: string, userId: string): Promise<'admin' | 'edit' | 'view' | null> {
|
async getAccessLevel(db: PostgresJsDatabase<any>, 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)
|
const [folder] = await db.select({ isPublic: storageFolders.isPublic, createdBy: storageFolders.createdBy }).from(storageFolders).where(eq(storageFolders.id, folderId)).limit(1)
|
||||||
if (!folder) return null
|
if (!folder) return null
|
||||||
|
|
||||||
@@ -108,9 +108,49 @@ export const StoragePermissionService = {
|
|||||||
userId: userId ?? null,
|
userId: userId ?? null,
|
||||||
accessLevel: accessLevel as any,
|
accessLevel: accessLevel as any,
|
||||||
}).returning()
|
}).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
|
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<any>, 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<any>, permissionId: string) {
|
async removePermission(db: PostgresJsDatabase<any>, permissionId: string) {
|
||||||
const [perm] = await db.delete(storageFolderPermissions).where(eq(storageFolderPermissions.id, permissionId)).returning()
|
const [perm] = await db.delete(storageFolderPermissions).where(eq(storageFolderPermissions.id, permissionId)).returning()
|
||||||
return perm ?? null
|
return perm ?? null
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const WebDavService = {
|
|||||||
/**
|
/**
|
||||||
* List children of a folder (or root) as DAV resources.
|
* List children of a folder (or root) as DAV resources.
|
||||||
*/
|
*/
|
||||||
async listChildren(db: PostgresJsDatabase<any>, folderId: string | null, basePath: string): Promise<DavResource[]> {
|
async listChildren(db: PostgresJsDatabase<any>, folderId: string | null, basePath: string, foldersOnly = false): Promise<DavResource[]> {
|
||||||
const resources: DavResource[] = []
|
const resources: DavResource[] = []
|
||||||
|
|
||||||
// Sub-folders
|
// Sub-folders
|
||||||
@@ -85,8 +85,8 @@ export const WebDavService = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files (only if we're inside a folder, not root)
|
// Files (only if we're inside a folder, not root, and not traverse-only)
|
||||||
if (folderId) {
|
if (folderId && !foldersOnly) {
|
||||||
const files = await db.select().from(storageFiles)
|
const files = await db.select().from(storageFiles)
|
||||||
.where(eq(storageFiles.folderId, folderId))
|
.where(eq(storageFiles.folderId, folderId))
|
||||||
.orderBy(storageFiles.filename)
|
.orderBy(storageFiles.filename)
|
||||||
|
|||||||
Reference in New Issue
Block a user