mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-04 09:06:01 +00:00
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>
This commit is contained in:
@@ -39,10 +39,28 @@ describe("MediaApi", () => {
|
|||||||
// @ts-ignore tests
|
// @ts-ignore tests
|
||||||
const api = new MediaApi({
|
const api = new MediaApi({
|
||||||
token: "token",
|
token: "token",
|
||||||
|
token_transport: "header",
|
||||||
});
|
});
|
||||||
expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token");
|
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 () => {
|
it("should get file: native", async () => {
|
||||||
const name = "image.png";
|
const name = "image.png";
|
||||||
const path = `${assetsTmpPath}/${name}`;
|
const path = `${assetsTmpPath}/${name}`;
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
"url": "https://github.com/bknd-io/bknd/issues"
|
"url": "https://github.com/bknd-io/bknd/issues"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
|
||||||
"build": "NODE_ENV=production bun run build.ts --minify --types",
|
"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: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",
|
"build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
|
|
||||||
this._controller = new AuthController(this);
|
this._controller = new AuthController(this);
|
||||||
this.ctx.server.route(this.config.basepath, this._controller.getController());
|
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) {
|
isStrategyEnabled(strategy: Strategy | string) {
|
||||||
|
|||||||
@@ -81,8 +81,12 @@ export class Guard {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerPermissions(permissions: Permission[]) {
|
registerPermissions(permissions: Record<string, Permission>);
|
||||||
for (const permission of permissions) {
|
registerPermissions(permissions: Permission[]);
|
||||||
|
registerPermissions(permissions: Permission[] | Record<string, Permission>) {
|
||||||
|
const p = Array.isArray(permissions) ? permissions : Object.values(permissions);
|
||||||
|
|
||||||
|
for (const permission of p) {
|
||||||
this.registerPermission(permission);
|
this.registerPermission(permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,14 +97,13 @@ export class Guard {
|
|||||||
if (user && typeof user.role === "string") {
|
if (user && typeof user.role === "string") {
|
||||||
const role = this.roles?.find((role) => role.name === user?.role);
|
const role = this.roles?.find((role) => role.name === user?.role);
|
||||||
if (role) {
|
if (role) {
|
||||||
$console.debug("guard: role found", [user.role]);
|
$console.debug(`guard: role "${user.role}" found`);
|
||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$console.debug("guard: role not found", {
|
$console.debug("guard: role not found", {
|
||||||
user: user,
|
user,
|
||||||
role: user?.role,
|
|
||||||
});
|
});
|
||||||
return this.getDefaultRole();
|
return this.getDefaultRole();
|
||||||
}
|
}
|
||||||
@@ -121,6 +124,10 @@ export class Guard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
|
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);
|
const exists = this.permissionExists(name);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
throw new Error(`Permission ${name} does not exist`);
|
throw new Error(`Permission ${name} does not exist`);
|
||||||
@@ -129,10 +136,10 @@ export class Guard {
|
|||||||
const role = this.getUserRole(user);
|
const role = this.getUserRole(user);
|
||||||
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
$console.debug("guard: role not found, denying");
|
$console.debug("guard: user has no role, denying");
|
||||||
return false;
|
return false;
|
||||||
} else if (role.implicit_allow === true) {
|
} 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { $console, type PrimaryFieldType } from "core";
|
import { $console, type PrimaryFieldType } from "core";
|
||||||
import { type Entity, type EntityManager } from "data";
|
import type { Entity, EntityManager } from "data";
|
||||||
import { type FileUploadedEventData, Storage, type StorageAdapter } from "media";
|
import { type FileUploadedEventData, Storage, type StorageAdapter, MediaPermissions } from "media";
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
import {
|
import {
|
||||||
type FieldSchema,
|
type FieldSchema,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
text,
|
text,
|
||||||
} from "../data/prototype";
|
} from "../data/prototype";
|
||||||
import { MediaController } from "./api/MediaController";
|
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<typeof AppMedia.mediaFields>;
|
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
||||||
declare module "core" {
|
declare module "core" {
|
||||||
@@ -46,6 +46,7 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
|||||||
this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr);
|
this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr);
|
||||||
this.setBuilt();
|
this.setBuilt();
|
||||||
this.setupListeners();
|
this.setupListeners();
|
||||||
|
this.ctx.guard.registerPermissions(MediaPermissions);
|
||||||
this.ctx.server.route(this.basepath, new MediaController(this).getController());
|
this.ctx.server.route(this.basepath, new MediaController(this).getController());
|
||||||
|
|
||||||
const media = this.getMediaEntity(true);
|
const media = this.getMediaEntity(true);
|
||||||
|
|||||||
@@ -54,10 +54,13 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getUploadHeaders(): Headers {
|
getUploadHeaders(): Headers {
|
||||||
|
if (this.options.token_transport === "header" && this.options.token) {
|
||||||
return new Headers({
|
return new Headers({
|
||||||
Authorization: `Bearer ${this.options.token}`,
|
Authorization: `Bearer ${this.options.token}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return new Headers();
|
||||||
|
}
|
||||||
|
|
||||||
protected uploadFile(
|
protected uploadFile(
|
||||||
body: File | ReadableStream,
|
body: File | ReadableStream,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { isDebug, tbValidator as tb } from "core";
|
import { isDebug, tbValidator as tb } from "core";
|
||||||
import { HttpStatus, getFileFromContext } from "core/utils";
|
import { HttpStatus, getFileFromContext } from "core/utils";
|
||||||
import type { StorageAdapter } from "media";
|
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 { Controller } from "modules/Controller";
|
||||||
import type { AppMedia } from "../AppMedia";
|
import type { AppMedia } from "../AppMedia";
|
||||||
import { MediaField } from "../MediaField";
|
import { MediaField } from "../MediaField";
|
||||||
@@ -28,18 +29,18 @@ export class MediaController extends Controller {
|
|||||||
override getController() {
|
override getController() {
|
||||||
// @todo: multiple providers?
|
// @todo: multiple providers?
|
||||||
// @todo: implement range requests
|
// @todo: implement range requests
|
||||||
const { auth } = this.middlewares;
|
const { auth, permission } = this.middlewares;
|
||||||
const hono = this.create().use(auth());
|
const hono = this.create().use(auth());
|
||||||
|
|
||||||
// get files list (temporary)
|
// get files list (temporary)
|
||||||
hono.get("/files", async (c) => {
|
hono.get("/files", permission(MediaPermissions.listFiles), async (c) => {
|
||||||
const files = await this.getStorageAdapter().listObjects();
|
const files = await this.getStorageAdapter().listObjects();
|
||||||
return c.json(files);
|
return c.json(files);
|
||||||
});
|
});
|
||||||
|
|
||||||
// get file by name
|
// get file by name
|
||||||
// @todo: implement more aggressive cache? (configurable)
|
// @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();
|
const { filename } = c.req.param();
|
||||||
if (!filename) {
|
if (!filename) {
|
||||||
throw new Error("No file name provided");
|
throw new Error("No file name provided");
|
||||||
@@ -59,7 +60,7 @@ export class MediaController extends Controller {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// delete a file by name
|
// 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();
|
const { filename } = c.req.param();
|
||||||
if (!filename) {
|
if (!filename) {
|
||||||
throw new Error("No file name provided");
|
throw new Error("No file name provided");
|
||||||
@@ -84,7 +85,7 @@ export class MediaController extends Controller {
|
|||||||
|
|
||||||
// upload file
|
// upload file
|
||||||
// @todo: add required type for "upload endpoints"
|
// @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 reqname = c.req.param("filename");
|
||||||
|
|
||||||
const body = await getFileFromContext(c);
|
const body = await getFileFromContext(c);
|
||||||
@@ -114,6 +115,7 @@ export class MediaController extends Controller {
|
|||||||
overwrite: Type.Optional(booleanLike),
|
overwrite: Type.Optional(booleanLike),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const entity_name = c.req.param("entity");
|
const entity_name = c.req.param("entity");
|
||||||
const field_name = c.req.param("field");
|
const field_name = c.req.param("field");
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { TObject } from "@sinclair/typebox";
|
import type { TObject } from "@sinclair/typebox";
|
||||||
import { type Constructor, Registry } from "core";
|
import { type Constructor, Registry } from "core";
|
||||||
|
|
||||||
//export { MIME_TYPES } from "./storage/mime-types";
|
|
||||||
export { guess as guessMimeType } from "./storage/mime-types-tiny";
|
export { guess as guessMimeType } from "./storage/mime-types-tiny";
|
||||||
export {
|
export {
|
||||||
Storage,
|
Storage,
|
||||||
@@ -22,6 +21,7 @@ export { StorageAdapter };
|
|||||||
export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig };
|
export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig };
|
||||||
|
|
||||||
export * as StorageEvents from "./storage/events";
|
export * as StorageEvents from "./storage/events";
|
||||||
|
export * as MediaPermissions from "./media-permissions";
|
||||||
export type { FileUploadedEventData } from "./storage/events";
|
export type { FileUploadedEventData } from "./storage/events";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -54,7 +54,6 @@ export function DropzoneContainer({
|
|||||||
sort: "-id",
|
sort: "-id",
|
||||||
});
|
});
|
||||||
const entity_name = (media?.entity_name ?? "media") as "media";
|
const entity_name = (media?.entity_name ?? "media") as "media";
|
||||||
//console.log("dropzone:baseUrl", baseUrl);
|
|
||||||
|
|
||||||
const selectApi = (api: Api, page: number = 0) =>
|
const selectApi = (api: Api, page: number = 0) =>
|
||||||
entity
|
entity
|
||||||
|
|||||||
Reference in New Issue
Block a user