Browse Source

feat: Added model job permission handling and configuration

Owen Diffey 1 year ago
parent
commit
e9d551ca19

+ 43 - 9
backend/src/JobContext.ts

@@ -22,6 +22,8 @@ export default class JobContext {
 
 	private _permissions?: Record<string, boolean>;
 
+	private _modelPermissions: Record<string, Record<string, boolean>>;
+
 	public constructor(
 		job: Job,
 		options?: { session?: SessionSchema; socketId?: string }
@@ -30,6 +32,7 @@ export default class JobContext {
 		this.jobQueue = JobQueue.getPrimaryInstance();
 		this._session = options?.session;
 		this._socketId = options?.socketId;
+		this._modelPermissions = {};
 	}
 
 	/**
@@ -109,26 +112,57 @@ export default class JobContext {
 			throw new Error("No user found for session");
 	}
 
-	public async getUserPermissions(
-		scope?: { stationId?: Types.ObjectId },
-		refresh = false
-	) {
+	public async getUserPermissions(refresh = false) {
 		if (this._permissions && !refresh) return this._permissions;
 
 		this._permissions = await this.executeJob(
 			"api",
 			"getUserPermissions",
-			scope ?? {}
+			{}
 		);
 
 		return this._permissions;
 	}
 
-	public async assertPermission(
-		permission: string,
-		scope?: { stationId?: Types.ObjectId }
+	public async getUserModelPermissions(
+		{
+			modelName,
+			modelId
+		}: {
+			modelName: keyof Models;
+			modelId?: Types.ObjectId;
+		},
+		refresh = false
 	) {
-		const permissions = await this.getUserPermissions(scope);
+		if (this._modelPermissions[modelName] && !refresh)
+			return this._modelPermissions[modelName];
+
+		this._modelPermissions[modelName] = await this.executeJob(
+			"api",
+			"getUserModelPermissions",
+			{ modelName, modelId }
+		);
+
+		return this._modelPermissions[modelName];
+	}
+
+	public async assertPermission(permission: string) {
+		let permissions = await this.getUserPermissions();
+
+		if (permission.startsWith("data")) {
+			const [, modelName, modelId] =
+				/^data\.([a-z]+)\.[A-z]+\.?([A-z0-9]+)?$/.exec(permission);
+
+			permissions = {
+				...permissions,
+				...(await this.getUserModelPermissions({
+					modelName,
+					modelId
+				}))
+			};
+
+			return;
+		}
 
 		if (!permissions[permission])
 			throw new Error("Insufficient permissions");

+ 92 - 20
backend/src/modules/APIModule.ts

@@ -9,6 +9,7 @@ import { UserRole } from "../schemas/user";
 import { StationType } from "../schemas/station";
 import permissions from "../permissions";
 import Job from "../Job";
+import { Models } from "../types/Models";
 
 export default class APIModule extends BaseModule {
 	private _subscriptions: Record<string, Set<string>>;
@@ -187,28 +188,12 @@ export default class APIModule extends BaseModule {
 		};
 	}
 
-	public async getUserPermissions(
-		context: JobContext,
-		{ stationId }: { stationId?: Types.ObjectId }
-	) {
-		const user = await context.getUser();
-
-		const roles: (UserRole | "owner" | "dj")[] = [user.role];
+	public async getUserPermissions(context: JobContext) {
+		const user = await context.getUser().catch(() => null);
 
-		if (stationId) {
-			const Station = await context.getModel("station");
+		if (!user) return {};
 
-			const station = await Station.findById(stationId);
-
-			if (!station) throw new Error("Station not found");
-
-			if (
-				station.type === StationType.COMMUNITY &&
-				station.owner === user._id
-			)
-				roles.push("owner");
-			else if (station.djs.find(dj => dj === user._id)) roles.push("dj");
-		}
+		const roles: UserRole[] = [user.role];
 
 		let rolePermissions: Record<string, boolean> = {};
 		roles.forEach(role => {
@@ -219,6 +204,93 @@ export default class APIModule extends BaseModule {
 		return rolePermissions;
 	}
 
+	public async getUserModelPermissions(
+		context: JobContext,
+		{
+			modelName,
+			modelId
+		}: { modelName: keyof Models; modelId?: Types.ObjectId }
+	) {
+		const user = await context.getUser().catch(() => null);
+		const permissions = await context.getUserPermissions();
+
+		const Model = await context.getModel(modelName);
+
+		if (!Model) throw new Error("Model not found");
+
+		const model = modelId ? await Model.findById(modelId) : null;
+
+		if (modelId && !model) throw new Error("Model not found");
+
+		const jobs = await Promise.all(
+			Object.keys(this._moduleManager.getModule("data")?.getJobs() ?? {})
+				.filter(
+					jobName =>
+						jobName.startsWith(modelName.toString()) &&
+						(modelId ? true : !jobName.endsWith("ById"))
+				)
+				.map(async jobName => {
+					jobName = `data.${jobName}`;
+
+					let hasPermission = permissions[jobName];
+
+					if (!hasPermission && modelId)
+						hasPermission =
+							permissions[`${jobName}.*`] ||
+							permissions[`${jobName}.${modelId}`];
+
+					if (hasPermission) return [jobName, true];
+
+					const [, shortJobName] =
+						new RegExp(`^data.${modelName}.([A-z]+)`).exec(
+							jobName
+						) ?? [];
+
+					const schemaOptions = (Model.schema.get("jobConfig") ?? {})[
+						shortJobName
+					];
+					let options = schemaOptions?.hasPermission ?? [];
+
+					if (!Array.isArray(options)) options = [options];
+
+					hasPermission = await options.reduce(
+						async (previous, option) => {
+							if (await previous) return true;
+
+							if (option === "loggedIn" && user) return true;
+
+							if (option === "owner" && user && model) {
+								let ownerAttribute;
+
+								if (model.schema.path("createdBy"))
+									ownerAttribute = "createdBy";
+								else if (model.schema.path("owner"))
+									ownerAttribute = "owner";
+
+								if (ownerAttribute)
+									return (
+										model[ownerAttribute].toString() ===
+										user._id.toString()
+									);
+							}
+
+							if (typeof option === "boolean") return option;
+
+							if (typeof option === "function")
+								return option(model, user);
+
+							return false;
+						},
+						Promise.resolve(false)
+					);
+
+					return [jobName, !!hasPermission];
+				})
+		);
+
+		return Object.fromEntries(jobs);
+	}
+
 	private async _subscriptionCallback(channel: string, value?: any) {
 		const promises = [];
 		for await (const socketId of this._subscriptions[channel].values()) {

+ 3 - 0
backend/src/permissions.ts

@@ -3,6 +3,9 @@ import { UserRole } from "./schemas/user";
 
 const temp = {
 	"data.news.getData": true,
+	"data.news.findById.*": true,
+	"data.news.updateById.*": true,
+	"data.news.deleteById.*": true
 };
 
 const user = { ...temp };

+ 10 - 7
backend/src/schemas/news.ts

@@ -101,14 +101,17 @@ export const schema = new Schema<NewsSchema, NewsModel, {}, NewsQueryHelpers>(
 			}
 		},
 		jobConfig: {
-			async published() {
-				return this.find().published();
+			published: {
+				async method() {
+					return this.find().published();
+				},
+				hasPermission: true
 			},
-			async newest(
-				context: JobContext,
-				payload: { showToNewUsers?: boolean }
-			) {
-				return this.find().newest(payload?.showToNewUsers);
+			newest: {
+				async method() {
+					return this.find().newest(payload?.showToNewUsers);
+				},
+				hasPermission: true
 			}
 		},
 		// @ts-ignore need to somehow use GetDataSchemaOptions

+ 21 - 0
backend/src/schemas/station.ts

@@ -64,6 +64,13 @@ export interface StationSchema extends BaseSchema {
 
 export interface StationModel extends Model<StationSchema>, GetData {}
 
+const isDj = (model, user) =>
+	model && user && model.djs.contains(user._id.toString());
+
+const isPublic = model => model && model.privacy === StationPrivacy.PUBLIC;
+const isUnlisted = model => model && model.privacy === StationPrivacy.UNLISTED;
+const isPrivate = model => model && model.privacy === StationPrivacy.PRIVATE;
+
 export const schema = new Schema<StationSchema, StationModel>(
 	{
 		type: {
@@ -176,6 +183,20 @@ export const schema = new Schema<StationSchema, StationModel>(
 	{
 		// @ts-ignore
 		documentVersion: 10,
+		jobConfig: {
+			create: {
+				hasPermission: "loggedIn"
+			},
+			findById: {
+				hasPermission: ["owner", isDj, isPublic, isUnlisted]
+			},
+			updateById: {
+				hasPermission: ["owner", isDj]
+			},
+			deleteById: {
+				hasPermission: ["owner", isDj]
+			}
+		},
 		// @ts-ignore
 		getData: {
 			enabled: true,