feat: implement comprehensive access control system

- Add authorizedGet function for secure resource access
- Implement ownership verification for all file/directory operations
- Use security through obscurity (not found vs access denied)
- Optimize bulk operations by removing redundant authorization checks
- Move generateFileUrl to filesystem.ts as fetchFileUrl with proper auth
- Ensure all database access goes through authorization layer

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2025-10-16 21:43:23 +00:00
parent b802cb5aec
commit 83a5f92506
7 changed files with 99 additions and 28 deletions

View File

@@ -1,26 +1,16 @@
import { v } from "convex/values"
import type { Id } from "./_generated/dataModel"
import { authenticatedMutation, authenticatedQuery } from "./functions"
import { authenticatedMutation, authenticatedQuery, authorizedGet } from "./functions"
import * as Directories from "./model/directories"
import * as Files from "./model/files"
import type { FileSystemItem } from "./model/filesystem"
export const generateUploadUrl = authenticatedMutation({
handler: async (ctx) => {
// ctx.user and ctx.identity are automatically available
return await ctx.storage.generateUploadUrl()
},
})
export const generateFileUrl = authenticatedQuery({
args: {
storageId: v.id("_storage"),
},
handler: async (ctx, { storageId }) => {
return await ctx.storage.getUrl(storageId)
},
})
export const fetchFiles = authenticatedQuery({
args: {
directoryId: v.optional(v.id("directories")),
@@ -46,6 +36,10 @@ export const fetchDirectory = authenticatedQuery({
directoryId: v.id("directories"),
},
handler: async (ctx, { directoryId }) => {
const directory = await authorizedGet(ctx, directoryId)
if (!directory) {
throw new Error("Directory not found")
}
return await Directories.fetch(ctx, { directoryId })
},
})
@@ -56,6 +50,11 @@ export const createDirectory = authenticatedMutation({
directoryId: v.id("directories"),
},
handler: async (ctx, { name, directoryId }): Promise<Id<"directories">> => {
const parentDirectory = await authorizedGet(ctx, directoryId)
if (!parentDirectory) {
throw new Error("Parent directory not found")
}
return await Directories.create(ctx, {
name,
parentId: directoryId,
@@ -72,6 +71,11 @@ export const saveFile = authenticatedMutation({
mimeType: v.optional(v.string()),
},
handler: async (ctx, { name, storageId, directoryId, size, mimeType }) => {
const directory = await authorizedGet(ctx, directoryId)
if (!directory) {
throw new Error("Directory not found")
}
const now = Date.now()
await ctx.db.insert("files", {
@@ -94,6 +98,11 @@ export const renameFile = authenticatedMutation({
newName: v.string(),
},
handler: async (ctx, { directoryId, itemId, newName }) => {
const file = await authorizedGet(ctx, itemId)
if (!file) {
throw new Error("File not found")
}
await Files.renameFile(ctx, { directoryId, itemId, newName })
},
})

View File

@@ -1,5 +1,5 @@
import { v } from "convex/values"
import { authenticatedMutation, authenticatedQuery } from "./functions"
import { authenticatedMutation, authenticatedQuery, authorizedGet } from "./functions"
import * as Directories from "./model/directories"
import * as Err from "./model/error"
import * as Files from "./model/files"
@@ -22,10 +22,7 @@ export const moveItems = authenticatedMutation({
items: v.array(VFileSystemHandle),
},
handler: async (ctx, { targetDirectory: targetDirectoryHandle, items }) => {
const targetDirectory = await Directories.fetchHandle(
ctx,
targetDirectoryHandle,
)
const targetDirectory = await authorizedGet(ctx, targetDirectoryHandle.id)
if (!targetDirectory) {
throw Err.create(
Err.Code.DirectoryNotFound,
@@ -69,6 +66,16 @@ export const moveToTrash = authenticatedMutation({
handles: v.array(VFileSystemHandle),
},
handler: async (ctx, { handles }) => {
for (const handle of handles) {
const item = await authorizedGet(ctx, handle.id)
if (!item) {
throw Err.create(
Err.Code.NotFound,
`Item ${handle.id} not found`,
)
}
}
// biome-ignore lint/suspicious/useIterableCallbackReturn: switch statement is exhaustive
const promises = handles.map((handle) => {
switch (handle.kind) {
@@ -142,3 +149,17 @@ export const restoreItems = authenticatedMutation({
return await FileSystem.restoreItems(ctx, { handles })
},
})
export const fetchFileUrl = authenticatedQuery({
args: {
fileId: v.id("files"),
},
handler: async (ctx, { fileId }) => {
const file = await authorizedGet(ctx, fileId)
if (!file) {
throw Err.create(Err.Code.NotFound, "File not found")
}
return await FileSystem.fetchFileUrl(ctx, { fileId })
},
})

View File

@@ -1,9 +1,15 @@
import type { UserIdentity } from "convex/server"
import type {
DocumentByName,
TableNamesInDataModel,
UserIdentity,
} from "convex/server"
import type { GenericId } from "convex/values"
import {
customCtx,
customMutation,
customQuery,
} from "convex-helpers/server/customFunctions"
import type { DataModel } from "./_generated/dataModel"
import type { MutationCtx, QueryCtx } from "./_generated/server"
import { mutation, query } from "./_generated/server"
import { type AuthUser, userIdentityOrThrow, userOrThrow } from "./model/user"
@@ -43,3 +49,19 @@ export const authenticatedMutation = customMutation(
return { user, identity }
}),
)
/**
* Gets a document by its id and checks if the user is authorized to access it
*
* @returns The document associated with the id or null if the document is not found.
*/
export async function authorizedGet<T extends TableNamesInDataModel<DataModel>>(
ctx: AuthenticatedQueryCtx | AuthenticatedMutationCtx,
id: GenericId<T>,
): Promise<DocumentByName<DataModel, T> | null> {
const item = await ctx.db.get(id)
if (item && item.userId !== ctx.user._id) {
return null
}
return item
}

View File

@@ -3,6 +3,7 @@ import type {
AuthenticatedMutationCtx,
AuthenticatedQueryCtx,
} from "../functions"
import { authorizedGet } from "../functions"
import * as Err from "./error"
import {
type DirectoryHandle,
@@ -27,8 +28,8 @@ export async function fetchHandle(
ctx: AuthenticatedQueryCtx,
handle: DirectoryHandle,
): Promise<Doc<"directories">> {
const directory = await ctx.db.get(handle.id)
if (!directory || directory.userId !== ctx.user._id) {
const directory = await authorizedGet(ctx, handle.id)
if (!directory) {
throw Err.create(
Err.Code.DirectoryNotFound,
`Directory ${handle.id} not found`,
@@ -41,7 +42,7 @@ export async function fetch(
ctx: AuthenticatedQueryCtx,
{ directoryId }: { directoryId: Id<"directories"> },
): Promise<DirectoryInfo> {
const directory = await ctx.db.get(directoryId)
const directory = await authorizedGet(ctx, directoryId)
if (!directory) {
throw Err.create(
Err.Code.DirectoryNotFound,
@@ -57,7 +58,7 @@ export async function fetch(
]
let parentDirId = directory.parentId
while (parentDirId) {
const parentDir = await ctx.db.get(parentDirId)
const parentDir = await authorizedGet(ctx, parentDirId)
if (parentDir) {
path.push({
handle: newDirectoryHandle(parentDir._id),
@@ -65,7 +66,7 @@ export async function fetch(
})
parentDirId = parentDir.parentId
} else {
throw Err.create(Err.Code.Internal)
throw Err.create(Err.Code.DirectoryNotFound, "Parent directory not found")
}
}
@@ -132,7 +133,7 @@ export async function create(
ctx: AuthenticatedMutationCtx,
{ name, parentId }: { name: string; parentId: Id<"directories"> },
): Promise<Id<"directories">> {
const parentDir = await ctx.db.get(parentId)
const parentDir = await authorizedGet(ctx, parentId)
if (!parentDir) {
throw Err.create(
Err.Code.DirectoryNotFound,
@@ -180,7 +181,7 @@ export async function move(
) {
const conflictCheckResults = await Promise.allSettled(
sourceDirectories.map((directory) =>
ctx.db.get(directory.id).then((d) => {
authorizedGet(ctx, directory.id).then((d) => {
if (!d) {
throw Err.create(
Err.Code.DirectoryNotFound,

View File

@@ -1,5 +1,5 @@
import type { Doc, Id } from "../_generated/dataModel"
import type { AuthenticatedMutationCtx } from "../functions"
import { type AuthenticatedMutationCtx, authorizedGet } from "../functions"
import * as Err from "./error"
import type { DirectoryHandle, FileHandle } from "./filesystem"
@@ -48,7 +48,7 @@ export async function move(
) {
const conflictCheckResults = await Promise.allSettled(
items.map((fileHandle) =>
ctx.db.get(fileHandle.id).then((f) => {
authorizedGet(ctx, fileHandle.id).then((f) => {
if (!f) {
throw Err.create(
Err.Code.FileNotFound,

View File

@@ -4,6 +4,7 @@ import type {
AuthenticatedMutationCtx,
AuthenticatedQueryCtx,
} from "../functions"
import { authorizedGet } from "../functions"
import * as Directories from "./directories"
import * as Err from "./error"
import * as Files from "./files"
@@ -295,3 +296,20 @@ export async function emptyTrash(
],
})
}
export async function fetchFileUrl(
ctx: AuthenticatedQueryCtx,
{ fileId }: { fileId: Id<"files"> },
): Promise<string> {
const file = await authorizedGet(ctx, fileId)
if (!file) {
throw Err.create(Err.Code.NotFound, "file not found")
}
const url = await ctx.storage.getUrl(file.storageId)
if (!url) {
throw Err.create(Err.Code.NotFound, "file not found")
}
return url
}