Professional Documents
Culture Documents
Source Code - Nov
Source Code - Nov
@@map("user")
}
enum UserCustomerRole {
+
+ USER
+ ADMIN
+}
+enum UserFacilityRole{
+ USER
+ ADMIN
}
+
+
+model UserFacilityRelations{
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ facilityId String @map("facility_id")
+ role UserFacilityRole @default(USER)
+
+ user User @relation(fields: [userId], references: [id])
+ facility Facility @relation(fields: [facilityId], references: [id])
+ @@map("user_facility_relations")
+}
+
+model UserCustomerRelations{
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ customerId String @map("customer_id")
+ role UserCustomerRole @default(USER)
+
+ user User @relation(fields: [userId], references: [id])
+ customer Customer @relation(fields: [customerId], references: [id])
+
+ @@map("user_customer_relations")
+}
+
+model Customer{
+ id String @id @default(cuid())
+ name String
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
+
+ facilities Facility[]
+ userCustomerRelations UserCustomerRelations[]
+
+ @@map("customer")
+}
+
model Facility {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
name String
- customerName String @map("customer_name")
+ customerId String @map("customer_id")
location String @map("location")
targetScore Decimal @map("target_score")
@unique([name])
@
@@map("facility")
diff --git a/prisma/seed/dev.ts b/prisma/seed/dev.ts
index 972040f9..58cf951a 100644
--- a/prisma/seed/dev.ts
+++ b/prisma/seed/dev.ts
@@ -16,11 +16,23 @@ import { prisma } from "@/server/db/client";
import "./devices/add_devices";
// admin
const adminFacility = await prisma.facility
@@ -197,9 +201,9 @@ export async function seedProd() {
});
});
- /**
+ /!**
* RECIPE SEED
- */
+ *!/
await prisma.recipe.create({
data: {
name: "Default Recipe",
@@ -262,3 +266,5 @@ export async function seedProd() {
},
});
}
+*/
+/* eslint-enable */
diff --git a/src/authorization/authorizationModel.ts b/src/authorization/authorizationModel.ts
new file mode 100644
index 00000000..7ec7d42d
--- /dev/null
+++ b/src/authorization/authorizationModel.ts
@@ -0,0 +1,19 @@
+import { hasFacilityPermission } from "./facilityPolicies";
+import { hasCustomerPermission } from "./customerPolicies";
+import { PolicyObjectType } from "@/common/dtos/permissions";
+import { type accessRequestSchema } from "@/common/schemas/permissions.schemas";
+import type * as z from "zod";
+
+export async function hasPermission(
+ user: string,
+ policy: z.infer<typeof accessRequestSchema>,
) {
+
+ switch (policy.resourceType) {
+ case PolicyObjectType.facility:
+ return await hasFacilityPermission(user, policy.resource, policy.permission);
+ case PolicyObjectType.customer:
+ return await hasCustomerPermission(user, policy.resource, policy.permission);
+ default:
+ throw new Error(`Typescript types problem. Unknown resourceType in policy:
${JSON.stringify(policy)}`);
+ }
+}
diff --git a/src/authorization/customerPolicies.ts b/src/authorization/customerPolicies.ts
new file mode 100644
index 00000000..401595b2
--- /dev/null
+++ b/src/authorization/customerPolicies.ts
@@ -0,0 +1,50 @@
+import { UserCustomerRole } from ".prisma/client";
+import { getServerLogger } from "@/utils/logging";
+import { CustomerPolicies } from "@/common/dtos/permissions";
+
+const logger = getServerLogger("authorization/customerPolicies");
+
+const customerPermissions = new Map<string, (user: string, id: string) =>
Promise<boolean>>();
+customerPermissions.set(UserCustomerRole.USER, async (user: string, id: string) => {
+ return (
+ (await hasRole(user, id, UserCustomerRole.USER)) ||
+ (await hasCustomerPermission(user, id, UserCustomerRole.ADMIN))
+ );
+});
+
+customerPermissions.set(UserCustomerRole.ADMIN, async (user: string, id: string) => {
+ return hasRole(user, id, UserCustomerRole.ADMIN);
+});
+
+async function hasRole(user: string, id: string, role: UserCustomerRole) {
+ if (!prisma) return false;
+ //TODO: 1 minute cache
+ const customerRoles = await prisma.userCustomerRelations.findMany({
+ where: {
+ userId: user,
+ customerId: id,
+ },
});
+
+ return customerRoles.some((r) => r.role === role);
+}
+
+export async function hasCustomerPermission(
+ user: string,
+ object: string,
+ relation: CustomerPolicies | UserCustomerRole,
+) {
+ const permissionsRule = customerPermissions.get(relation as string);
+ if (permissionsRule) {
+ return await permissionsRule(user, object);
+ } else {
+ logger.warn(`Unknown customer permission ${relation}`);
+ return false; //TODO: or throw error?
+ }
+}
+
+//Sanity check
+for (const permission of Object.values(CustomerPolicies)) {
+ if (!customerPermissions.has(permission)) {
+ logger.error(`Customer permission ${permission} is not registered`);
+ }
+}
diff --git a/src/authorization/facilityPolicies.ts b/src/authorization/facilityPolicies.ts
new file mode 100644
index 00000000..32ad82ec
--- /dev/null
+++ b/src/authorization/facilityPolicies.ts
@@ -0,0 +1,107 @@
+import { UserCustomerRole, UserFacilityRole } from ".prisma/client";
+import { hasCustomerPermission } from "./customerPolicies";
+import { getServerLogger } from "@/utils/logging";
+import { type CustomerPolicies, FacilityPolicies } from "@/common/dtos/permissions";
+import { LRUCache } from "lru-cache";
+
+const userRolesCache = new LRUCache<string, UserFacilityRole[]>({
+ ttl: 1000, // 1 minute
+ ttlAutopurge: true, //TODO: check performance
+});
+const customerCache = new LRUCache<string, string>({
+ ttl: 1000 * 60 * 24, // 1 day
+ ttlAutopurge: true, //TODO: check performance
+});
const logger = getServerLogger("authorization/facilityPolicies");
+
+
+const facilityPermissions = new Map<string, (user: string, id: string) => Promise<boolean>>();
+
+function register(ruleName: string, inheritedFrom: FacilityPolicies | UserCustomerRole) {
+ facilityPermissions.set(ruleName, async (user: string, id: string) => {
+ return await hasFacilityPermission(user, id, inheritedFrom);
+ });
+}
+
+register(FacilityPolicies.canViewRooms, UserFacilityRole.USER);
+register(FacilityPolicies.canViewLots, UserFacilityRole.USER);
+register(FacilityPolicies.canViewForecast, UserFacilityRole.USER);
+register(FacilityPolicies.canEditForecast, UserFacilityRole.ADMIN);
+
+facilityPermissions.set(UserFacilityRole.USER, async (user: string, id: string) => {
+ return (
+ (await hasRole(user, id, UserFacilityRole.USER)) ||
+ (await hasFacilityPermission(user, id, UserFacilityRole.ADMIN)) ||
+ (await checkCustomerPermission(user, id, UserCustomerRole.USER))
+ );
+});
+facilityPermissions.set(UserFacilityRole.ADMIN, async (user: string, id: string) => {
+ return (
+ (await hasRole(user, id, UserFacilityRole.ADMIN)) ||
+ checkCustomerPermission(user, id, UserCustomerRole.ADMIN)
+ );
+});
+
+async function checkCustomerPermission(
+ user: string,
+ facilityId: string,
+ customerPermission: CustomerPolicies | UserCustomerRole,
+) {
+ const customerId = await getCustomer(facilityId);
+ if (!customerId) return false;
+ return await hasCustomerPermission(user, customerId, customerPermission);
+}
+
+async function getCustomer(facilityId: string) {
+ const cached = customerCache.get(facilityId);
+ if (cached) return cached;
+ if (!prisma) return null;
+
const facility = await prisma.facility.findUnique({
+
+ where: {
+ id: facilityId,
+ },
+ });
+ if (!facility) {
+ logger.error(`Permission was requested for non-existing facility ${facilityId}`);
+ return null;
+ }
+ customerCache.set(facilityId, facility.customerId);
+ return facility.customerId;
+}
+
+async function hasRole(user: string, id: string, role: UserFacilityRole) {
+ let facilityRoles = userRolesCache.get(user);
+ if (!prisma) return false;
+
+ if(!facilityRoles) {
+ facilityRoles = (await prisma.userFacilityRelations.findMany({
+ where: {
+ userId: user,
+ facilityId: id,
+ },
+ })).map(x => x.role);
+ userRolesCache.set(user, facilityRoles);
+ }
+ return facilityRoles.some((r) => r === role);
+}
+
+export async function hasFacilityPermission(
+ user: string,
+ object: string,
+ relation: FacilityPolicies | UserFacilityRole,
+) {
+ const permissionsRule = facilityPermissions.get(relation);
+ if (permissionsRule) {
+ return await permissionsRule(user, object);
+ } else {
+ logger.warn(`Unknown facility permission ${relation}`);
+ return false; //TODO: or throw error?
+ }
+}
+
+//Sanity check
for (const permission of Object.values(FacilityPolicies)) {
+
+ if (!facilityPermissions.has(permission)) {
+ logger.error(`Facility permission ${permission} is not registered`);
+ }
+}
diff --git a/src/common/dtos/permissions.ts b/src/common/dtos/permissions.ts
new file mode 100644
index 00000000..02003ab4
--- /dev/null
+++ b/src/common/dtos/permissions.ts
@@ -0,0 +1,14 @@
+export enum CustomerPolicies {
+ testPermission = "testPermission",
+}
+export enum FacilityPolicies {
+ canViewRooms = "canViewRooms",
+ canEditForecast = "canEditForecast",
+ canViewForecast = "canViewForecast",
+ canViewLots = "canViewLots",
+}
+
+export enum PolicyObjectType {
+ facility = "facility",
+ customer = "customer",
+}
diff --git a/src/common/schemas/permissions.schemas.ts
b/src/common/schemas/permissions.schemas.ts
new file mode 100644
index 00000000..8583fec6
--- /dev/null
+++ b/src/common/schemas/permissions.schemas.ts
@@ -0,0 +1,25 @@
+import { z } from "zod";
+import { CustomerPolicies, FacilityPolicies, PolicyObjectType } from
"@/common/dtos/permissions";
+
+export const customerAccessRequestSchema = z.object({
+ permission: z.nativeEnum(CustomerPolicies),
+ resource: z.string(),
+ resourceType: z.enum([PolicyObjectType.customer]),
+});
+
+export const facilityAccessRequestSchema = z.object({
+ permission: z.nativeEnum(FacilityPolicies),
resource: z.string(),
+
+ resourceType: z.enum([PolicyObjectType.facility]),
+});
+
+export const accessRequestSchema = z.union([
+ customerAccessRequestSchema,
+ facilityAccessRequestSchema,
+]);
+export const accessRequestSchemaWithoutResource = z.union([
+ customerAccessRequestSchema.omit({ resource: true }),
+ facilityAccessRequestSchema.omit({ resource: true }),
+]);
+
+export type AccessRequest = z.infer<typeof accessRequestSchema>;
diff --git a/src/components/AppBar/NavLinks.tsx b/src/components/AppBar/NavLinks.tsx
index 8eed45e0..3eb65dbd 100644
--- a/src/components/AppBar/NavLinks.tsx
+++ b/src/components/AppBar/NavLinks.tsx
@@ -3,21 +3,50 @@ import { useRouter } from "next/router";
- const LINKS = (isAdmin: boolean): { href: string; title: string }[] => {
- if (isAdmin) {
+const LINKS = (data?: UserSessionData): (AccessRequest & { href: string; title: string })[] => {
+ if (!data) return [];
+ if (data.role === "ADMIN")
return [
- { href: "/admin/recipes", title: "Recipes" },
- { href: "/admin/users", title: "Users" },
- { href: "/admin/devices", title: "Devices" },
+ // { href: "/admin/recipes", title: "Recipes", resource: "recipe", action: "read" },
+ // /*{ href: "/admin/users", title: "Users", resource: "user", action: "read" },*/
+ // { href: "/admin/devices", title: "Devices", resource: "device", action: "read" },
+ ];
+ else
+ return [
+ {
+ href: "/rooms",
+ title: "Rooms",
+ resourceType: PolicyObjectType.facility,
+ resource: `${data.facilityId}`,
+ permission: FacilityPolicies.canViewRooms,
+ },
+ {
+ href: "/lots",
+ title: "Receiving",
+ resourceType: PolicyObjectType.facility,
+ resource: `${data.facilityId}`,
+ permission: FacilityPolicies.canViewLots,
+ },
+ {
+ href: "/forecast",
+ title: "Forecast",
+ resourceType: PolicyObjectType.facility,
+ resource: `${data.facilityId}`,
+ permission: FacilityPolicies.canViewForecast,
+ },
+ {
+ href: "/ship-out-forecast",
+ title: "Ship out",
+ resourceType: PolicyObjectType.facility,
+ resource: `${data.facilityId}`,
+ permission: FacilityPolicies.canViewForecast,
+ },
];
- }
- return [
- { href: "/rooms", title: "Rooms" },
- { href: "/lots", title: "Receiving" },
- { href: "/forecast", title: "Forecast" },
- { href: "/ship-out-forecast", title: "Ship out" },
- ];
};
return (
<div className="flex space-x-4">
- {LINKS(data?.role === "ADMIN").map(({ href, title }) => (
- <div
- key={`${title.toLowerCase()}-link-container`}
- className={`pb-1 ${
- isActive(href) ? "border-b-2 border-b-brand-periwinkle font-semibold text-gray-700" : ""
- }`}
- >
- <Link key={`${title.toLowerCase()}-link`} href={href}>
- <Button.Root variant="link">{title}</Button.Root>
- </Link>
- </div>
+ {LINKS(data).map(({ href, title, ...request }) => (
+ <PermissionEnforcer key={title} {...request}>
+ <div
+ key={`${title.toLowerCase()}-link-container`}
+ className={`pb-1 ${
+ isActive(href)
+ ? "border-b-2 border-b-brand-periwinkle font-semibold text-gray-700"
+ : ""
+ }`}
+ >
+ <Link key={`${title.toLowerCase()}-link`} href={href}>
+ <Button.Root variant="link">{title}</Button.Root>
+ </Link>
+ </div>
+ </PermissionEnforcer>
))}
</div>
);
diff --git a/src/components/PermissionEnforcer/PermissionEnforcer.tsx
b/src/components/PermissionEnforcer/PermissionEnforcer.tsx
new file mode 100644
index 00000000..bbdeed50
--- /dev/null
+++ b/src/components/PermissionEnforcer/PermissionEnforcer.tsx
@@ -0,0 +1,10 @@
+import { trpc } from "@/utils/trpc";
+import { type AccessRequest } from "@/common/schemas/permissions.schemas";
+
+type PermissionEnforcerProps = React.PropsWithChildren<AccessRequest>;
+
+export const PermissionEnforcer = ({ children, ...request }: PermissionEnforcerProps) => {
+ const { data: isAllowed } = trpc.permissions.hasPermission.useQuery(request);
+ if (!isAllowed) return null;
+ return <>{children}</>;
+};
diff --git a/src/env/schema.mjs b/src/env/schema.mjs
index fa7b26f6..75199fc2 100644
--- a/src/env/schema.mjs
+++ b/src/env/schema.mjs
@@ -1,5 +1,5 @@
// @ts-check
-import { z } from "zod";
+import {z} from "zod";
/**
* Specify your server-side environment variables schema here.
diff --git a/src/pages/forecast/index.tsx b/src/pages/forecast/index.tsx
index 2ddafbf9..6712cf4c 100644
--- a/src/pages/forecast/index.tsx
+++ b/src/pages/forecast/index.tsx
@@ -25,6 +25,8 @@ import { PencilIcon } from "@heroicons/react/24/outline";
import { getClientLogger } from "@/utils/logging";
import { DeleteConfirmationModal } from "@/components/Modal/DeleteConfirmationModal";
import { TrashIcon } from "@heroicons/react/20/solid";
+import { PermissionEnforcer } from "@/components/PermissionEnforcer/PermissionEnforcer";
+import {FacilityPolicies, PolicyObjectType} from "@/common/dtos/permissions";
interface DaysCount {
Sunday?: number;
@@ -53,6 +55,7 @@ interface WeekTableValues extends DaysCount {
getForecastStartingFromDate: protectedProcedure
+ .use(
+ requireAccess(
+ {
+ resourceType: PolicyObjectType.facility,
+ permission: FacilityPolicies.canViewForecast,
+ },
+ (input) => input.facilityId,
+ dailyForecastRequestSchema,
+ ),
+ )
.input(dailyForecastRequestSchema)
.output(dailyForecastsSchema)
.query(async ({ input, ctx }) => {
@@ -224,11 +235,27 @@ export const dailyShipmentForecastRouter = router({
}),
create: protectedProcedure
+ .use(
+ requireAccess(
+ {
+ resourceType: PolicyObjectType.facility,
+ permission: FacilityPolicies.canEditForecast,
+ },
+ (input, user) => user.facilityId,
+ ),
+ )
.input(createDailyForecastInputSchema)
.mutation(async ({ input, ctx }): Promise<void> => {
try {
+ if (!ctx.session.user) throw formatTRPCError(new Error("User not found")); //should never
happen because of middlewares
+ const facilityId = ctx.session.user.facilityId;
+
await ctx.prisma.dailyShipmentForecast.createMany({
- data: input.map((item) => ({ ...item, date: parseDateOnlyStringISO(item.date) })),
+ data: input.map((item) => ({
+ ...item,
+ facilityId: facilityId,
+ date: parseDateOnlyStringISO(item.date),
+ })),
});
} catch (error) {
throw formatTRPCError(error);
@@ -236,14 +263,25 @@ export const dailyShipmentForecastRouter = router({
}),
update: protectedProcedure
+ .use(
+ requireAccess(
+ {
+ resourceType: PolicyObjectType.facility,
+ permission: FacilityPolicies.canEditForecast,
+ },
+ (input, user) => user.facilityId,
+ ),
+ )
.input(createDailyForecastInputSchema)
.mutation(async ({ input, ctx }): Promise<void> => {
try {
+ if (!ctx.session.user) throw formatTRPCError(new Error("User not found")); //should never
happen because of middlewares
+
for (const item of input) {
await ctx.prisma.dailyShipmentForecast.update({
where: {
date_bananaType_facilityId: {
- facilityId: item.facilityId,
+ facilityId: ctx.session.user.facilityId,
date: parseDateOnlyStringISO(item.date),
bananaType: item.bananaType,
},
@@ -259,6 +297,16 @@ export const dailyShipmentForecastRouter = router({
}),
delete: protectedProcedure
+ .use(
+ requireAccess(
+ {
+ resourceType: PolicyObjectType.facility,
+ permission: FacilityPolicies.canEditForecast,
+ },
+ (input, user) => user.facilityId,
+ deleteDailyForecastInputSchema,
+ ),
+ )
.input(deleteDailyForecastInputSchema)
.mutation(async ({ input, ctx }): Promise<void> => {
try {
diff --git a/src/server/trpc/router/permissions.ts b/src/server/trpc/router/permissions.ts
new file mode 100644
index 00000000..a6fa2e7f
--- /dev/null
+++ b/src/server/trpc/router/permissions.ts
@@ -0,0 +1,21 @@
+import { protectedProcedure, router } from "@/server/trpc/trpc";
+import { accessRequestSchema } from "@/common/schemas/permissions.schemas";
+import { TRPCError } from "@trpc/server";
+import { hasPermission } from "../../../authorization/authorizationModel";
+
+export const permissionsRouter = router({
+ hasPermission: protectedProcedure
+ .input(accessRequestSchema)
+ .query(async ({ ctx, input }) => {
+ if (!ctx.session.user)
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You must be logged in to access this resource.",
+ }); //should never happen
+
+ return await hasPermission(
+ `${ctx.session.user?.id}`,
+ input
+ );
+ }),
+});
diff --git a/src/server/trpc/trpc.ts b/src/server/trpc/trpc.ts
index 13423f57..ccd13f14 100644
--- a/src/server/trpc/trpc.ts
+++ b/src/server/trpc/trpc.ts
@@ -5,6 +5,9 @@ import { type Context } from "./context";