diff --git a/.changeset/hungry-shirts-scream.md b/.changeset/hungry-shirts-scream.md new file mode 100644 index 000000000..2a1029685 --- /dev/null +++ b/.changeset/hungry-shirts-scream.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-core": patch +--- + +Members of an organization now inherit its roles on a farm properly. diff --git a/.changeset/new-places-shop.md b/.changeset/new-places-shop.md new file mode 100644 index 000000000..b6c630774 --- /dev/null +++ b/.changeset/new-places-shop.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": minor +--- + +In the sidebar, when a farm is selected, show the farm name and role that the user has. diff --git a/fdm-app/app/components/blocks/sidebar/farm.tsx b/fdm-app/app/components/blocks/sidebar/farm.tsx index b6bfcc716..a9c815afd 100644 --- a/fdm-app/app/components/blocks/sidebar/farm.tsx +++ b/fdm-app/app/components/blocks/sidebar/farm.tsx @@ -1,3 +1,4 @@ +import type { getFarm } from "@svenvw/fdm-core" import { Calendar, Check, @@ -30,7 +31,22 @@ import { SidebarMenuSubItem, } from "~/components/ui/sidebar" -export function SidebarFarm() { +export function SidebarFarm({ + farm, +}: { + farm: Awaited> | undefined +}) { + function getSuperiorRole(allRoles: ("owner" | "advisor" | "researcher")[]) { + if (allRoles.length > 0) { + const ordering = ["owner", "advisor", "researcher"] as const + const sorted = [...allRoles].sort( + (a, b) => ordering.indexOf(a) - ordering.indexOf(b), + ) + return sorted[0] + } + return null + } + const farmId = useFarmStore((state) => state.farmId) const selectedCalendar = useCalendarStore((state) => state.calendar) @@ -38,13 +54,13 @@ export function SidebarFarm() { const [isCalendarOpen, setIsCalendarOpen] = useState(false) const calendarSelection = getCalendarSelection() - // Check if the page or its return page contains `farm/create` in url const location = useLocation() const [searchParams] = useSearchParams() + // Check if the page or its return page contains `farm/create` in url const isCreateFarmWizard = location.pathname.includes("farm/create") || searchParams.get("returnUrl")?.includes("farm/create") - + const farmRole = farm ? getSuperiorRole(farm.roles) : null // Set the farm link let farmLink: string let farmLinkDisplay: string @@ -53,7 +69,7 @@ export function SidebarFarm() { farmLinkDisplay = "Terug naar bedrijven" } else if (farmId && farmId !== "undefined") { farmLink = `/farm/${farmId}` - farmLinkDisplay = "Bedrijf" + farmLinkDisplay = farm?.b_name_farm ? farm.b_name_farm : "Bedrijf" } else { farmLink = "/farm" farmLinkDisplay = "Overzicht bedrijven" @@ -102,6 +118,21 @@ export function SidebarFarm() { {farmLinkDisplay} + {farmRole && ( + + {farmRole === "owner" + ? "Eigenaar" + : farmRole === "advisor" + ? "Adviseur" + : farmRole === "researcher" + ? "Onderzoeker" + : "Onbekend"} + + )} diff --git a/fdm-app/app/routes/farm.$b_id_farm.tsx b/fdm-app/app/routes/farm.$b_id_farm.tsx index e5758d7b6..b09df2899 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.tsx @@ -1,7 +1,9 @@ +import { getFarm } from "@svenvw/fdm-core" import { data, type LoaderFunctionArgs, type MetaFunction } from "react-router" import { getSession } from "~/lib/auth.server" import { clientConfig } from "~/lib/config" import { handleActionError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" // Meta export const meta: MetaFunction = () => { @@ -39,9 +41,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // Get the session const session = await getSession(request) + const farm = await getFarm(fdm, session.principal_id, b_id_farm) + // Return the farm ID and session info return { farmId: b_id_farm, + farm: farm, session, } } catch (error) { diff --git a/fdm-app/app/routes/farm.tsx b/fdm-app/app/routes/farm.tsx index a91909683..904ab5319 100644 --- a/fdm-app/app/routes/farm.tsx +++ b/fdm-app/app/routes/farm.tsx @@ -1,3 +1,4 @@ +import { getFarm } from "@svenvw/fdm-core" import posthog from "posthog-js" import { useEffect } from "react" import type { LoaderFunctionArgs, MetaFunction } from "react-router" @@ -19,6 +20,7 @@ import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { useCalendarStore } from "~/store/calendar" import { useFarmStore } from "~/store/farm" +import { fdm } from "../lib/fdm.server" export const meta: MetaFunction = () => { return [ @@ -33,16 +35,17 @@ export const meta: MetaFunction = () => { /** * Retrieves the session from the HTTP request and returns user information if available. + * Also retrieves the current farm when available. * * If the session does not contain a user, the function redirects to the "/signin" route. * Any errors encountered during session retrieval are processed by the designated error handler. * * @param request - The HTTP request used for obtaining session data. - * @returns An object with a "user" property when a valid session is found. + * @returns An object with a "user" property when a valid session is found, and a "farm" property when b_id_farm is found in the URL. * * @throws {Error} If an error occurs during session retrieval, processed by handleLoaderError. */ -export async function loader({ request }: LoaderFunctionArgs) { +export async function loader({ params, request }: LoaderFunctionArgs) { try { // Get the session const session = await getSession(request) @@ -52,8 +55,13 @@ export async function loader({ request }: LoaderFunctionArgs) { return sessionCheckResponse } + const farm = params.b_id_farm + ? await getFarm(fdm, session.principal_id, params.b_id_farm) + : undefined + // Return user information from loader return { + farm: farm, user: session.user, userName: session.userName, initials: session.initials, @@ -116,7 +124,7 @@ export default function App() { - + { let fdm: FdmServerType + let fdmAuth: BetterAuth let principal_id: string let farm_id: string + let organization_id: string + let organization_member_email: string + let organization_member_id: string let host: string let port: number let user: string @@ -34,8 +44,30 @@ describe("Authorization Functions", () => { user = inject("user") password = inject("password") database = inject("database") + // Mock environment variables + const googleAuth = { + clientId: "mock_google_client_id", + clientSecret: "mock_google_client_secret", + } + const microsoftAuth = { + clientId: "mock_ms_client_id", + clientSecret: "mock_ms_client_secret", + } + fdm = createFdmServer(host, port, user, password, database, 10) // allow some connections - principal_id = createId() + fdmAuth = createFdmAuth(fdm, googleAuth, microsoftAuth, undefined, true) + const principal = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: "principal@example.com", + name: "Principal I", + firstname: "Principal", + surname: "I", + username: "principal", + password: "password", + }, + }) + principal_id = principal.user.id }) beforeEach(async () => { @@ -53,6 +85,27 @@ describe("Authorization Functions", () => { farmPostalCode, principal_id, ) + organization_id = await createOrganization( + fdm, + principal_id, + "Test Organization", + `test-${createId()}`, + "This is an organization created for testing purposes.", + ) + const organization_member_username = `orgmember${createId(8).toLowerCase()}` + organization_member_email = `${organization_member_username}@example.com` + const organization_member = await fdmAuth.api.signUpEmail({ + headers: undefined, + body: { + email: organization_member_email, + name: "Organization Member", + firstname: "Organization", + surname: "Member", + username: organization_member_username, + password: "password", + }, + }) + organization_member_id = organization_member.user.id }) describe("checkPermission", () => { @@ -83,6 +136,65 @@ describe("Authorization Functions", () => { ) }) + it("should grant access through the organization", async () => { + await grantRole(fdm, "farm", "owner", farm_id, organization_id) + const invitation_id = await inviteUserToOrganization( + fdm, + principal_id, + organization_member_email, + "owner", + organization_id, + ) + await acceptInvitation(fdm, invitation_id, organization_member_id) + await checkPermission( + fdm, + "farm", + "write", + farm_id, + organization_member_id, + "test", + ) + }) + + it("should not grant access through an organization not invited to a farm", async () => { + await expect( + checkPermission( + fdm, + "farm", + "write", + farm_id, + organization_id, + "test", + ), + ).rejects.toThrow( + "Principal does not have permission to perform this action", + ) + }) + + it("should not grant permissions higher than the organization permissions", async () => { + await grantRole(fdm, "farm", "researcher", farm_id, organization_id) + const invitation_id = await inviteUserToOrganization( + fdm, + principal_id, + organization_member_email, + "owner", + organization_id, + ) + await acceptInvitation(fdm, invitation_id, organization_member_id) + await expect( + checkPermission( + fdm, + "farm", + "write", + farm_id, + organization_member_id, + "test", + ), + ).rejects.toThrow( + "Principal does not have permission to perform this action", + ) + }) + it("should throw an error for unknown resource", async () => { await grantRole(fdm, "farm", "owner", farm_id, principal_id) await expect( @@ -443,6 +555,56 @@ describe("Authorization Functions", () => { expect(accessibleResources).toContain(farm_id2) }) + it("should list resources that the user's organization has access to", async () => { + const farm_id2 = createId() + await grantRole(fdm, "farm", "owner", farm_id, organization_id) + await grantRole(fdm, "farm", "advisor", farm_id2, organization_id) + const invitation_id = await inviteUserToOrganization( + fdm, + principal_id, + organization_member_email, + "admin", + organization_id, + ) + await acceptInvitation(fdm, invitation_id, organization_member_id) + + const accessibleResources = await listResources( + fdm, + "farm", + "read", + organization_member_id, + ) + expect(accessibleResources.length).toBe(2) + expect(accessibleResources).toContain(farm_id) + expect(accessibleResources).toContain(farm_id2) + }) + + it("should not list duplicates", async () => { + await grantRole( + fdm, + "farm", + "owner", + farm_id, + organization_member_id, + ) + await grantRole(fdm, "farm", "advisor", farm_id, organization_id) + const invitation_id = await inviteUserToOrganization( + fdm, + principal_id, + organization_member_email, + "admin", + organization_id, + ) + await acceptInvitation(fdm, invitation_id, organization_member_id) + const accessibleResources = await listResources( + fdm, + "farm", + "read", + organization_member_id, + ) + expect(accessibleResources).toEqual([farm_id]) + }) + it("should handle empty list", async () => { const principal_id_new = createId() const accessibleResources = await listResources( @@ -573,6 +735,51 @@ describe("Authorization Functions", () => { expect(roles).toEqual([]) }) + it("should get roles derived from an organization", async () => { + await grantRole(fdm, "farm", "researcher", farm_id, organization_id) + const invitation_id = await inviteUserToOrganization( + fdm, + principal_id, + organization_member_email, + "admin", + organization_id, + ) + await acceptInvitation(fdm, invitation_id, organization_member_id) + const roles = await getRolesOfPrincipalForResource( + fdm, + "farm", + farm_id, + organization_member_id, + ) + expect(roles).toEqual(["researcher"]) + }) + + it("should get all roles", async () => { + await grantRole( + fdm, + "farm", + "researcher", + farm_id, + organization_member_id, + ) + await grantRole(fdm, "farm", "owner", farm_id, organization_id) + const invitation_id = await inviteUserToOrganization( + fdm, + principal_id, + organization_member_email, + "admin", + organization_id, + ) + await acceptInvitation(fdm, invitation_id, organization_member_id) + const roles = await getRolesOfPrincipalForResource( + fdm, + "farm", + farm_id, + organization_member_id, + ) + expect(roles).toEqual(["owner", "researcher"]) + }) + it("should throw error with invalid resource", async () => { await expect( getRolesOfPrincipalForResource( diff --git a/fdm-core/src/authorization.ts b/fdm-core/src/authorization.ts index a6c4e5142..2d72d8f70 100644 --- a/fdm-core/src/authorization.ts +++ b/fdm-core/src/authorization.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, isNull } from "drizzle-orm" +import { and, eq, inArray, isNotNull, isNull, or } from "drizzle-orm" import type { Action, Permission, @@ -10,6 +10,7 @@ import type { Role, } from "./authorization.d" import * as schema from "./db/schema" +import * as authNSchema from "./db/schema-authn" import * as authZSchema from "./db/schema-authz" import { handleError } from "./error" import type { FdmType } from "./fdm" @@ -182,13 +183,29 @@ export async function checkPermission( resource_id: authZSchema.role.resource_id, }) .from(authZSchema.role) + .leftJoin( + authNSchema.member, + eq( + authZSchema.role.principal_id, + authNSchema.member.organizationId, + ), + ) .where( and( eq(authZSchema.role.resource, bead.resource), eq(authZSchema.role.resource_id, bead.resource_id), - inArray( - authZSchema.role.principal_id, - principal_ids, + or( + inArray( + authZSchema.role.principal_id, + principal_ids, + ), + and( + isNotNull(authNSchema.member.userId), + inArray( + authNSchema.member.userId, + principal_ids, + ), + ), ), inArray(authZSchema.role.role, roles), isNull(authZSchema.role.deleted), @@ -244,12 +261,16 @@ export async function checkPermission( * * This function queries the database to find all roles that a principal has been granted for the given resource. * It returns an array of role strings. - * CAUTION: This function does not return inherited roles yet. + * + * When the principal id is for an user, it can get any of the user's roles derived through their organizations. + * + * CAUTION: This function does not return roles inherited from related resources yet. * * @param fdm - The FDM instance providing the connection to the database. * @param resource - The type of the resource to query for the principal's roles. * @param resource_id - The identifier of the specific resource instance. * @param principal_id - The identifier of the principal. + * If an user id is supplied, the function can also retrieve roles for the user's organizations. * @returns A promise that resolves to an array of roles (strings) that the principal has for the given resource. * Returns an empty array if the principal has no roles for the resource. * @throws {Error} If the resource type is invalid or if the database operation fails. @@ -277,11 +298,30 @@ export async function getRolesOfPrincipalForResource( role: authZSchema.role.role, }) .from(authZSchema.role) + .leftJoin( + authNSchema.member, + eq( + authZSchema.role.principal_id, + authNSchema.member.organizationId, + ), + ) .where( and( eq(authZSchema.role.resource, resource), eq(authZSchema.role.resource_id, resource_id), - inArray(authZSchema.role.principal_id, principal_ids), + or( + inArray( + authZSchema.role.principal_id, + principal_ids, + ), + and( + isNotNull(authNSchema.member.userId), + inArray( + authNSchema.member.userId, + principal_ids, + ), + ), + ), isNull(authZSchema.role.deleted), ), ) @@ -534,14 +574,33 @@ export async function listResources( } return await tx - .select({ + .selectDistinct({ resource_id: authZSchema.role.resource_id, }) .from(authZSchema.role) + .leftJoin( + authNSchema.member, + eq( + authZSchema.role.principal_id, + authNSchema.member.organizationId, + ), + ) .where( and( eq(authZSchema.role.resource, resource), - inArray(authZSchema.role.principal_id, principal_ids), + or( + inArray( + authZSchema.role.principal_id, + principal_ids, + ), + and( + isNotNull(authNSchema.member.userId), + inArray( + authNSchema.member.userId, + principal_ids, + ), + ), + ), inArray(authZSchema.role.role, roles), isNull(authZSchema.role.deleted), ),