From edb5d5f4a9f5829fbecca51df79cc9c2ac501d0c Mon Sep 17 00:00:00 2001 From: dswbx Date: Sun, 20 Apr 2025 09:53:11 +0200 Subject: [PATCH] added media permissions (#142) * added permissions support for media module introduced `MediaPermissions` for fine-grained access control in the media module, updated routes to enforce these permissions, and adjusted permission registration logic. * fix: handle token absence in getUploadHeaders and add tests for transport modes ensure getUploadHeaders does not set Authorization header when token is missing. Add unit tests to validate behavior for different token_transport options. * remove console.log on DropzoneContainer.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/__test__/api/MediaApi.spec.ts | 18 ++++++++++++++++ app/package.json | 2 +- app/src/auth/AppAuth.ts | 2 +- app/src/auth/authorize/Guard.ts | 21 ++++++++++++------- app/src/media/AppMedia.ts | 7 ++++--- app/src/media/api/MediaApi.ts | 9 +++++--- app/src/media/api/MediaController.ts | 14 +++++++------ app/src/media/index.ts | 2 +- app/src/media/media-permissions.ts | 6 ++++++ .../ui/elements/media/DropzoneContainer.tsx | 1 - 10 files changed, 59 insertions(+), 23 deletions(-) create mode 100644 app/src/media/media-permissions.ts diff --git a/app/__test__/api/MediaApi.spec.ts b/app/__test__/api/MediaApi.spec.ts index a479d19f..fffca386 100644 --- a/app/__test__/api/MediaApi.spec.ts +++ b/app/__test__/api/MediaApi.spec.ts @@ -39,10 +39,28 @@ describe("MediaApi", () => { // @ts-ignore tests const api = new MediaApi({ token: "token", + token_transport: "header", }); expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token"); }); + it("should return empty headers if not using `header` transport", () => { + expect( + new MediaApi({ + token_transport: "cookie", + }) + .getUploadHeaders() + .has("Authorization"), + ).toBe(false); + expect( + new MediaApi({ + token_transport: "none", + }) + .getUploadHeaders() + .has("Authorization"), + ).toBe(false); + }); + it("should get file: native", async () => { const name = "image.png"; const path = `${assetsTmpPath}/${name}`; diff --git a/app/package.json b/app/package.json index accd53c9..5fc96043 100644 --- a/app/package.json +++ b/app/package.json @@ -14,7 +14,7 @@ "url": "https://github.com/bknd-io/bknd/issues" }, "scripts": { - "dev": "vite", + "dev": "BKND_CLI_LOG_LEVEL=debug vite", "build": "NODE_ENV=production bun run build.ts --minify --types", "build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", "build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts", diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 95d87312..17ca1fc0 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -90,7 +90,7 @@ export class AppAuth extends Module { this._controller = new AuthController(this); this.ctx.server.route(this.config.basepath, this._controller.getController()); - this.ctx.guard.registerPermissions(Object.values(AuthPermissions)); + this.ctx.guard.registerPermissions(AuthPermissions); } isStrategyEnabled(strategy: Strategy | string) { diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 96e6e1ba..a45c160d 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -81,8 +81,12 @@ export class Guard { return this; } - registerPermissions(permissions: Permission[]) { - for (const permission of permissions) { + registerPermissions(permissions: Record); + registerPermissions(permissions: Permission[]); + registerPermissions(permissions: Permission[] | Record) { + const p = Array.isArray(permissions) ? permissions : Object.values(permissions); + + for (const permission of p) { this.registerPermission(permission); } @@ -93,14 +97,13 @@ export class Guard { if (user && typeof user.role === "string") { const role = this.roles?.find((role) => role.name === user?.role); if (role) { - $console.debug("guard: role found", [user.role]); + $console.debug(`guard: role "${user.role}" found`); return role; } } $console.debug("guard: role not found", { - user: user, - role: user?.role, + user, }); return this.getDefaultRole(); } @@ -121,6 +124,10 @@ export class Guard { } const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name; + $console.debug("guard: checking permission", { + name, + user: { id: user?.id, role: user?.role }, + }); const exists = this.permissionExists(name); if (!exists) { throw new Error(`Permission ${name} does not exist`); @@ -129,10 +136,10 @@ export class Guard { const role = this.getUserRole(user); if (!role) { - $console.debug("guard: role not found, denying"); + $console.debug("guard: user has no role, denying"); return false; } else if (role.implicit_allow === true) { - $console.debug("guard: role implicit allow, allowing"); + $console.debug(`guard: role "${role.name}" has implicit allow, allowing`); return true; } diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index aa53a89c..5d5bd351 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -1,6 +1,6 @@ import { $console, type PrimaryFieldType } from "core"; -import { type Entity, type EntityManager } from "data"; -import { type FileUploadedEventData, Storage, type StorageAdapter } from "media"; +import type { Entity, EntityManager } from "data"; +import { type FileUploadedEventData, Storage, type StorageAdapter, MediaPermissions } from "media"; import { Module } from "modules/Module"; import { type FieldSchema, @@ -13,7 +13,7 @@ import { text, } from "../data/prototype"; import { MediaController } from "./api/MediaController"; -import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; +import { buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; export type MediaFieldSchema = FieldSchema; declare module "core" { @@ -46,6 +46,7 @@ export class AppMedia extends Module { this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr); this.setBuilt(); this.setupListeners(); + this.ctx.guard.registerPermissions(MediaPermissions); this.ctx.server.route(this.basepath, new MediaController(this).getController()); const media = this.getMediaEntity(true); diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts index 2c3bc7e1..2b2eaea8 100644 --- a/app/src/media/api/MediaApi.ts +++ b/app/src/media/api/MediaApi.ts @@ -54,9 +54,12 @@ export class MediaApi extends ModuleApi { } getUploadHeaders(): Headers { - return new Headers({ - Authorization: `Bearer ${this.options.token}`, - }); + if (this.options.token_transport === "header" && this.options.token) { + return new Headers({ + Authorization: `Bearer ${this.options.token}`, + }); + } + return new Headers(); } protected uploadFile( diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 7ca6db9f..4783be98 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -1,7 +1,8 @@ import { isDebug, tbValidator as tb } from "core"; import { HttpStatus, getFileFromContext } from "core/utils"; import type { StorageAdapter } from "media"; -import { StorageEvents, getRandomizedFilename } from "media"; +import { StorageEvents, getRandomizedFilename, MediaPermissions } from "media"; +import { DataPermissions } from "data"; import { Controller } from "modules/Controller"; import type { AppMedia } from "../AppMedia"; import { MediaField } from "../MediaField"; @@ -28,18 +29,18 @@ export class MediaController extends Controller { override getController() { // @todo: multiple providers? // @todo: implement range requests - const { auth } = this.middlewares; + const { auth, permission } = this.middlewares; const hono = this.create().use(auth()); // get files list (temporary) - hono.get("/files", async (c) => { + hono.get("/files", permission(MediaPermissions.listFiles), async (c) => { const files = await this.getStorageAdapter().listObjects(); return c.json(files); }); // get file by name // @todo: implement more aggressive cache? (configurable) - hono.get("/file/:filename", async (c) => { + hono.get("/file/:filename", permission(MediaPermissions.readFile), async (c) => { const { filename } = c.req.param(); if (!filename) { throw new Error("No file name provided"); @@ -59,7 +60,7 @@ export class MediaController extends Controller { }); // delete a file by name - hono.delete("/file/:filename", async (c) => { + hono.delete("/file/:filename", permission(MediaPermissions.deleteFile), async (c) => { const { filename } = c.req.param(); if (!filename) { throw new Error("No file name provided"); @@ -84,7 +85,7 @@ export class MediaController extends Controller { // upload file // @todo: add required type for "upload endpoints" - hono.post("/upload/:filename?", async (c) => { + hono.post("/upload/:filename?", permission(MediaPermissions.uploadFile), async (c) => { const reqname = c.req.param("filename"); const body = await getFileFromContext(c); @@ -114,6 +115,7 @@ export class MediaController extends Controller { overwrite: Type.Optional(booleanLike), }), ), + permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]), async (c) => { const entity_name = c.req.param("entity"); const field_name = c.req.param("field"); diff --git a/app/src/media/index.ts b/app/src/media/index.ts index a49a4f9f..d9451ad5 100644 --- a/app/src/media/index.ts +++ b/app/src/media/index.ts @@ -1,7 +1,6 @@ import type { TObject } from "@sinclair/typebox"; import { type Constructor, Registry } from "core"; -//export { MIME_TYPES } from "./storage/mime-types"; export { guess as guessMimeType } from "./storage/mime-types-tiny"; export { Storage, @@ -22,6 +21,7 @@ export { StorageAdapter }; export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig }; export * as StorageEvents from "./storage/events"; +export * as MediaPermissions from "./media-permissions"; export type { FileUploadedEventData } from "./storage/events"; export * from "./utils"; diff --git a/app/src/media/media-permissions.ts b/app/src/media/media-permissions.ts new file mode 100644 index 00000000..714cc2df --- /dev/null +++ b/app/src/media/media-permissions.ts @@ -0,0 +1,6 @@ +import { Permission } from "core"; + +export const readFile = new Permission("media.file.read"); +export const listFiles = new Permission("media.file.list"); +export const uploadFile = new Permission("media.file.upload"); +export const deleteFile = new Permission("media.file.delete"); diff --git a/app/src/ui/elements/media/DropzoneContainer.tsx b/app/src/ui/elements/media/DropzoneContainer.tsx index 5b724c34..73e29a66 100644 --- a/app/src/ui/elements/media/DropzoneContainer.tsx +++ b/app/src/ui/elements/media/DropzoneContainer.tsx @@ -54,7 +54,6 @@ export function DropzoneContainer({ sort: "-id", }); const entity_name = (media?.entity_name ?? "media") as "media"; - //console.log("dropzone:baseUrl", baseUrl); const selectApi = (api: Api, page: number = 0) => entity