From 4c9bcf2aad0b08905c182b2241edb2be54947fe2 Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Thu, 23 Jan 2025 09:37:53 -0500 Subject: [PATCH] refactor: Authenticated Pages and Layouts (#4978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * securityQuestions lib * security questions tests update * remove AccessControlError from barrel file. * update securityQuestions actions * update flags lib * invitations and users * move unauthorized page * appSettings and authenticated actions * fix: add authentication check to checkIfClosed server action * chore: AuthenticatedAction wrapper pass session when calling inner action * fix: prevent user from requesting permission to publish forms if they already have it * fix: add missing authentication check in settings server actions * fix: add missing authentication + privilege checks in manage server actions * chore: migrate all appropriate server actions to use the new AuthenticatedAction wrapper * feat: add missing audit logs when modifying throttling rate on a form set to deliver responses through API method * chore: remove unnecessary use of getAbility function * fix: broken unit tests due to refactoring around getAbility * chore: refactor custom AccessControlError class to avoid using a promise to get the user identifier * Authenticate Page and Layout * fix: preview page should handle non authenticated users * Authenticated Layout and Pages wrapper * update unlock-publishing * update typing * clean up * add type check * align with privileges requested on page * Align thottling privileges with page * remove force typing * fix logic on publish priv check * Add helper to check for publish forms priv * refactor - rename authorization publish priv check * Add auth wrapper to unauthorized page * refactor: convert checkIfClosed function from server action to simple lib function * fix: misuse of new AccessControlError constructor * chore: remove useless mocks * refactor: delete unused server actions * refactor: delete unused refreshKey server action/lib function * refactor: remove not needed server action for view templates page * refactor: remove not needed server action for view accounts page * refactor: remove not needed server action for application settings page * refactor: remove not needed server action for form settings page * refactor: convert some server actions to regular lib functions --------- Co-authored-by: Clément Janin --- __utils__/authorization.ts | 14 +- __utils__/mocks/server-actions/index.ts | 5 - .../admin/(no nav)/layout.tsx | 112 ++++----- .../admin/(no nav)/page.tsx | 13 - .../admin/(no nav)/upload/page.tsx | 11 +- .../admin/(no nav)/view-templates/actions.ts | 15 +- .../admin/(no nav)/view-templates/page.tsx | 27 ++- .../accounts/[id]/manage-forms/actions.ts | 2 +- .../accounts/[id]/manage-forms/page.tsx | 163 ++++++------- .../[id]/manage-permissions/actions.ts | 32 ++- .../accounts/[id]/manage-permissions/page.tsx | 142 ++++++----- .../admin/(with nav)/accounts/actions.ts | 42 +--- .../accounts/components/server/UsersList.tsx | 25 +- .../admin/(with nav)/accounts/page.tsx | 66 +++-- .../admin/(with nav)/flags/actions.ts | 12 +- .../flags/components/server/FlagList.tsx | 5 +- .../admin/(with nav)/flags/page.tsx | 14 +- .../admin/(with nav)/layout.tsx | 135 ++++++----- .../(with nav)/settings/[settingId]/page.tsx | 46 ++-- .../admin/(with nav)/settings/actions.ts | 74 +----- .../components/server/ManageSettingForm.tsx | 51 +++- .../settings/components/server/Settings.tsx | 20 +- .../admin/(with nav)/settings/create/page.tsx | 13 +- .../admin/(with nav)/settings/page.tsx | 53 ++-- .../form-builder/[id]/layout.tsx | 2 +- .../form-builder/[id]/preview/page.tsx | 5 +- .../form-builder/[id]/publish/page.tsx | 16 +- .../responses/[[...statusFilter]]/actions.ts | 65 ++--- .../ManageFormAccessDialog/actions.ts | 121 +++++----- .../form-builder/[id]/settings/actions.ts | 21 +- .../[id]/settings/components/utils.ts | 7 +- .../[id]/settings/manage/ThrottlingRate.tsx | 40 ++- .../[id]/settings/manage/actions.ts | 70 +++++- .../[id]/settings/manage/page.tsx | 83 ++----- .../form-builder/[id]/settings/page.tsx | 15 +- .../form-builder/actions.ts | 193 ++++++++------- .../(form administration)/forms/actions.ts | 4 +- .../forms/components/Invitations/actions.ts | 16 +- .../(form administration)/forms/layout.tsx | 17 +- .../(form administration)/forms/page.tsx | 2 +- .../(support)/unlock-publishing/actions.ts | 102 ++++---- .../(support)/unlock-publishing/page.tsx | 24 +- .../(user authentication)/auth/mfa/actions.ts | 16 +- .../auth/setup-security-questions/actions.ts | 58 ++--- .../(user authentication)/profile/action.ts | 42 ++-- .../(user authentication)/profile/page.tsx | 7 +- app/api/id/[form]/submission/report/route.ts | 2 +- .../id/[form]/submission/unprocessed/route.ts | 2 +- app/api/templates/[formID]/route.ts | 2 +- app/api/templates/route.ts | 2 +- .../admin => }/unauthorized/page.tsx | 8 +- i18n/index.ts | 6 - i18n/translations/en/form-builder.json | 1 - i18n/translations/fr/form-builder.json | 1 - lib/actions/auth.ts | 5 +- lib/actions/checkIfClosed.ts | 42 ---- lib/appSettings.ts | 228 +++++++++--------- lib/auditLogs.ts | 8 +- lib/auth/errors.ts | 14 ++ lib/auth/index.ts | 18 -- lib/auth/securityQuestions.ts | 47 ++-- lib/cache/flags.ts | 73 ++---- lib/cache/throttlingCache.ts | 13 +- lib/invitations/acceptInvitation.ts | 18 +- lib/invitations/cancelInvitation.ts | 34 +-- lib/invitations/declineInvitation.ts | 19 +- lib/invitations/inviteUserByEmail.ts | 28 ++- lib/invitations/tests/fixtures/Ability.ts | 15 -- lib/invitations/tests/invitations.test.ts | 69 +++--- lib/pages/auth.ts | 83 +++++++ lib/privileges.ts | 211 ++++++---------- lib/serviceAccount.ts | 69 ------ lib/templates.ts | 36 +++ lib/tests/appSettings.test.ts | 192 ++++++--------- lib/tests/privileges.test.ts | 25 +- lib/tests/securityQuestions.test.ts | 73 ++---- lib/tests/serviceAccount.test.ts | 85 +------ lib/tests/templates.test.ts | 16 +- lib/tests/users.test.ts | 50 ++-- lib/tests/vault.test.ts | 2 +- lib/users.ts | 156 +++++------- lib/vault.ts | 2 +- 82 files changed, 1643 insertions(+), 2030 deletions(-) rename app/{(gcforms)/[locale]/(app administration)/admin => }/unauthorized/page.tsx (85%) delete mode 100644 lib/actions/checkIfClosed.ts create mode 100644 lib/auth/errors.ts delete mode 100644 lib/invitations/tests/fixtures/Ability.ts create mode 100644 lib/pages/auth.ts diff --git a/__utils__/authorization.ts b/__utils__/authorization.ts index 727ce5eedd..94396f8f34 100644 --- a/__utils__/authorization.ts +++ b/__utils__/authorization.ts @@ -2,28 +2,30 @@ jest.mock("@lib/privileges"); import { authorization, getAbility } from "@lib/privileges"; import { UserAbility } from "@lib/types"; -import { AccessControlError } from "@lib/auth"; +import { AccessControlError } from "@lib/auth/errors"; type MockedAuthFunction = { [key: string]: jest.Mock; }; -export const mockAuthorizationPass = (userID: string) => { +export const mockAuthorizationPass = (userId: string) => { const mockedAuth: MockedAuthFunction = jest.mocked(authorization); for (const property in authorization) { mockedAuth[property] = jest .fn() - .mockImplementation(() => Promise.resolve({ user: { id: userID } })); + .mockImplementation(() => Promise.resolve({ user: { id: userId } })); } }; -export const mockAuthorizationFail = (userID: string) => { +export const mockAuthorizationFail = (userId: string) => { const mockedAuth: MockedAuthFunction = jest.mocked(authorization); - mockGetAbility(userID); + mockGetAbility(userId); for (const property in authorization) { mockedAuth[property] = jest .fn() - .mockImplementation(() => Promise.reject(new AccessControlError())); + .mockImplementation(() => + Promise.reject(new AccessControlError(userId, "AccessControlError")) + ); } }; diff --git a/__utils__/mocks/server-actions/index.ts b/__utils__/mocks/server-actions/index.ts index fe6f710e09..272c277a8a 100644 --- a/__utils__/mocks/server-actions/index.ts +++ b/__utils__/mocks/server-actions/index.ts @@ -13,11 +13,6 @@ jest.mock("@formBuilder/actions", () => ({ getTranslatedProperties: jest.fn(), })); -jest.mock("@formBuilder/actions", () => ({ - __esModule: true, - checkFlag: jest.fn(), -})); - jest.mock("@lib/actions/auth", () => ({ __esModule: true, authCheckAndThrow: jest.fn(), diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/layout.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/layout.tsx index 9288dbe6c7..d4a77ca714 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/layout.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/layout.tsx @@ -6,70 +6,70 @@ import { SiteLogo } from "@serverComponents/icons"; import LanguageToggle from "@serverComponents/globals/LanguageToggle"; import { YourAccountDropdown } from "@clientComponents/globals/Header/YourAccountDropdown"; -import { authCheckAndRedirect } from "@lib/actions"; + import { SkipLink } from "@serverComponents/globals/SkipLink"; import { Footer } from "@serverComponents/globals/Footer"; -export default async function Layout(props: { - children: React.ReactNode; - params: Promise<{ locale: string }>; -}) { - const params = await props.params; - - const { locale } = params; - - const { children } = props; +import { AuthenticatedLayout } from "@lib/pages/auth"; +import { authorization } from "@lib/privileges"; - await authCheckAndRedirect(); +export default AuthenticatedLayout( + [authorization.hasAdministrationPrivileges], + async ({ children, params }) => { + const { locale } = await params; - const { t } = await serverTranslation(["common", "admin-login"], { lang: locale }); + const { t } = await serverTranslation(["common", "admin-login"], { lang: locale }); - return ( -
-
- -
-
-
- - - + return ( +
+
+ +
+
+
+ + + -
- {t("title", { ns: "admin-login" })} +
+ {t("title", { ns: "admin-login" })} +
+
- +
+
+ + <> +
+
{children}
+
+
-
-
- - <> -
-
{children}
-
- +
-
-
- ); -} + ); + } +); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/page.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/page.tsx index 623f86b2e8..39cebd5e5a 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/page.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/page.tsx @@ -1,10 +1,7 @@ -import { authCheckAndRedirect } from "@lib/actions"; -import { checkPrivilegesAsBoolean } from "@lib/privileges"; import { serverTranslation } from "@i18n"; import Link from "next/link"; import { ManageAccountsIcon, SettingsApplicationsIcon } from "@serverComponents/icons"; import { Metadata } from "next"; -import { redirect } from "next/navigation"; export async function generateMetadata(props: { params: Promise<{ locale: string }>; @@ -27,16 +24,6 @@ export default async function Page(props: { params: Promise<{ locale: string }> const { t } = await serverTranslation(["admin-home", "common"]); - const { ability } = await authCheckAndRedirect(); - - const canViewUsers = checkPrivilegesAsBoolean(ability, [{ action: "view", subject: "User" }], { - redirect: true, - }); - - if (!canViewUsers) { - redirect(`/${locale}/forms`); - } - return ( <>

{t("title", { ns: "admin-home" })}

diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/upload/page.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/upload/page.tsx index 576e6e1e2f..efd122b173 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/upload/page.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/upload/page.tsx @@ -1,7 +1,6 @@ import JSONUpload from "@clientComponents/admin/JsonUpload/JsonUpload"; import { serverTranslation } from "@i18n"; -import { authCheckAndRedirect } from "@lib/actions"; -import { checkPrivilegesAsBoolean } from "@lib/privileges"; +import { authorization } from "@lib/privileges"; import { Metadata } from "next"; export async function generateMetadata(props: { @@ -19,12 +18,10 @@ export async function generateMetadata(props: { export default async function Page() { const { t } = await serverTranslation("admin-templates"); - - const { ability } = await authCheckAndRedirect(); - - checkPrivilegesAsBoolean(ability, [{ action: "update", subject: "FormRecord" }], { - redirect: true, + await authorization.canManageAllForms().catch(() => { + authorization.unauthorizedRedirect(); }); + return ( <>

{t("upload.title")}

diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/view-templates/actions.ts b/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/view-templates/actions.ts index ace9169a66..78677f9d24 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/view-templates/actions.ts +++ b/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/view-templates/actions.ts @@ -2,27 +2,15 @@ import { getAllTemplates } from "@lib/templates"; import { AuthenticatedAction } from "@lib/actions"; -import { FormRecord } from "@lib/types"; // Public facing functions - they can be used by anyone who finds the associated server action identifer -export const getTemplates = AuthenticatedAction(async () => { - const templates = await getAllTemplates(); - return filterTemplateProperties(templates); -}); - export const getLatestPublishedTemplates = AuthenticatedAction(async () => { const templates = await getAllTemplates({ requestedWhere: { isPublished: true }, sortByDateUpdated: "desc", }); - return filterTemplateProperties(templates); -}); - -// Internal and private functions - won't be converted into server actions - -const filterTemplateProperties = (templates: FormRecord[]) => { return templates.map((template) => { const { id, @@ -30,6 +18,7 @@ const filterTemplateProperties = (templates: FormRecord[]) => { isPublished, updatedAt, } = template; + return { id, titleEn, titleFr, isPublished, updatedAt }; }); -}; +}); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/view-templates/page.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/view-templates/page.tsx index 342c1b7d7f..b724970ea5 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/view-templates/page.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(no nav)/view-templates/page.tsx @@ -1,9 +1,9 @@ import { serverTranslation } from "@i18n"; -import { authCheckAndRedirect } from "@lib/actions"; -import { checkPrivilegesAsBoolean } from "@lib/privileges"; +import { authorization } from "@lib/privileges"; import { Metadata } from "next"; import { DataView } from "./clientSide"; -import { getTemplates } from "./actions"; +import { getAllTemplates } from "@lib/templates"; +import { AuthenticatedPage } from "@lib/pages/auth"; export async function generateMetadata(props: { params: Promise<{ locale: string }>; @@ -18,14 +18,19 @@ export async function generateMetadata(props: { }; } -export default async function Page() { - const { ability } = await authCheckAndRedirect(); +export default AuthenticatedPage([authorization.canManageAllForms], async () => { + const allTemplates = await getAllTemplates(); - checkPrivilegesAsBoolean(ability, [{ action: "update", subject: "FormRecord" }], { - redirect: true, - }); + const templatesToDataViewObject = allTemplates.map((template) => { + const { + id, + form: { titleEn, titleFr }, + isPublished, + updatedAt, + } = template; - const templates = await getTemplates(); + return { id, titleEn, titleFr, isPublished, updatedAt }; + }); - return ; -} + return ; +}); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms/actions.ts b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms/actions.ts index 9bb02bd56f..1f05eeff4c 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms/actions.ts +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms/actions.ts @@ -7,7 +7,7 @@ import { AuthenticatedAction } from "@lib/actions"; // Public facing functions - they can be used by anyone who finds the associated server action identifer -export const deleteForm = AuthenticatedAction(async (id: string) => { +export const deleteForm = AuthenticatedAction(async (_, id: string) => { try { await deleteTemplate(id); revalidatePath("app/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms"); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms/page.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms/page.tsx index a50d3156c0..088595a88d 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms/page.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms/page.tsx @@ -1,7 +1,7 @@ import { Suspense } from "react"; import { serverTranslation } from "@i18n"; -import { authCheckAndRedirect } from "@lib/actions"; -import { checkPrivilegesAsBoolean } from "@lib/privileges"; +import { AuthenticatedPage } from "@lib/pages/auth"; +import { authorization } from "@lib/privileges"; import { getUser } from "@lib/users"; import { BackLink } from "@clientComponents/admin/LeftNav/BackLink"; import { Metadata } from "next"; @@ -24,94 +24,83 @@ export async function generateMetadata(props: { }; } -export default async function Page(props: { params: Promise<{ id: string; locale: string }> }) { - const params = await props.params; - - const { id, locale } = params; - - const { ability } = await authCheckAndRedirect(); +export default AuthenticatedPage<{ id: string }>( + [authorization.canViewAllUsers, authorization.canViewAllForms], + async ({ params }) => { + const { id, locale } = await params; - checkPrivilegesAsBoolean( - ability, - [ - { action: "view", subject: "User" }, - { - action: "view", - subject: "FormRecord", - }, - ], - { redirect: true } - ); + const formUser = await getUser(id); - const formUser = await getUser(ability, id); - - const templates = ( - await getAllTemplates({ - requestedWhere: { - users: { - some: { - id, + const templates = ( + await getAllTemplates({ + requestedWhere: { + users: { + some: { + id, + }, }, }, - }, - }) - ).map((template) => { - const { - id, - form: { titleEn, titleFr }, - isPublished, - createdAt, - } = template; - - return { - id, - titleEn, - titleFr, - isPublished, - createdAt: Number(createdAt), - }; - }); - - const overdueTemplateIds = await getOverdueTemplateIds(templates.map((template) => template.id)); - - const { t } = await serverTranslation(["admin-forms", "admin-users"], { lang: locale }); - - return ( - <> -
- - {t("backToAccounts", { ns: "admin-users" })} - -

- {formUser && {formUser?.name}} - {formUser && {formUser?.email}} - {t("title")} -

-
- - {templates.length === 0 ? ( -
-

{t("noForms")}

+ }) + ).map((template) => { + const { + id, + form: { titleEn, titleFr }, + isPublished, + createdAt, + } = template; + + return { + id, + titleEn, + titleFr, + isPublished, + createdAt: Number(createdAt), + }; + }); + + const overdueTemplateIds = await getOverdueTemplateIds( + templates.map((template) => template.id) + ); + + const { t } = await serverTranslation(["admin-forms", "admin-users"], { lang: locale }); + + return ( + <> +
+ + {t("backToAccounts", { ns: "admin-users" })} + +

+ {formUser && {formUser?.name}} + {formUser && {formUser?.email}} + {t("title")} +

- ) : null} -
    - {templates.map(({ id, titleEn, titleFr, isPublished }) => { - const overdue = overdueTemplateIds.includes(id); - return ( - }> - - - ); - })} -
- - ); -} + {templates.length === 0 ? ( +
+

{t("noForms")}

+
+ ) : null} + +
    + {templates.map(({ id, titleEn, titleFr, isPublished }) => { + const overdue = overdueTemplateIds.includes(id); + return ( + }> + + + ); + })} +
+ + ); + } +); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-permissions/actions.ts b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-permissions/actions.ts index b4d360ab57..c245d8f492 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-permissions/actions.ts +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-permissions/actions.ts @@ -2,25 +2,21 @@ import { updatePrivilegesForUser } from "@lib/privileges"; import { revalidatePath } from "next/cache"; -import { authCheckAndThrow } from "@lib/actions"; +import { AuthenticatedAction } from "@lib/actions"; // Public facing functions - they can be used by anyone who finds the associated server action identifer -export const updatePrivileges = async ( - userID: string, - privilegeID: string, - action: "add" | "remove" -) => { - const { ability } = await authCheckAndThrow(); - - try { - const result = await updatePrivilegesForUser(ability, userID, [{ id: privilegeID, action }]); - revalidatePath( - "(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-permissions", - "page" - ); - return { data: result }; - } catch (e) { - return { error: "Failed to update permissions." }; +export const updatePrivileges = AuthenticatedAction( + async (_, userID: string, privilegeID: string, action: "add" | "remove") => { + try { + const result = await updatePrivilegesForUser(userID, [{ id: privilegeID, action }]); + revalidatePath( + "(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-permissions", + "page" + ); + return { data: result }; + } catch (e) { + return { error: "Failed to update permissions." }; + } } -}; +); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-permissions/page.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-permissions/page.tsx index 16e447d4ae..c0a650cb3e 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-permissions/page.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-permissions/page.tsx @@ -1,6 +1,6 @@ import { serverTranslation } from "@i18n"; -import { authCheckAndRedirect } from "@lib/actions"; -import { checkPrivilegesAsBoolean, getAllPrivileges } from "@lib/privileges"; +import { AuthenticatedPage } from "@lib/pages/auth"; +import { authorization, getAllPrivileges } from "@lib/privileges"; import { getUser } from "@lib/users"; import { BackLink } from "@clientComponents/admin/LeftNav/BackLink"; import { Metadata } from "next"; @@ -19,81 +19,77 @@ export async function generateMetadata(props: { }; } -export default async function Page(props: { params: Promise<{ id: string; locale: string }> }) { - const params = await props.params; - - const { id, locale } = params; +export default AuthenticatedPage<{ id: string }>( + [authorization.canViewAllUsers, authorization.canAccessPrivileges], + async ({ params }) => { + const { id, locale } = await params; - const { ability } = await authCheckAndRedirect(); + const formUser = await getUser(id); - checkPrivilegesAsBoolean( - ability, - [ - { action: "view", subject: "User" }, - { action: "view", subject: "Privilege" }, - ], - { logic: "all", redirect: true } - ); - const formUser = await getUser(ability, id as string); + const allPrivileges = (await getAllPrivileges()).map( + ({ id, name, descriptionFr, descriptionEn }) => ({ + id, + name, + descriptionFr, + descriptionEn, + }) + ); - const allPrivileges = (await getAllPrivileges(ability)).map( - ({ id, name, descriptionFr, descriptionEn }) => ({ - id, - name, - descriptionFr, - descriptionEn, - }) - ); + const canManageUsers = await authorization + .canManageAllUsers() + .then(() => true) + .catch(() => false); - const canManageUsers = checkPrivilegesAsBoolean(ability, [{ action: "update", subject: "User" }]); - const userPrivileges = allPrivileges.filter( - (privilege) => privilege.name === "Base" || privilege.name === "PublishForms" - ); + const userPrivileges = allPrivileges.filter( + (privilege) => privilege.name === "Base" || privilege.name === "PublishForms" + ); - const accountPrivileges = allPrivileges.filter( - (privilege) => - privilege.name === "ManageForms" || - privilege.name === "ViewUserPrivileges" || - privilege.name === "ManageUsers" - ); + const accountPrivileges = allPrivileges.filter( + (privilege) => + privilege.name === "ManageForms" || + privilege.name === "ViewUserPrivileges" || + privilege.name === "ManageUsers" + ); - const systemPrivileges = allPrivileges.filter( - (privilege) => - privilege.name === "ViewApplicationSettings" || privilege.name === "ManageApplicationSettings" - ); + const systemPrivileges = allPrivileges.filter( + (privilege) => + privilege.name === "ViewApplicationSettings" || + privilege.name === "ManageApplicationSettings" + ); - const { t } = await serverTranslation("admin-users", { lang: locale }); - return ( - <> - - {t("backToAccounts")} - -

-
- {formUser.name} - {formUser.email} -
- {t("managePermissions")} -

- {/* Toast Message goes here */} -

{t("userAdministration")}

- -

{t("accountAdministration")}

- -

{t("systemAdministration")}

- - - ); -} + const { t } = await serverTranslation("admin-users", { lang: locale }); + return ( + <> + + {t("backToAccounts")} + +

+
+ {formUser.name} + {formUser.email} +
+ {t("managePermissions")} +

+ {/* Toast Message goes here */} +

{t("userAdministration")}

+ +

{t("accountAdministration")}

+ +

{t("systemAdministration")}

+ + + ); + } +); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/actions.ts b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/actions.ts index 18c63ff1bb..0b034382f0 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/actions.ts +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/actions.ts @@ -1,35 +1,17 @@ "use server"; - -import { updatePrivilegesForUser, getPrivilege } from "@lib/privileges"; +import { AuthenticatedAction } from "@lib/actions"; +import { updatePrivilegesForUser } from "@lib/privileges"; import { revalidatePath } from "next/cache"; -import { getUsers, updateActiveStatus } from "@lib/users"; -import { authCheckAndThrow } from "@lib/actions"; - -// Public facing functions - they can be used by anyone who finds the associated server action identifer +import { updateActiveStatus } from "@lib/users"; -export const updatePublishing = async ( - userID: string, - publishFormsId: string, - action: "add" | "remove" -) => { - const { ability } = await authCheckAndThrow(); - await updatePrivilegesForUser(ability, userID, [{ id: publishFormsId, action }]); - revalidatePath("(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts", "page"); -}; +export const updatePublishing = AuthenticatedAction( + async (_, userID: string, publishFormsId: string, action: "add" | "remove") => { + await updatePrivilegesForUser(userID, [{ id: publishFormsId, action }]); + revalidatePath("(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts", "page"); + } +); -export const updateActive = async (userID: string, active: boolean) => { - const { ability } = await authCheckAndThrow(); - await updateActiveStatus(ability, userID, active); +export const updateActive = AuthenticatedAction(async (_, userID: string, active: boolean) => { + await updateActiveStatus(userID, active); revalidatePath("(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts", "page"); -}; - -export const getAllUsers = async (active?: boolean) => { - const { ability } = await authCheckAndThrow(); - return getUsers(ability, typeof active !== "undefined" ? { active } : undefined); -}; - -export const getPublishedFormsPrivilegeId = async () => { - const { ability } = await authCheckAndThrow(); - const publishPrivilege = await getPrivilege(ability, { name: "PublishForms" }); - return publishPrivilege?.id; -}; +}); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/components/server/UsersList.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/components/server/UsersList.tsx index d99d98f2be..491d5d5618 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/components/server/UsersList.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/components/server/UsersList.tsx @@ -1,24 +1,31 @@ import { UserCard } from "./UserCard"; -import { getPublishedFormsPrivilegeId, getAllUsers } from "../../actions"; - import { authCheckAndThrow } from "@lib/actions"; import { serverTranslation } from "@i18n"; import { Card } from "@serverComponents/globals/card/Card"; import { ScrollHelper } from "../client/ScrollHelper"; -import { checkPrivilegesAsBoolean } from "@lib/privileges"; +import { authorization, getPrivilege } from "@lib/privileges"; +import { getUsers } from "@lib/users"; export const UsersList = async ({ filter }: { filter?: string }) => { const { ability } = await authCheckAndThrow(); - const canManageUser = checkPrivilegesAsBoolean(ability, [{ action: "update", subject: "User" }]); - const canManageForms = checkPrivilegesAsBoolean(ability, [ - { action: "update", subject: "FormRecord" }, - ]); + const canManageUser = await authorization + .canManageAllUsers() + .then(() => true) + .catch(() => false); + const canManageForms = await authorization + .canManageAllForms() + .then(() => true) + .catch(() => false); + + const publishFormsId = await getPrivilege({ name: "PublishForms" }).then( + (privilege) => privilege?.id + ); - const publishFormsId = await getPublishedFormsPrivilegeId(); if (!publishFormsId) throw new Error("No publish forms privilege found in global privileges."); - const users = await getAllUsers(filter ? filter === "active" : undefined); + const users = await getUsers(filter ? { active: filter === "active" } : undefined); + const { t } = await serverTranslation("admin-users"); return ( diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/page.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/page.tsx index 9399b77375..ac2612cef4 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/page.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/page.tsx @@ -1,7 +1,7 @@ import { Suspense } from "react"; import { serverTranslation } from "@i18n"; -import { authCheckAndRedirect } from "@lib/actions"; -import { checkPrivilegesAsBoolean } from "@lib/privileges"; +import { AuthenticatedPage } from "@lib/pages/auth"; +import { authorization } from "@lib/privileges"; import { Metadata } from "next"; import { NavigtationFrame } from "./components/server/NavigationFrame"; import { Loader } from "@clientComponents/globals/Loader"; @@ -21,40 +21,28 @@ export async function generateMetadata(props: { }; } -export default async function Page(props: { - params: Promise<{ locale: string }>; - searchParams: Promise<{ userState?: string }>; -}) { - const searchParams = await props.searchParams; - - const { userState } = searchParams; - - const params = await props.params; - - const { locale } = params; - - const { ability } = await authCheckAndRedirect(); - - // Can the user view this page - checkPrivilegesAsBoolean( - ability, - [ - { action: "view", subject: "User" }, - { action: "view", subject: "Privilege" }, - ], - { logic: "all", redirect: true } - ); - - const { t } = await serverTranslation("admin-users", { lang: locale }); - - return ( - <> -

{t("accounts")}

- - }> - - - - - ); -} +export default AuthenticatedPage( + [authorization.canViewAllUsers, authorization.canAccessPrivileges], + async ({ params, searchParams }) => { + const { userState } = await searchParams; + + if (Array.isArray(userState)) { + throw new Error("Invalid user state, expected a string and received an array"); + } + + const { locale } = await params; + + const { t } = await serverTranslation("admin-users", { lang: locale }); + + return ( + <> +

{t("accounts")}

+ + }> + + + + + ); + } +); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/flags/actions.ts b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/flags/actions.ts index 205806f346..bf88db2677 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/flags/actions.ts +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/flags/actions.ts @@ -2,19 +2,17 @@ import { enableFlag, disableFlag } from "@lib/cache/flags"; import { revalidatePath } from "next/cache"; -import { authCheckAndThrow } from "@lib/actions"; +import { AuthenticatedAction } from "@lib/actions"; // Public facing functions - they can be used by anyone who finds the associated server action identifer // Note: any thrown errors will be caught in the Error boundary/component -export async function modifyFlag(id: string, value: boolean) { - const { ability } = await authCheckAndThrow(); - +export const modifyFlag = AuthenticatedAction(async (_, id: string, value: boolean) => { if (value) { - await enableFlag(ability, id); + await enableFlag(id); } else { - await disableFlag(ability, id); + await disableFlag(id); } revalidatePath("(gcforms)/[locale]/(app administration)/admin/(with nav)/flags", "page"); -} +}); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/flags/components/server/FlagList.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/flags/components/server/FlagList.tsx index 4f9a8ffb8c..0967f037a8 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/flags/components/server/FlagList.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/flags/components/server/FlagList.tsx @@ -1,11 +1,10 @@ import { serverTranslation } from "@i18n"; import { checkAll } from "@lib/cache/flags"; -import { UserAbility } from "@lib/types"; import { Flag } from "../client/Flag"; -export const FlagList = async ({ ability }: { ability: UserAbility }) => { +export const FlagList = async () => { const { t } = await serverTranslation("admin-flags"); - const flags = await checkAll(ability); + const flags = await checkAll(); return ( diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/flags/page.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/flags/page.tsx index 9335b608c1..70cee8df6d 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/flags/page.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/flags/page.tsx @@ -1,7 +1,7 @@ import { Suspense } from "react"; import { serverTranslation } from "@i18n"; -import { authCheckAndRedirect } from "@lib/actions"; -import { checkPrivilegesAsBoolean } from "@lib/privileges"; +import { authorization } from "@lib/privileges"; +import { AuthenticatedPage } from "@lib/pages/auth"; import { Metadata } from "next"; import { FlagList } from "./components/server/FlagList"; import { Loader } from "@clientComponents/globals/Loader"; @@ -19,11 +19,7 @@ export async function generateMetadata(props: { }; } -export default async function Page() { - const { ability } = await authCheckAndRedirect(); - - checkPrivilegesAsBoolean(ability, [{ action: "view", subject: "Flag" }], { redirect: true }); - +export default AuthenticatedPage([authorization.canAccessFlags], async () => { const { t } = await serverTranslation("admin-flags"); return ( @@ -31,8 +27,8 @@ export default async function Page() {

{t("title")}

{t("subTitle")}

}> - + ); -} +}); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/layout.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/layout.tsx index 2521dc4a86..fe8be2c912 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/layout.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/layout.tsx @@ -7,83 +7,84 @@ import LanguageToggle from "@serverComponents/globals/LanguageToggle"; import { YourAccountDropdown } from "@clientComponents/globals/Header/YourAccountDropdown"; import { SkipLink } from "@serverComponents/globals/SkipLink"; import { Footer } from "@serverComponents/globals/Footer"; +import { AuthenticatedLayout } from "@lib/pages/auth"; +import { authorization } from "@lib/privileges"; -export default async function Layout(props: { - children: React.ReactNode; - params: Promise<{ locale: string }>; -}) { - const params = await props.params; +export default AuthenticatedLayout( + [authorization.hasAdministrationPrivileges], + async ({ children, params }) => { + const { locale } = await params; - const { locale } = params; + const { t } = await serverTranslation(["common", "admin-login"], { lang: locale }); - const { children } = props; + return ( +
+
+ - const { t } = await serverTranslation(["common", "admin-login"], { lang: locale }); - - return ( -
-
- +
+
+
+ +
+ +
+ -
-
-
- -
- +
+ {t("title", { ns: "admin-login" })}
- - -
- {t("title", { ns: "admin-login" })}
-
-
-
-
- - - <> -
-
-
- +
  • + +
  • + + +
    +
    +
    + + + <> +
    +
    +
    + +
    +
    + {children} +
    -
    - {children} -
    -
    - -
    + +
    -
    +
    +
    - - ); -} + ); + } +); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/[settingId]/page.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/[settingId]/page.tsx index a5aac18d67..525a2a8d55 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/[settingId]/page.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/[settingId]/page.tsx @@ -1,10 +1,11 @@ import { serverTranslation } from "@i18n"; -import { checkPrivilegesAsBoolean } from "@lib/privileges"; +import { authorization } from "@lib/privileges"; +import { AuthenticatedPage } from "@lib/pages/auth"; import { Metadata } from "next"; import { ManageSettingForm } from "../components/server/ManageSettingForm"; import { Suspense } from "react"; import Loader from "@clientComponents/globals/Loader"; -import { authCheckAndRedirect } from "@lib/actions"; + export async function generateMetadata(props: { params: Promise<{ locale: string }>; }): Promise { @@ -18,25 +19,22 @@ export async function generateMetadata(props: { }; } -export default async function Page(props: { params: Promise<{ settingId: string }> }) { - const params = await props.params; - - const { settingId } = params; - - const { ability } = await authCheckAndRedirect(); - - checkPrivilegesAsBoolean(ability, [{ action: "update", subject: "Setting" }], { - redirect: true, - }); - - const { t } = await serverTranslation("admin-settings"); - - return ( - <> -

    {t("title-update")}

    - }> - - - - ); -} +export default AuthenticatedPage<{ settingId: string }>( + [authorization.canManageSettings], + async ({ params }) => { + const { settingId } = await params; + + await authorization.canManageSettings().catch(() => authorization.unauthorizedRedirect()); + + const { t } = await serverTranslation("admin-settings"); + + return ( + <> +

    {t("title-update")}

    + }> + + + + ); + } +); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/actions.ts b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/actions.ts index 3d1731db50..035374ef75 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/actions.ts +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/actions.ts @@ -1,78 +1,14 @@ "use server"; -import { - createAppSetting, - deleteAppSetting, - getFullAppSetting, - updateAppSetting, -} from "@lib/appSettings"; +import { deleteAppSetting } from "@lib/appSettings"; import { revalidatePath } from "next/cache"; -import { authCheckAndThrow } from "@lib/actions"; -import { redirect } from "next/navigation"; +import { AuthenticatedAction } from "@lib/actions"; // Public facing functions - they can be used by anyone who finds the associated server action identifer -export async function getSetting(internalId: string) { - const { ability } = await authCheckAndThrow(); - return getFullAppSetting(ability, internalId); -} - -export async function updateSetting(language: string, formData: FormData) { - try { - const { ability } = await authCheckAndThrow(); - const setting = { - internalId: nullCheck(formData, "internalId"), - nameEn: nullCheck(formData, "nameEn"), - nameFr: nullCheck(formData, "nameFr"), - descriptionEn: formData.get("descriptionEn") as string, - descriptionFr: formData.get("descriptionFr") as string, - value: nullCheck(formData, "value"), - }; - - await updateAppSetting(ability, setting.internalId, setting); - } catch (e) { - redirect(`/${language}/admin/settings?error=errorUpdating`); - } - redirect(`/${language}/admin/settings?success=updated`); -} - -export async function createSetting(language: string, formData: FormData) { - try { - const { ability } = await authCheckAndThrow(); - const setting = { - internalId: nullCheck(formData, "internalId"), - nameEn: nullCheck(formData, "nameEn"), - nameFr: nullCheck(formData, "nameFr"), - descriptionEn: formData.get("descriptionEn") as string, - descriptionFr: formData.get("descriptionFr") as string, - value: nullCheck(formData, "value"), - }; - await createAppSetting( - ability, - setting as { - internalId: string; - nameEn: string; - nameFr: string; - } - ); - } catch (e) { - redirect(`/${language}/admin/settings?error=errorCreating`); - } - redirect(`/${language}/admin/settings?success=created`); -} - -export async function deleteSetting(internalId: string) { - const { ability } = await authCheckAndThrow(); - await deleteAppSetting(ability, internalId).catch(() => { +export const deleteSetting = AuthenticatedAction(async (_, internalId: string) => { + await deleteAppSetting(internalId).catch(() => { throw new Error("Error deleting setting"); }); revalidatePath("(gcforms)/[locale]/(app administration)/admin/(with nav)/settings", "page"); -} - -// Internal and private functions - won't be converted into server actions - -function nullCheck(formData: FormData, key: string) { - const result = formData.get(key); - if (!result) throw new Error(`No value found for ${key}`); - return result as string; -} +}); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/components/server/ManageSettingForm.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/components/server/ManageSettingForm.tsx index 3e3681e6b0..9de3359c4e 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/components/server/ManageSettingForm.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/components/server/ManageSettingForm.tsx @@ -1,9 +1,10 @@ import { serverTranslation } from "@i18n"; -import { createSetting, getSetting, updateSetting } from "../../actions"; import { LinkButton } from "@serverComponents/globals/Buttons/LinkButton"; import { SaveButton } from "../client/SaveButton"; import { Danger } from "@clientComponents/globals/Alert/Alert"; import { Label } from "@clientComponents/forms"; +import { getFullAppSetting, createAppSetting, updateAppSetting } from "@lib/appSettings"; +import { redirect } from "next/navigation"; export const ManageSettingForm = async ({ settingId }: { settingId?: string }) => { const { @@ -17,7 +18,7 @@ export const ManageSettingForm = async ({ settingId }: { settingId?: string }) = if (settingId) { // An access control error will redirect the user to the login page // Other errors will hit the nearest error boundary - setting = await getSetting(settingId); + setting = await getFullAppSetting(settingId); } else { setting = { internalId: "", @@ -33,9 +34,13 @@ export const ManageSettingForm = async ({ settingId }: { settingId?: string }) = "use server"; // use server is needed to expose the function to the form component if (isCreateSetting) { - await createSetting(language, formData); + await createSetting(formData) + .then(redirect(`/${language}/admin/settings?success=created`)) + .catch(redirect(`/${language}/admin/settings?error=errorCreating`)); } else { - await updateSetting(language, formData); + await updateSetting(formData) + .then(redirect(`/${language}/admin/settings?success=updated`)) + .catch(redirect(`/${language}/admin/settings?error=errorUpdating`)); } }; @@ -126,3 +131,41 @@ export const ManageSettingForm = async ({ settingId }: { settingId?: string }) = ); }; + +async function createSetting(formData: FormData) { + const setting = { + internalId: nullCheck(formData, "internalId"), + nameEn: nullCheck(formData, "nameEn"), + nameFr: nullCheck(formData, "nameFr"), + descriptionEn: formData.get("descriptionEn") as string, + descriptionFr: formData.get("descriptionFr") as string, + value: nullCheck(formData, "value"), + }; + + await createAppSetting( + setting as { + internalId: string; + nameEn: string; + nameFr: string; + } + ); +} + +async function updateSetting(formData: FormData) { + const setting = { + internalId: nullCheck(formData, "internalId"), + nameEn: nullCheck(formData, "nameEn"), + nameFr: nullCheck(formData, "nameFr"), + descriptionEn: formData.get("descriptionEn") as string, + descriptionFr: formData.get("descriptionFr") as string, + value: nullCheck(formData, "value"), + }; + + await updateAppSetting(setting.internalId, setting); +} + +function nullCheck(formData: FormData, key: string) { + const result = formData.get(key); + if (!result) throw new Error(`No value found for ${key}`); + return result as string; +} diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/components/server/Settings.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/components/server/Settings.tsx index d823075ce2..63238640d5 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/components/server/Settings.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/components/server/Settings.tsx @@ -1,7 +1,7 @@ import React from "react"; import { LinkButton } from "@serverComponents/globals/Buttons/LinkButton"; import { DeleteSettingsButton } from "../client/DeleteSettingsButton"; -import { authCheckAndRedirect } from "@lib/actions"; +import { authorization } from "@lib/privileges"; import { getAllAppSettings } from "@lib/appSettings"; import { serverTranslation } from "@i18n"; @@ -11,14 +11,12 @@ export const Settings = async () => { i18n: { language }, } = await serverTranslation("admin-settings"); - const { ability } = await authCheckAndRedirect(); + const editAvailable = await authorization + .canManageSettings() + .then(() => true) + .catch(() => false); - // Note: could add a util to return an array if this is useful elsewhere - const canUpdateSettings = ability?.can("update", "Setting") ?? false; - const canCreateSettings = ability?.can("create", "Setting") ?? false; - const canDeleteSettings = ability?.can("delete", "Setting") ?? false; - - const settings = await getAllAppSettings(ability); + const settings = await getAllAppSettings(); return (
    @@ -39,14 +37,14 @@ export const Settings = async () => { href={`/${language}/admin/settings/${setting.internalId}`} className="mr-2" > - {canUpdateSettings ? t("manageSetting") : t("viewSetting")} + {editAvailable ? t("manageSetting") : t("viewSetting")} - {canDeleteSettings && } + {editAvailable && }
    ))} - {canCreateSettings && ( + {editAvailable && (
    {t("createSetting")} diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/create/page.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/create/page.tsx index c3db50455f..46ee72ca55 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/create/page.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/create/page.tsx @@ -1,6 +1,6 @@ import { serverTranslation } from "@i18n"; -import { authCheckAndThrow } from "@lib/actions"; -import { checkPrivilegesAsBoolean } from "@lib/privileges"; +import { authorization } from "@lib/privileges"; +import { AuthenticatedPage } from "@lib/pages/auth"; import { Metadata } from "next"; import { ManageSettingForm } from "../components/server/ManageSettingForm"; @@ -17,12 +17,7 @@ export async function generateMetadata(props: { }; } -export default async function Page() { - const { ability } = await authCheckAndThrow(); - checkPrivilegesAsBoolean(ability, [{ action: "create", subject: "Setting" }], { - redirect: true, - }); - +export default AuthenticatedPage([authorization.canManageSettings], async () => { const { t } = await serverTranslation("admin-settings"); return ( @@ -31,4 +26,4 @@ export default async function Page() { ); -} +}); diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/page.tsx b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/page.tsx index b2ec186540..0a8748f80b 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/page.tsx +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/settings/page.tsx @@ -1,11 +1,11 @@ import { serverTranslation } from "@i18n"; -import { authCheckAndRedirect } from "@lib/actions"; -import { checkPrivilegesAsBoolean } from "@lib/privileges"; +import { authorization } from "@lib/privileges"; import { Metadata } from "next"; import { Settings } from "./components/server/Settings"; import { Suspense } from "react"; import Loader from "@clientComponents/globals/Loader"; import { Messages } from "./components/client/Messages"; +import { AuthenticatedPage } from "@lib/pages/auth"; export async function generateMetadata(props: { params: Promise<{ locale: string }>; @@ -21,29 +21,26 @@ export async function generateMetadata(props: { } // Note: the searchParam is used as the language key to display the success or error message -export default async function Page(props: { - params: Promise<{ locale: string }>; - searchParams: Promise<{ success?: string; error?: string }>; -}) { - const searchParams = await props.searchParams; - - const { success, error } = searchParams; - - const { ability } = await authCheckAndRedirect(); - - checkPrivilegesAsBoolean(ability, [{ action: "view", subject: "Setting" }], { - redirect: true, - }); - - const { t } = await serverTranslation("admin-settings"); - - return ( - <> -

    {t("title")}

    - - }> - - - - ); -} +export default AuthenticatedPage( + [authorization.canAccessSettings], + async (props: { + params: Promise<{ locale: string }>; + searchParams: Promise<{ success?: string; error?: string }>; + }) => { + const searchParams = await props.searchParams; + + const { success, error } = searchParams; + + const { t } = await serverTranslation("admin-settings"); + + return ( + <> +

    {t("title")}

    + + }> + + + + ); + } +); diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/layout.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/layout.tsx index 66c2eaee10..a5ad679c99 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/layout.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/layout.tsx @@ -3,7 +3,7 @@ import { LeftNavigation } from "./components/LeftNavigation"; import { ToastContainer } from "@formBuilder/components/shared/Toast"; import { SkipLink, Footer } from "@clientComponents/globals"; import { Header } from "@clientComponents/globals/Header/Header"; -import { AccessControlError } from "@lib/auth"; +import { AccessControlError } from "@lib/auth/errors"; import { getFullTemplateByID } from "@lib/templates"; import { redirect } from "next/navigation"; import { SaveTemplateProvider } from "@lib/hooks/form-builder/useTemplateContext"; diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/preview/page.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/preview/page.tsx index f1826bdec4..31f2a364d1 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/preview/page.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/preview/page.tsx @@ -5,7 +5,7 @@ import { notFound } from "next/navigation"; import { Preview } from "./Preview"; import { allowGrouping } from "@formBuilder/components/shared/right-panel/treeview/util/allowGrouping"; import { ClientContainer } from "./ClientContainer"; -import { checkIfClosed } from "@lib/actions/checkIfClosed"; +import { checkIfClosed } from "@lib/templates"; import { ClosedDetails } from "@lib/types"; import { PreviewClosed } from "./PreviewClosed"; @@ -41,7 +41,8 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { return notFound(); } - const closedDetails = await checkIfClosed(id); + // A non authenticated user can't set a closing date on a form. + const closedDetails = session ? await checkIfClosed(id) : null; if (closedDetails && closedDetails.isPastClosingDate) { return ; diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/publish/page.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/publish/page.tsx index 5bdcf3a7ba..b93a6e6e98 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/publish/page.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/publish/page.tsx @@ -1,7 +1,7 @@ import { serverTranslation } from "@i18n"; import { Metadata } from "next"; import { Publish } from "./Publish"; -import { checkPrivilegesAsBoolean } from "@lib/privileges"; +import { authorization } from "@lib/privileges"; import { authCheckAndThrow } from "@lib/actions"; import Markdown from "markdown-to-jsx"; @@ -31,22 +31,18 @@ export default async function Page(props: { params: Promise<{ id: string; locale const { t } = await serverTranslation("form-builder", { lang: locale }); - const { session, ability } = await authCheckAndThrow().catch(() => ({ + const { session } = await authCheckAndThrow().catch(() => ({ session: null, - ability: null, })); if (!session) { return ; } - const userCanPublish = checkPrivilegesAsBoolean(ability, [ - { - action: "update", - subject: { type: "FormRecord", object: { users: [{ id: session.user.id }] } }, - field: "isPublished", - }, - ]); + const userCanPublish = await authorization + .canPublishForm(id) + .then(() => true) + .catch(() => false); return ( diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/actions.ts b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/actions.ts index 9735e5377c..c88ec9d396 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/actions.ts +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/actions.ts @@ -4,7 +4,6 @@ import { Language, FormServerErrorCodes, ServerActionError } from "@lib/types/fo import { getAppSetting } from "@lib/appSettings"; import { logEvent } from "@lib/auditLogs"; import { ucfirst } from "@lib/client/clientHelpers"; -import { getAbility } from "@lib/privileges"; import { Answer, CSVResponse, @@ -57,15 +56,18 @@ import { serverTranslation } from "@i18n"; // Public facing functions - they can be used by anyone who finds the associated server action identifer export const fetchSubmissions = AuthenticatedAction( - async ({ - formId, - status, - lastKey, - }: { - formId: string; - status: string; - lastKey: string | null; - }) => { + async ( + _, + { + formId, + status, + lastKey, + }: { + formId: string; + status: string; + lastKey: string | null; + } + ) => { try { if (!formId) { return { @@ -110,17 +112,20 @@ export const fetchSubmissions = AuthenticatedAction( ); export const getSubmissionsByFormat = AuthenticatedAction( - async ({ - formID, - ids, - format = DownloadFormat.HTML, - lang, - }: { - formID: string; - ids: string[]; - format: DownloadFormat; - lang: Language; - }): Promise< + async ( + session, + { + formID, + ids, + format = DownloadFormat.HTML, + lang, + }: { + formID: string; + ids: string[]; + format: DownloadFormat; + lang: Language; + } + ): Promise< | HtmlResponse | HtmlZippedResponse | HtmlAggregatedResponse @@ -299,7 +304,7 @@ export const getSubmissionsByFormat = AuthenticatedAction( }); await updateLastDownloadedBy(responseIdStatusArray, formID); - await logDownload(responseIdStatusArray, format); + await logDownload(responseIdStatusArray, format, session.user.id); switch (format) { case DownloadFormat.CSV: @@ -349,7 +354,7 @@ export const getSubmissionsByFormat = AuthenticatedAction( ); export const confirmSubmissionCodes = AuthenticatedAction( - async (confirmationCodes: string[], formId: string) => { + async (_, confirmationCodes: string[], formId: string) => { try { return confirmResponses(confirmationCodes, formId); } catch (e) { @@ -362,7 +367,7 @@ export const confirmSubmissionCodes = AuthenticatedAction( } ); -export const newResponsesExist = AuthenticatedAction(async (formId: string) => { +export const newResponsesExist = AuthenticatedAction(async (_, formId: string) => { try { return submissionTypeExists(formId, VaultStatus.NEW); } catch (error) { @@ -371,7 +376,7 @@ export const newResponsesExist = AuthenticatedAction(async (formId: string) => { } }); -export const unConfirmedResponsesExist = AuthenticatedAction(async (formId: string) => { +export const unConfirmedResponsesExist = AuthenticatedAction(async (_, formId: string) => { try { return submissionTypeExists(formId, VaultStatus.DOWNLOADED); } catch (error) { @@ -381,7 +386,7 @@ export const unConfirmedResponsesExist = AuthenticatedAction(async (formId: stri }); export const getSubmissionRemovalDate = AuthenticatedAction( - async (formId: string, submissionName: string) => { + async (_, formId: string, submissionName: string) => { try { return retrieveSubmissionRemovalDate(formId, submissionName); } catch (error) { @@ -437,15 +442,15 @@ const getAnswerAsString = (question: FormElement | undefined, answer: unknown): const logDownload = async ( responseIdStatusArray: { id: string; status: string }[], - format: DownloadFormat + format: DownloadFormat, + userId: string ) => { - const ability = await getAbility(); responseIdStatusArray.forEach((item) => { logEvent( - ability.user.id, + userId, { type: "Response", id: item.id }, "DownloadResponse", `Downloaded form response in ${format} for submission ID ${item.id}` ); }); -}; \ No newline at end of file +}; diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/ManageFormAccessDialog/actions.ts b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/ManageFormAccessDialog/actions.ts index e16f5da5c4..857d0d09f3 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/ManageFormAccessDialog/actions.ts +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/responses/[[...statusFilter]]/components/ManageFormAccessDialog/actions.ts @@ -1,9 +1,9 @@ "use server"; -import { authCheckAndThrow, AuthenticatedAction } from "@lib/actions"; +import { AuthenticatedAction } from "@lib/actions"; import { prisma } from "@lib/integration/prismaConnector"; import { TemplateUser } from "./types"; -import { AccessControlError } from "@lib/auth"; +import { AccessControlError } from "@lib/auth/errors"; import { InvalidDomainError, MismatchedEmailDomainError, @@ -20,74 +20,72 @@ import { logMessage } from "@lib/logger"; import { inviteUserByEmail } from "@lib/invitations/inviteUserByEmail"; import { cancelInvitation as cancelInvitationAction } from "@lib/invitations/cancelInvitation"; -// Public facing functions - they can be used by anyone who finds the associated server action identifer +export const sendInvitation = AuthenticatedAction( + async (_, emails: string[], templateId: string, message: string) => { + const { t } = await serverTranslation("manage-form-access"); -export const sendInvitation = async (emails: string[], templateId: string, message: string) => { - const { ability } = await authCheckAndThrow(); - const { t } = await serverTranslation("manage-form-access"); + const errors: string[] = []; - const errors: string[] = []; + const template = await getPublicTemplateByID(templateId); + if (!template?.isPublished) { + logMessage.error(`Invitation failed - draft form ${templateId}`); + errors.push(t("draftFormError")); - const template = await getPublicTemplateByID(templateId); - if (!template?.isPublished) { - logMessage.error(`Invitation failed - draft form ${templateId}`); - errors.push(t("draftFormError")); + return { + success: false, + errors, + }; + } - return { - success: false, - errors, - }; - } + const invites = emails.map(async (email) => { + try { + await inviteUserByEmail(email, templateId, message); + } catch (e) { + if (e instanceof UserAlreadyHasAccessError) { + errors.push(t("userAlreadyHasAccess", { email })); + } + if (e instanceof MismatchedEmailDomainError) { + errors.push(t("emailDomainMismatch", { email })); + } + if (e instanceof InvalidDomainError) { + errors.push(t("invalidEmail", { email })); + } + if (e instanceof TemplateNotFoundError) { + errors.push(t("templateNotFound", { templateId })); + throw e; // stop processing other emails + } + if (e instanceof AccessControlError) { + errors.push(t("accessControlError")); + throw e; // stop processing other emails + } + logMessage.error("Invitation failed", e); + errors.push(t("invitationFailed", { email })); + } + }); - const invites = emails.map(async (email) => { try { - await inviteUserByEmail(ability, email, templateId, message); + await Promise.allSettled(invites); } catch (e) { - if (e instanceof UserAlreadyHasAccessError) { - errors.push(t("userAlreadyHasAccess", { email })); - } - if (e instanceof MismatchedEmailDomainError) { - errors.push(t("emailDomainMismatch", { email })); - } - if (e instanceof InvalidDomainError) { - errors.push(t("invalidEmail", { email })); - } - if (e instanceof TemplateNotFoundError) { - errors.push(t("templateNotFound", { templateId })); - throw e; // stop processing other emails - } - if (e instanceof AccessControlError) { - errors.push(t("accessControlError")); - throw e; // stop processing other emails - } - logMessage.error("Invitation failed", e); - errors.push(t("invitationFailed", { email })); + return { + success: false, + errors, + }; } - }); - try { - await Promise.allSettled(invites); - } catch (e) { - return { - success: false, - errors, - }; - } + if (errors.length) { + return { + success: false, + errors, + }; + } - if (errors.length) { return { - success: false, - errors, + success: true, }; } +); - return { - success: true, - }; -}; - -export const removeUserFromForm = async (userId: string, formId: string) => { - await authCheckAndThrow(); +export const removeUserFromForm = AuthenticatedAction(async (_, userId: string, formId: string) => { try { await removeAssignedUserFromTemplate(formId, userId); return { @@ -100,9 +98,9 @@ export const removeUserFromForm = async (userId: string, formId: string) => { message: "Failed to remove user", }; } -}; +}); -export const getTemplateUsers = AuthenticatedAction(async (formId: string) => { +export const getTemplateUsers = AuthenticatedAction(async (_, formId: string) => { const template = await getTemplateWithAssociatedUsers(formId); if (!template) { @@ -132,7 +130,6 @@ export const getTemplateUsers = AuthenticatedAction(async (formId: string) => { return combinedUsers as TemplateUser[]; }); -export const cancelInvitation = async (id: string) => { - const { ability } = await authCheckAndThrow(); - cancelInvitationAction(ability, id); -}; +export const cancelInvitation = AuthenticatedAction(async (_, id: string) => { + cancelInvitationAction(id); +}); diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/actions.ts b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/actions.ts index a8a8f1af02..defdddd9a6 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/actions.ts +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/actions.ts @@ -1,13 +1,14 @@ "use server"; -import { createKey, deleteKey, refreshKey } from "@lib/serviceAccount"; +import { createKey, deleteKey } from "@lib/serviceAccount"; import { revalidatePath } from "next/cache"; import { promises as fs } from "fs"; import path from "path"; +import { AuthenticatedAction } from "@lib/actions"; // Public facing functions - they can be used by anyone who finds the associated server action identifer -export const getReadmeContent = async () => { +export const getReadmeContent = AuthenticatedAction(async () => { try { const readmePath = path.join(process.cwd(), "./public/static/api/Readme.md"); const content = await fs.readFile(readmePath, "utf-8"); @@ -15,23 +16,17 @@ export const getReadmeContent = async () => { } catch (e) { return { error: true }; } -}; +}); -// Privilege Checks are done at the lib/serviceAccount.ts level for the next server actions - -export const createServiceAccountKey = async (templateId: string) => { +export const createServiceAccountKey = AuthenticatedAction(async (_, templateId: string) => { revalidatePath( "/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/api", "page" ); return createKey(templateId); -}; - -export const refreshServiceAccountKey = async (templateId: string) => { - return refreshKey(templateId); -}; +}); -export const deleteServiceAccountKey = async (templateId: string) => { +export const deleteServiceAccountKey = AuthenticatedAction(async (_, templateId: string) => { try { await deleteKey(templateId); revalidatePath( @@ -42,4 +37,4 @@ export const deleteServiceAccountKey = async (templateId: string) => { } catch (e) { return { error: true, templateId: templateId }; } -}; +}); diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/components/utils.ts b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/components/utils.ts index 166c2c97c1..95296df7a2 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/components/utils.ts +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/components/utils.ts @@ -1,4 +1,4 @@ -import { createServiceAccountKey, refreshServiceAccountKey } from "../actions"; +import { createServiceAccountKey } from "../actions"; import JSZip from "jszip"; import { getReadmeContent } from "../actions"; @@ -19,11 +19,6 @@ export const _createKey = async (templateId: string) => { return key; }; -export const _refreshKey = async (templateId: string) => { - const key = await refreshServiceAccountKey(templateId); - return key; -}; - export const downloadKey = async (key: string, templateId: string) => { const zip = new JSZip(); diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/ThrottlingRate.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/ThrottlingRate.tsx index 399d069b00..87d85833b9 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/ThrottlingRate.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/ThrottlingRate.tsx @@ -3,11 +3,11 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "@i18n/client"; import { Alert, Button } from "@clientComponents/globals"; import { - getThrottling, - setPermanentThrottling, - setThrottlingExpiry, - resetThrottling, -} from "@lib/cache/throttlingCache"; + getCurrentThrottlingRate, + temporarilyIncreaseThrottlingRate, + permanentlyIncreaseThrottlingRate, + resetThrottlingRate, +} from "./actions"; import { Checkbox } from "@formBuilder/components/shared/MultipleChoice"; import { Input } from "@formBuilder/components/shared/Input"; import { toast, ToastContainer } from "@formBuilder/components/shared/Toast"; @@ -76,19 +76,34 @@ export const ThrottlingRate = ({ formId }: { formId: string }) => { setSubmitting(true); try { if (permanent) { - await setPermanentThrottling(formId); + const response = await permanentlyIncreaseThrottlingRate(formId); + + if (response !== undefined) { + throw new Error("Failed to permanently increase throttling rate"); + } + setSuccess(THROTTLE_EXPIRY.permanent); return; } if (weeks) { - await setThrottlingExpiry(formId, Number(weeks)); + const response = await temporarilyIncreaseThrottlingRate(formId, Number(weeks)); + + if (response !== undefined) { + throw new Error("Failed to temporarily increase throttling rate"); + } + setSuccess(THROTTLE_EXPIRY.weeks); return; } // Reset throttling back to default - await resetThrottling(formId); + const response = await resetThrottlingRate(formId); + + if (response !== undefined) { + throw new Error("Failed to reset throttling rate"); + } + setSuccess(THROTTLE_EXPIRY.default); } catch (error) { toast.error(t("throttling.error")); @@ -101,7 +116,14 @@ export const ThrottlingRate = ({ formId }: { formId: string }) => { useEffect(() => { const getThrottlingSetting = async () => { try { - const { rate, expires } = await getThrottling(formId); + const response = await getCurrentThrottlingRate(formId); + + if ("error" in response) { + throw new Error("Failed to get current throttling rate"); + } + + const { rate, expires } = response; + if (rate && expires < 0) { setWeeksDisabled(true); setPermanent(true); diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/actions.ts b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/actions.ts index ad9711f984..431b06cc27 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/actions.ts +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/actions.ts @@ -1,12 +1,68 @@ "use server"; -import { headers } from "next/headers"; +import { + getThrottling, + setThrottlingExpiry, + setPermanentThrottling, + resetThrottling, +} from "@lib/cache/throttlingCache"; +import { AuthenticatedAction } from "@lib/actions"; +import { ServerActionError } from "@lib/types/form-builder-types"; +import { logEvent } from "@lib/auditLogs"; // Public facing functions - they can be used by anyone who finds the associated server action identifer -// Can throw because it is not called by Client Components -// @todo Should these types of functions be moved to a different file? -export const getNonce = async () => { - const nonce = (await headers()).get("x-nonce"); - return nonce; -}; +export const getCurrentThrottlingRate = AuthenticatedAction(async (_, formId: string) => { + return getThrottling(formId).catch(() => { + return { error: "There was an error. Please try again later." } as ServerActionError; + }); +}); + +export const temporarilyIncreaseThrottlingRate = AuthenticatedAction( + async (session, formId: string, weeks: number) => { + try { + await setThrottlingExpiry(formId, weeks); + + logEvent( + session.user.id, + { type: "ServiceAccount" }, + "IncreaseThrottlingRate", + `User :${session.user.id} increased throttling rate on form ${formId} for ${weeks} week(s)` + ); + } catch (error) { + return { error: "There was an error. Please try again later." } as ServerActionError; + } + } +); + +export const permanentlyIncreaseThrottlingRate = AuthenticatedAction( + async (session, formId: string) => { + try { + await setPermanentThrottling(formId); + + logEvent( + session.user.id, + { type: "ServiceAccount" }, + "IncreaseThrottlingRate", + `User :${session.user.id} permanently increased throttling rate on form ${formId}` + ); + } catch (error) { + return { error: "There was an error. Please try again later." } as ServerActionError; + } + } +); + +export const resetThrottlingRate = AuthenticatedAction(async (session, formId: string) => { + try { + await resetThrottling(formId); + + logEvent( + session.user.id, + { type: "ServiceAccount" }, + "ResetThrottlingRate", + `User :${session.user.id} reset throttling rate on form ${formId}` + ); + } catch (error) { + return { error: "There was an error. Please try again later." } as ServerActionError; + } +}); diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/page.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/page.tsx index b5fee31060..51f76bd01f 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/page.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/page.tsx @@ -1,16 +1,13 @@ import { serverTranslation } from "@i18n"; -import { getTemplateWithAssociatedUsers } from "@lib/templates"; -import { authCheckAndThrow } from "@lib/actions"; -import { checkPrivilegesAsBoolean } from "@lib/privileges"; +import { getTemplateWithAssociatedUsers, checkIfClosed } from "@lib/templates"; +import { authorization } from "@lib/privileges"; import { getUsers } from "@lib/users"; import { ManageForm } from "./ManageForm"; import { Metadata } from "next"; -import { UserAbility } from "@lib/types"; -import { Session } from "next-auth"; -import { getNonce } from "./actions"; -import { checkIfClosed } from "@lib/actions/checkIfClosed"; +import { headers } from "next/headers"; import { ApiKeyDialog } from "../../components/dialogs/ApiKeyDialog/ApiKeyDialog"; import { DeleteApiKeyDialog } from "../../components/dialogs/DeleteApiKeyDialog/DeleteApiKeyDialog"; +import { AuthenticatedPage } from "@lib/pages/auth"; export async function generateMetadata(props: { params: Promise<{ locale: string }>; @@ -26,59 +23,25 @@ export async function generateMetadata(props: { }; } -const canManageAllForms = (formId: string, ability: UserAbility | null) => { - if (!ability || formId === "0000") { - return false; - } - - return checkPrivilegesAsBoolean(ability, [ - { - action: "view", - subject: "User", - }, - { - action: "view", - subject: "FormRecord", - }, - ]); -}; - -const getCanSetClosingDate = ( - formId: string, - ability: UserAbility | null, - session: Session | null -) => { - if (!ability || !session || formId === "0000") { - return false; - } - - return session ? true : false; -}; - -const getAllUsers = async (ability: UserAbility) => { - const users = await getUsers(ability); - return users.map((user) => ({ - id: user.id, - name: user.name || "", - email: user.email || "", - })); -}; - -export default async function Page(props: { params: Promise<{ id: string }> }) { +export default AuthenticatedPage(async (props: { params: Promise<{ id: string }> }) => { const params = await props.params; const { id } = params; - const { session, ability } = await authCheckAndThrow().catch(() => ({ - session: null, - ability: null, - })); - let closedDetails; - const manageAllForms = canManageAllForms(id, ability); - const canSetClosingDate = getCanSetClosingDate(id, ability, session); - const nonce = await getNonce(); + const manageAllForms = await authorization + .canManageAllForms() + .then(() => true) + .catch(() => false); + const canSetClosingDate = + id !== "0000" || + (await authorization + .canEditForm(id) + .then(() => true) + .catch(() => false)); + + const nonce = (await headers()).get("x-nonce"); if (canSetClosingDate) { const closedData = await checkIfClosed(id); @@ -97,13 +60,19 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { ); } - const templateWithAssociatedUsers = ability && (await getTemplateWithAssociatedUsers(id)); + const templateWithAssociatedUsers = await getTemplateWithAssociatedUsers(id); if (!templateWithAssociatedUsers) { throw new Error("Template not found"); } - const allUsers = await getAllUsers(ability); + const allUsers = await getUsers().then((users) => + users.map((user) => ({ + id: user.id, + name: user.name || "", + email: user.email || "", + })) + ); const isPublished = templateWithAssociatedUsers.formRecord.isPublished; const isVaultDelivery = !templateWithAssociatedUsers.formRecord.deliveryMethod; @@ -135,4 +104,4 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { )} ); -} +}); diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/page.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/page.tsx index 3addafd681..0d50747522 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/page.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/page.tsx @@ -1,8 +1,7 @@ import { serverTranslation } from "@i18n"; import { Metadata } from "next"; import { ResponseDelivery } from "./components/ResponseDelivery"; -import { authCheckAndRedirect } from "@lib/actions"; -import { checkPrivilegesAsBoolean } from "@lib/privileges"; +import { authorization } from "@lib/privileges"; import { ApiKeyDialog } from "../components/dialogs/ApiKeyDialog/ApiKeyDialog"; import { DeleteApiKeyDialog } from "../components/dialogs/DeleteApiKeyDialog/DeleteApiKeyDialog"; @@ -20,14 +19,10 @@ export async function generateMetadata(props: { } export default async function Page() { - const { ability } = await authCheckAndRedirect(); - - const isFormsAdmin = checkPrivilegesAsBoolean(ability, [ - { - action: "view", - subject: "FormRecord", - }, - ]); + const isFormsAdmin = await authorization + .canManageAllForms() + .then(() => true) + .catch(() => false); return ( <> diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/actions.ts b/app/(gcforms)/[locale]/(form administration)/form-builder/actions.ts index ebc6ac76e5..105275e440 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/actions.ts +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/actions.ts @@ -2,7 +2,6 @@ import { promises as fs } from "fs"; import { AuthenticatedAction } from "@lib/actions"; -import { getAbility } from "@lib/privileges"; import { DeliveryOption, FormProperties, @@ -24,7 +23,6 @@ import { } from "@lib/templates"; import { serverTranslation } from "@i18n"; import { revalidatePath } from "next/cache"; -import { checkOne } from "@lib/cache/flags"; import { isValidDateString } from "@lib/utils/date/isValidDateString"; import { allowedTemplates, TemplateTypes } from "@lib/utils/form-builder"; @@ -40,14 +38,17 @@ export type CreateOrUpdateTemplateType = { // Public facing functions - they can be used by anyone who finds the associated server action identifer export const createOrUpdateTemplate = AuthenticatedAction( - async ({ - id, - formConfig, - name, - deliveryOption, - securityAttribute, - formPurpose, - }: CreateOrUpdateTemplateType): Promise<{ + async ( + session, + { + id, + formConfig, + name, + deliveryOption, + securityAttribute, + formPurpose, + }: CreateOrUpdateTemplateType + ): Promise<{ formRecord: FormRecord | null; error?: string; }> => { @@ -65,10 +66,8 @@ export const createOrUpdateTemplate = AuthenticatedAction( }); } - const ability = await getAbility(); - const response = await createDbTemplate({ - userID: ability.user.id, + userID: session.user.id, formConfig: formConfig, name: name, deliveryOption: deliveryOption, @@ -90,21 +89,24 @@ export const createOrUpdateTemplate = AuthenticatedAction( ); export const updateTemplate = AuthenticatedAction( - async ({ - id: formID, - formConfig, - name, - deliveryOption, - securityAttribute, - formPurpose, - }: { - id: string; - formConfig: FormProperties; - name?: string; - deliveryOption?: DeliveryOption; - securityAttribute?: SecurityAttribute; - formPurpose?: FormPurpose; - }): Promise<{ + async ( + _, + { + id: formID, + formConfig, + name, + deliveryOption, + securityAttribute, + formPurpose, + }: { + id: string; + formConfig: FormProperties; + name?: string; + deliveryOption?: DeliveryOption; + securityAttribute?: SecurityAttribute; + formPurpose?: FormPurpose; + } + ): Promise<{ formRecord: FormRecord | null; error?: string; }> => { @@ -130,19 +132,22 @@ export const updateTemplate = AuthenticatedAction( ); export const updateTemplatePublishedStatus = AuthenticatedAction( - async ({ - id: formID, - isPublished, - publishReason, - publishFormType, - publishDescription, - }: { - id: string; - isPublished: boolean; - publishReason: string; - publishFormType: string; - publishDescription: string; - }): Promise<{ + async ( + _, + { + id: formID, + isPublished, + publishReason, + publishFormType, + publishDescription, + }: { + id: string; + isPublished: boolean; + publishReason: string; + publishFormType: string; + publishDescription: string; + } + ): Promise<{ formRecord: FormRecord | null; error?: string; }> => { @@ -170,13 +175,16 @@ export const updateTemplatePublishedStatus = AuthenticatedAction( ); export const updateTemplateFormPurpose = AuthenticatedAction( - async ({ - id: formID, - formPurpose, - }: { - id: string; - formPurpose: string; - }): Promise<{ + async ( + _, + { + id: formID, + formPurpose, + }: { + id: string; + formPurpose: string; + } + ): Promise<{ formRecord: FormRecord | null; error?: string; }> => { @@ -196,13 +204,16 @@ export const updateTemplateFormPurpose = AuthenticatedAction( ); export const updateTemplateSecurityAttribute = AuthenticatedAction( - async ({ - id: formID, - securityAttribute, - }: { - id: string; - securityAttribute: SecurityAttribute; - }): Promise<{ + async ( + _, + { + id: formID, + securityAttribute, + }: { + id: string; + securityAttribute: SecurityAttribute; + } + ): Promise<{ formRecord: FormRecord | null; error?: string; }> => { @@ -222,15 +233,18 @@ export const updateTemplateSecurityAttribute = AuthenticatedAction( ); export const closeForm = AuthenticatedAction( - async ({ - id: formID, - closingDate, - closedDetails, - }: { - id: string; - closingDate: string | null; - closedDetails?: ClosedDetails; - }): Promise<{ + async ( + _, + { + id: formID, + closingDate, + closedDetails, + }: { + id: string; + closingDate: string | null; + closedDetails?: ClosedDetails; + } + ): Promise<{ formID: string; closingDate: string | null; error?: string; @@ -259,13 +273,16 @@ export const closeForm = AuthenticatedAction( ); export const updateTemplateUsers = AuthenticatedAction( - async ({ - id: formID, - users, - }: { - id: string; - users: { id: string }[]; - }): Promise<{ + async ( + _, + { + id: formID, + users, + }: { + id: string; + users: { id: string }[]; + } + ): Promise<{ success: boolean; error?: string; }> => { @@ -289,13 +306,16 @@ export const updateTemplateUsers = AuthenticatedAction( ); export const updateTemplateDeliveryOption = AuthenticatedAction( - async ({ - id: formID, - deliveryOption, - }: { - id: string; - deliveryOption: DeliveryOption | undefined; - }): Promise<{ + async ( + _, + { + id: formID, + deliveryOption, + }: { + id: string; + deliveryOption: DeliveryOption | undefined; + } + ): Promise<{ formRecord: FormRecord | null; error?: string; }> => { @@ -319,11 +339,14 @@ export const updateTemplateDeliveryOption = AuthenticatedAction( ); export const sendResponsesToVault = AuthenticatedAction( - async ({ - id: formID, - }: { - id: string; - }): Promise<{ + async ( + _, + { + id: formID, + }: { + id: string; + } + ): Promise<{ success?: boolean; error?: string; }> => { @@ -377,10 +400,6 @@ export const getTranslatedDynamicRowProperties = async () => { }; }; -export async function checkFlag(id: string) { - return checkOne(id); -} - export const loadBlockTemplate = async ({ type, }: { diff --git a/app/(gcforms)/[locale]/(form administration)/forms/actions.ts b/app/(gcforms)/[locale]/(form administration)/forms/actions.ts index ae78d174c4..b297753af7 100644 --- a/app/(gcforms)/[locale]/(form administration)/forms/actions.ts +++ b/app/(gcforms)/[locale]/(form administration)/forms/actions.ts @@ -12,7 +12,7 @@ import { AuthenticatedAction } from "@lib/actions"; // Public facing functions - they can be used by anyone who finds the associated server action identifer export const getForm = AuthenticatedAction( - async (formId: string): Promise<{ formRecord: FormRecord | null; error?: string }> => { + async (_, formId: string): Promise<{ formRecord: FormRecord | null; error?: string }> => { try { const response = await getFullTemplateByID(formId).catch(() => { throw new Error("Failed to Get Form"); @@ -29,7 +29,7 @@ export const getForm = AuthenticatedAction( // Note: copied from manage-forms actions and added revalidatePath() export const deleteForm = AuthenticatedAction( - async (id: string): Promise => { + async (_, id: string): Promise => { try { await deleteTemplate(id).catch((error) => { if (error instanceof TemplateHasUnprocessedSubmissions) { diff --git a/app/(gcforms)/[locale]/(form administration)/forms/components/Invitations/actions.ts b/app/(gcforms)/[locale]/(form administration)/forms/components/Invitations/actions.ts index 05be2e24da..94be1a8b07 100644 --- a/app/(gcforms)/[locale]/(form administration)/forms/components/Invitations/actions.ts +++ b/app/(gcforms)/[locale]/(form administration)/forms/components/Invitations/actions.ts @@ -1,7 +1,6 @@ "use server"; import { serverTranslation } from "@i18n"; -import { authCheckAndThrow } from "@lib/actions"; import { acceptInvitation } from "@lib/invitations/acceptInvitation"; import { declineInvitation } from "@lib/invitations/declineInvitation"; import { @@ -10,15 +9,15 @@ import { UnableToAssignUserToTemplateError, UserNotFoundError, } from "@lib/invitations/exceptions"; +import { AuthenticatedAction } from "@lib/actions"; // Public facing functions - they can be used by anyone who finds the associated server action identifer -export const accept = async (id: string) => { - const { ability } = await authCheckAndThrow(); +export const accept = AuthenticatedAction(async (_, id: string) => { const { t } = await serverTranslation("manage-form-access"); try { - await acceptInvitation(ability, id); + await acceptInvitation(id); return true; } catch (e) { if (e instanceof InvitationNotFoundError) { @@ -34,17 +33,16 @@ export const accept = async (id: string) => { return { message: t("error") }; } } -}; +}); -export const decline = async (id: string) => { - const { ability } = await authCheckAndThrow(); +export const decline = AuthenticatedAction(async (_, id: string) => { const { t } = await serverTranslation("manage-form-access"); try { - await declineInvitation(ability, id); + await declineInvitation(id); } catch (e) { if (e instanceof InvitationNotFoundError) { return { message: t("invitationNotFound") }; } } -}; +}); diff --git a/app/(gcforms)/[locale]/(form administration)/forms/layout.tsx b/app/(gcforms)/[locale]/(form administration)/forms/layout.tsx index a81160ab13..e0f6a319ef 100644 --- a/app/(gcforms)/[locale]/(form administration)/forms/layout.tsx +++ b/app/(gcforms)/[locale]/(form administration)/forms/layout.tsx @@ -1,23 +1,14 @@ import React from "react"; import { ToastContainer } from "@formBuilder/components/shared/Toast"; import { Header } from "@clientComponents/globals/Header/Header"; -import { authCheckAndRedirect } from "@lib/actions"; import { SaveTemplateProvider } from "@lib/hooks/form-builder/useTemplateContext"; import { TemplateStoreProvider } from "@lib/store/useTemplateStore"; import { SkipLink } from "@serverComponents/globals/SkipLink"; import { Footer } from "@serverComponents/globals/Footer"; +import { AuthenticatedLayout } from "@lib/pages/auth"; -export default async function Layout(props: { - children: React.ReactNode; - params: Promise<{ locale: string }>; -}) { - const params = await props.params; - - const { locale } = params; - - const { children } = props; - - await authCheckAndRedirect(); +export default AuthenticatedLayout(async ({ children, params }) => { + const { locale } = await params; return ( @@ -36,4 +27,4 @@ export default async function Layout(props: { ); -} +}); diff --git a/app/(gcforms)/[locale]/(form administration)/forms/page.tsx b/app/(gcforms)/[locale]/(form administration)/forms/page.tsx index f6a3c9d525..ce996cfc27 100644 --- a/app/(gcforms)/[locale]/(form administration)/forms/page.tsx +++ b/app/(gcforms)/[locale]/(form administration)/forms/page.tsx @@ -1,7 +1,7 @@ import { serverTranslation } from "@i18n"; import { Metadata } from "next"; import { authCheckAndRedirect } from "@lib/actions"; -import { AccessControlError } from "@lib/auth"; +import { AccessControlError } from "@lib/auth/errors"; import { redirect } from "next/navigation"; import { Navigation } from "./components/server/Navigation"; import { Cards } from "./components/server/Cards"; diff --git a/app/(gcforms)/[locale]/(support)/unlock-publishing/actions.ts b/app/(gcforms)/[locale]/(support)/unlock-publishing/actions.ts index e10965d0a3..7b8a0d1964 100644 --- a/app/(gcforms)/[locale]/(support)/unlock-publishing/actions.ts +++ b/app/(gcforms)/[locale]/(support)/unlock-publishing/actions.ts @@ -1,6 +1,5 @@ "use server"; -import { authCheckAndRedirect } from "@lib/actions"; import { serverTranslation } from "@i18n"; import { createTicket } from "@lib/integration/freshdesk"; import { logMessage } from "@lib/logger"; @@ -16,6 +15,8 @@ import { toTrimmed, } from "valibot"; import { isValidGovEmail } from "@lib/validation/validation"; +import { AuthenticatedAction } from "@lib/actions"; +import { authorization } from "@lib/privileges"; export interface ErrorStates { validationErrors: { @@ -27,63 +28,62 @@ export interface ErrorStates { // Public facing functions - they can be used by anyone who finds the associated server action identifer -export async function unlockPublishing( - language: string, - userEmail: string, - _: ErrorStates, - formData: FormData -): Promise { - const { session } = await authCheckAndRedirect(); +export const unlockPublishing = AuthenticatedAction( + async (session, language: string, userEmail: string, _: ErrorStates, formData: FormData) => { + const rawData = Object.fromEntries(formData.entries()); + const validatedData = await validate(language, userEmail, rawData); - const rawData = Object.fromEntries(formData.entries()); - const validatedData = await validate(language, userEmail, rawData); + if (!validatedData.success) { + return { + validationErrors: validatedData.issues.map((issue) => ({ + fieldKey: issue.path?.[0].key as string, + fieldValue: issue.message, + })), + }; + } - if (!validatedData.success) { - return { - validationErrors: validatedData.issues.map((issue) => ({ - fieldKey: issue.path?.[0].key as string, - fieldValue: issue.message, - })), - }; - } + const { managerEmail, department, goals } = validatedData.output; - const { managerEmail, department, goals } = validatedData.output; + const emailBody = ` + ${session.user.name} (${session.user.email}) from ${department} has requested permission to publish forms.
    +
    + Goals:
    + ${goals}
    +
    + Manager email address: ${managerEmail} .

    + ****

    + ${session.user.name} (${session.user.email}) du ${department} a demandé l'autorisation de publier des formulaires.
    +
    + Objectifs:
    + ${goals}
    +
    + Adresse email du responsable: ${managerEmail} .
    + `; - const emailBody = ` - ${session.user.name} (${session.user.email}) from ${department} has requested permission to publish forms.
    -
    - Goals:
    - ${goals}
    -
    - Manager email address: ${managerEmail} .

    - ****

    - ${session.user.name} (${session.user.email}) du ${department} a demandé l'autorisation de publier des formulaires.
    -
    - Objectifs:
    - ${goals}
    -
    - Adresse email du responsable: ${managerEmail} .
    - `; + if (!session.user.name || !session.user.email) { + throw new Error("User name or email not found"); + } - if (!session.user.name || !session.user.email) { - throw new Error("User name or email not found"); - } + try { + const userHasPermissionToPublishForms = await authorization.hasPublishFormsPrivilege(); + if (userHasPermissionToPublishForms) + throw new Error("Permissiong to publish forms has already been granted"); - try { - await createTicket({ - type: "publishing", - name: session.user.name, - email: session.user.email, - description: emailBody, - language: language, - }); - } catch (error) { - logMessage.error(`Failed to unlock publishing: ${(error as Error).message}`); - return { error: "Failed to send request", validationErrors: [] }; - } + await createTicket({ + type: "publishing", + name: session.user.name, + email: session.user.email, + description: emailBody, + language: language, + }); + } catch (error) { + logMessage.error(`Failed to unlock publishing: ${(error as Error).message}`); + return { error: "Failed to send request", validationErrors: [] }; + } - return { error: "", validationErrors: [] }; -} + return { error: "", validationErrors: [] }; + } +); // Internal and private functions - won't be converted into server actions diff --git a/app/(gcforms)/[locale]/(support)/unlock-publishing/page.tsx b/app/(gcforms)/[locale]/(support)/unlock-publishing/page.tsx index ad5380c9a8..ecbfde8df0 100644 --- a/app/(gcforms)/[locale]/(support)/unlock-publishing/page.tsx +++ b/app/(gcforms)/[locale]/(support)/unlock-publishing/page.tsx @@ -1,9 +1,9 @@ import { serverTranslation } from "@i18n"; -import { checkPrivilegesAsBoolean } from "@lib/privileges"; +import { authorization } from "@lib/privileges"; import { Metadata } from "next"; import { RedirectType, redirect } from "next/navigation"; import { UnlockPublishingForm } from "./components/client/UnlockPublishingForm"; -import { authCheckAndRedirect } from "@lib/actions"; +import { AuthenticatedPage } from "@lib/pages/auth"; export async function generateMetadata(props: { params: Promise<{ locale: string }>; @@ -18,24 +18,14 @@ export async function generateMetadata(props: { }; } -export default async function Page(props: { params: Promise<{ locale: string }> }) { - const params = await props.params; - - const { locale } = params; +export default AuthenticatedPage(async ({ params, session }) => { + const { locale } = await params; - const { ability, session } = await authCheckAndRedirect(); + const hasPublishPrivilege = await authorization.hasPublishFormsPrivilege(); - if ( - checkPrivilegesAsBoolean(ability, [ - { - action: "update", - subject: { type: "FormRecord", object: { users: [{ id: session.user.id }] } }, - field: "isPublished", - }, - ]) - ) { + if (hasPublishPrivilege) { redirect(`/${locale}/forms`, RedirectType.replace); } return ; -} +}); diff --git a/app/(gcforms)/[locale]/(user authentication)/auth/mfa/actions.ts b/app/(gcforms)/[locale]/(user authentication)/auth/mfa/actions.ts index 3d39ce3816..ba0c614466 100644 --- a/app/(gcforms)/[locale]/(user authentication)/auth/mfa/actions.ts +++ b/app/(gcforms)/[locale]/(user authentication)/auth/mfa/actions.ts @@ -12,7 +12,7 @@ import { CredentialsSignin } from "next-auth"; import { getUnprocessedSubmissionsForUser } from "@lib/users"; import { logMessage } from "@lib/logger"; import { revalidatePath } from "next/cache"; -import { authCheckAndThrow } from "@lib/actions"; +import { AuthenticatedAction } from "@lib/actions"; export interface ErrorStates { authError?: { @@ -131,17 +131,7 @@ export const getErrorText = async (language: string, errorID: string) => { return handleErrorById(errorID, language); }; -export const getRedirectPath = async (locale: string) => { - const { session } = await authCheckAndThrow().catch(() => ({ - session: null, - })); - - if (!session) { - // The sessions between client and server are not in sync. - // Try to redirect to auth policy page and let logic handle there. - return { callback: `/${locale}/auth/policy` }; - } - +export const getRedirectPath = AuthenticatedAction(async (session, locale: string) => { if (session.user.newlyRegistered || !session.user.hasSecurityQuestions) { return { callback: `/${locale}/auth/setup-security-questions` }; } @@ -169,7 +159,7 @@ export const getRedirectPath = async (locale: string) => { } return { callback: `/${locale}/auth/policy` }; -}; +}); // Internal and private functions - won't be converted into server actions diff --git a/app/(gcforms)/[locale]/(user authentication)/auth/setup-security-questions/actions.ts b/app/(gcforms)/[locale]/(user authentication)/auth/setup-security-questions/actions.ts index 4b6f5b1d51..f75a823234 100644 --- a/app/(gcforms)/[locale]/(user authentication)/auth/setup-security-questions/actions.ts +++ b/app/(gcforms)/[locale]/(user authentication)/auth/setup-security-questions/actions.ts @@ -3,9 +3,8 @@ import * as v from "valibot"; import { serverTranslation } from "@i18n"; import { createSecurityAnswers } from "@lib/auth"; -import { getAbility } from "@lib/privileges"; import { logMessage } from "@lib/logger"; -import { authCheckAndThrow } from "@lib/actions"; +import { AuthenticatedAction } from "@lib/actions"; export interface ErrorStates { validationErrors?: { @@ -17,40 +16,35 @@ export interface ErrorStates { // Public facing functions - they can be used by anyone who finds the associated server action identifer -export const setupQuestions = async ( - language: string, - _: ErrorStates, - formData: FormData -): Promise => { - const { t } = await serverTranslation(["setup-security-questions"], { lang: language }); - const { session } = await authCheckAndThrow().catch(() => ({ session: null })); - if (!session) return { generalError: t("errors.serverError.title") }; +export const setupQuestions = AuthenticatedAction( + async (_, language: string, __: ErrorStates, formData: FormData): Promise => { + const { t } = await serverTranslation(["setup-security-questions"], { lang: language }); - const ability = await getAbility(); + const rawFormData = Object.fromEntries(formData.entries()); - const rawFormData = Object.fromEntries(formData.entries()); + const result = await validateData(rawFormData, language); - const result = await validateData(rawFormData, language); - if (!result.success) { - return { - validationErrors: result.issues.map((issue) => ({ - fieldKey: issue.path?.[0].key as string, - fieldValue: issue.message, - })), - }; - } + if (!result.success) { + return { + validationErrors: result.issues.map((issue) => ({ + fieldKey: issue.path?.[0].key as string, + fieldValue: issue.message, + })), + }; + } - return createSecurityAnswers(ability, [ - { questionId: result.output.question1, answer: result.output.answer1 }, - { questionId: result.output.question2, answer: result.output.answer2 }, - { questionId: result.output.question3, answer: result.output.answer3 }, - ]) - .then(() => ({})) - .catch((error) => { - logMessage.warn(error); - return { generalError: t("errors.serverError.title") }; - }); -}; + return createSecurityAnswers([ + { questionId: result.output.question1, answer: result.output.answer1 }, + { questionId: result.output.question2, answer: result.output.answer2 }, + { questionId: result.output.question3, answer: result.output.answer3 }, + ]) + .then(() => ({})) + .catch((error) => { + logMessage.warn(error); + return { generalError: t("errors.serverError.title") }; + }); + } +); // Internal and private functions - won't be converted into server actions diff --git a/app/(gcforms)/[locale]/(user authentication)/profile/action.ts b/app/(gcforms)/[locale]/(user authentication)/profile/action.ts index 42a3d2fb9a..10cfcd8036 100644 --- a/app/(gcforms)/[locale]/(user authentication)/profile/action.ts +++ b/app/(gcforms)/[locale]/(user authentication)/profile/action.ts @@ -1,35 +1,31 @@ "use server"; +import { AuthenticatedAction } from "@lib/actions"; import { updateSecurityAnswer } from "@lib/auth"; -import { authCheckAndThrow } from "@lib/actions"; import { revalidatePath } from "next/cache"; import * as v from "valibot"; // Public facing functions - they can be used by anyone who finds the associated server action identifer -export const updateSecurityQuestion = async ( - oldQuestionId: string, - newQuestionId: string, - answer: string | undefined -) => { - const { ability } = await authCheckAndThrow(); - - const data = validateData({ oldQuestionId, newQuestionId, newAnswer: answer }); - - if (!data.success) { - return { - error: "Data did not pass validation", - }; +export const updateSecurityQuestion = AuthenticatedAction( + async (_, oldQuestionId: string, newQuestionId: string, answer: string | undefined) => { + const data = validateData({ oldQuestionId, newQuestionId, newAnswer: answer }); + + if (!data.success) { + return { + error: "Data did not pass validation", + }; + } + + const response = await updateSecurityAnswer(data.output).catch(() => { + return { + error: "Failed to update security question", + }; + }); + revalidatePath("(gcforms)/[locale]/(user authentication)/profile"); + return response; } - - const response = await updateSecurityAnswer(ability, data.output).catch(() => { - return { - error: "Failed to update security question", - }; - }); - revalidatePath("(gcforms)/[locale]/(user authentication)/profile"); - return response; -}; +); // Internal and private functions - won't be converted into server actions diff --git a/app/(gcforms)/[locale]/(user authentication)/profile/page.tsx b/app/(gcforms)/[locale]/(user authentication)/profile/page.tsx index ed763c081c..e8931f4f03 100644 --- a/app/(gcforms)/[locale]/(user authentication)/profile/page.tsx +++ b/app/(gcforms)/[locale]/(user authentication)/profile/page.tsx @@ -1,9 +1,9 @@ import { serverTranslation } from "@i18n"; import { Metadata } from "next"; import { retrievePoolOfSecurityQuestions, retrieveUserSecurityQuestions } from "@lib/auth"; - import { Profile } from "./components/server/Profile"; import { authCheckAndRedirect } from "@lib/actions"; +import { authorization } from "@lib/privileges"; export async function generateMetadata(props: { params: Promise<{ locale: string }>; @@ -25,8 +25,7 @@ export default async function Page(props: { params: Promise<{ locale: string }> const { session, ability } = await authCheckAndRedirect(); - // Check is a user can update at least one FormRecord and has the privilege to publish - const hasPublishPrivilege = ability.can("update", "FormRecord", "isPublished"); + const userCanPublish = await authorization.hasPublishFormsPrivilege(); const [userQuestions, allQuestions] = await Promise.all([ retrieveUserSecurityQuestions({ userId: ability.user.id }), @@ -37,7 +36,7 @@ export default async function Page(props: { params: Promise<{ locale: string }> ); } diff --git a/app/api/id/[form]/submission/report/route.ts b/app/api/id/[form]/submission/report/route.ts index 578d19a04f..c08b5586d6 100644 --- a/app/api/id/[form]/submission/report/route.ts +++ b/app/api/id/[form]/submission/report/route.ts @@ -9,8 +9,8 @@ import { dynamoDBDocumentClient } from "@lib/integration/awsServicesConnector"; import { getAbility } from "@lib/privileges"; import { checkUserHasTemplateOwnership } from "@lib/templates"; import { logEvent } from "@lib/auditLogs"; +import { AccessControlError } from "@lib/auth/errors"; import { vaultStatusFromStatusCreatedAt } from "@lib/vault"; -import { AccessControlError } from "@lib/auth"; const MAXIMUM_SUBMISSION_NAMES_PER_REQUEST = 20; diff --git a/app/api/id/[form]/submission/unprocessed/route.ts b/app/api/id/[form]/submission/unprocessed/route.ts index 9a07b6c5e0..b6c3569cdc 100644 --- a/app/api/id/[form]/submission/unprocessed/route.ts +++ b/app/api/id/[form]/submission/unprocessed/route.ts @@ -1,4 +1,4 @@ -import { AccessControlError } from "@lib/auth"; +import { AccessControlError } from "@lib/auth/errors"; import { middleware, sessionExists } from "@lib/middleware"; import { NextResponse } from "next/server"; import { unprocessedSubmissions } from "@lib/vault"; diff --git a/app/api/templates/[formID]/route.ts b/app/api/templates/[formID]/route.ts index e83f4c787f..d735738c7e 100644 --- a/app/api/templates/[formID]/route.ts +++ b/app/api/templates/[formID]/route.ts @@ -19,7 +19,7 @@ import { uniqueIDValidator, } from "@lib/middleware/jsonIDValidator"; import { FormProperties, DeliveryOption, SecurityAttribute } from "@lib/types"; -import { AccessControlError } from "@lib/auth"; +import { AccessControlError } from "@lib/auth/errors"; import { logMessage } from "@lib/logger"; import { authCheckAndThrow } from "@lib/actions"; diff --git a/app/api/templates/route.ts b/app/api/templates/route.ts index 01508d789c..916c27540b 100644 --- a/app/api/templates/route.ts +++ b/app/api/templates/route.ts @@ -1,4 +1,4 @@ -import { AccessControlError } from "@lib/auth"; +import { AccessControlError } from "@lib/auth/errors"; import { middleware, sessionExists, jsonValidator } from "@lib/middleware"; import { createTemplate, diff --git a/app/(gcforms)/[locale]/(app administration)/admin/unauthorized/page.tsx b/app/unauthorized/page.tsx similarity index 85% rename from app/(gcforms)/[locale]/(app administration)/admin/unauthorized/page.tsx rename to app/unauthorized/page.tsx index 02b38f6bc7..47628f2407 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/unauthorized/page.tsx +++ b/app/unauthorized/page.tsx @@ -2,7 +2,7 @@ import React from "react"; import { serverTranslation } from "@i18n"; import { ErrorPanel } from "@clientComponents/globals/ErrorPanel"; import { Metadata } from "next"; -import { authCheckAndRedirect } from "@lib/actions"; +import { AuthenticatedPage } from "@lib/pages/auth"; export async function generateMetadata(props: { params: Promise<{ locale: string }>; @@ -17,9 +17,9 @@ export async function generateMetadata(props: { }; } -export default async function Page() { +export default AuthenticatedPage(async () => { const { t } = await serverTranslation("admin-login"); - await authCheckAndRedirect(); + return (
    @@ -27,4 +27,4 @@ export default async function Page() {
    ); -} +}); diff --git a/i18n/index.ts b/i18n/index.ts index 344683fde0..2d0f0c0f96 100644 --- a/i18n/index.ts +++ b/i18n/index.ts @@ -1,5 +1,3 @@ -"use server"; - import { createInstance } from "i18next"; import resourcesToBackend from "i18next-resources-to-backend"; import { initReactI18next } from "react-i18next/initReactI18next"; @@ -7,8 +5,6 @@ import { getOptions, languages } from "./settings"; import { headers, cookies } from "next/headers"; import { pathLanguageDetection } from "./utils"; -// Public facing functions - they can be used by anyone who finds the associated server action identifer - export async function serverTranslation( ns?: string | string[], options?: { keyPrefix?: string; lang?: string } @@ -33,8 +29,6 @@ export async function getCurrentLanguage() { return pathLang || cookieLang || languages[0]; } -// Internal and private functions - won't be converted into server actions - const initI18next = async (lang: string, ns: string | string[]) => { const i18nInstance = createInstance(); await i18nInstance diff --git a/i18n/translations/en/form-builder.json b/i18n/translations/en/form-builder.json index be53924bc0..145746ca81 100644 --- a/i18n/translations/en/form-builder.json +++ b/i18n/translations/en/form-builder.json @@ -635,7 +635,6 @@ "keyId": "API key ID", "title": "API access", "generateKey": "Create API key", - "refreshKey": "Refresh key", "deleteKey": "Delete API key", "deleteKeyFailed": { "title": "Something went wrong", diff --git a/i18n/translations/fr/form-builder.json b/i18n/translations/fr/form-builder.json index d962ae62d6..fc58d0dc60 100644 --- a/i18n/translations/fr/form-builder.json +++ b/i18n/translations/fr/form-builder.json @@ -635,7 +635,6 @@ "keyId": "Identifiant de la clé API", "title": "Accès pour l'API", "generateKey": "Créer une clé API", - "refreshKey": "Rafraîchir la clé", "deleteKey": "Supprimer la clé API", "deleteKeyFailed": { "title": "Quelque chose a mal tourné", diff --git a/lib/actions/auth.ts b/lib/actions/auth.ts index 3dfc07ec9b..2edfcad01f 100644 --- a/lib/actions/auth.ts +++ b/lib/actions/auth.ts @@ -3,9 +3,10 @@ import { auth } from "@lib/auth"; import { redirect } from "next/navigation"; import { getCurrentLanguage } from "@i18n"; import { getAbility } from "@lib/privileges"; +import { Session } from "next-auth"; export const AuthenticatedAction = ( - action: (...args: Input) => Promise + action: (session: Session, ...args: Input) => Promise ) => { return async (...args: Input) => { const session = await auth(); @@ -13,7 +14,7 @@ export const AuthenticatedAction = ( const language = await getCurrentLanguage(); redirect(`/${language}/auth/login`); } - return action(...args); + return action(session, ...args); }; }; diff --git a/lib/actions/checkIfClosed.ts b/lib/actions/checkIfClosed.ts deleted file mode 100644 index 0843c59021..0000000000 --- a/lib/actions/checkIfClosed.ts +++ /dev/null @@ -1,42 +0,0 @@ -"use server"; - -import { prisma, prismaErrors } from "@lib/integration/prismaConnector"; -import { ClosedDetails } from "@lib/types"; -import { dateHasPast } from "@lib/utils"; - -// Public facing functions - they can be used by anyone who finds the associated server action identifer - -export const checkIfClosed = async (formId: string) => { - try { - let isPastClosingDate = false; - - // Note these are the only fields we need from the template - // They are public fields so no auth check is needed see _unprotectedGetTemplateByID - const template = await prisma.template - .findUnique({ - where: { - id: formId, - }, - select: { - closingDate: true, - closedDetails: true, - }, - }) - .catch((e) => prismaErrors(e, null)); - - if (!template) { - throw new Error("Template not found"); - } - - if (template.closingDate) { - isPastClosingDate = dateHasPast(Date.parse(String(template.closingDate))); - } - - return { - isPastClosingDate, - closedDetails: template.closedDetails as ClosedDetails, - }; - } catch (e) { - return null; - } -}; diff --git a/lib/appSettings.ts b/lib/appSettings.ts index 76554b534b..3ad033b866 100644 --- a/lib/appSettings.ts +++ b/lib/appSettings.ts @@ -1,36 +1,32 @@ import { prisma, prismaErrors } from "@lib/integration/prismaConnector"; -import { checkPrivileges } from "./privileges"; -import { AccessControlError } from "@lib/auth"; +import { authorization } from "./privileges"; +import { AccessControlError } from "@lib/auth/errors"; import { logEvent } from "./auditLogs"; import { logMessage } from "@lib/logger"; -import { UserAbility } from "./types"; import { settingCheck, settingPut, settingDelete } from "@lib/cache/settingCache"; -export const getAllAppSettings = async (ability: UserAbility) => { - try { - checkPrivileges(ability, [{ action: "view", subject: "Setting" }]); - const settings = await prisma.setting - .findMany({ - select: { - internalId: true, - nameEn: true, - nameFr: true, - descriptionEn: true, - descriptionFr: true, - value: true, - }, - }) - .catch((e) => prismaErrors(e, [])); - logEvent(ability.user.id, { type: "Setting" }, "ListAllSettings"); - return settings; - } catch (e) { +export const getAllAppSettings = async () => { + const { user } = await authorization.canAccessSettings().catch((e) => { if (e instanceof AccessControlError) { - logEvent(ability.user.id, { type: "Setting" }, "AccessDenied", "Attempted to list all Forms"); - throw e; + logEvent(e.user.id, { type: "Setting" }, "AccessDenied", "Attempted to list all Settings"); } - logMessage.error(e); - return []; - } + throw e; + }); + + const settings = await prisma.setting + .findMany({ + select: { + internalId: true, + nameEn: true, + nameFr: true, + descriptionEn: true, + descriptionFr: true, + value: true, + }, + }) + .catch((e) => prismaErrors(e, [])); + logEvent(user.id, { type: "Setting" }, "ListAllSettings"); + return settings; }; export const getAppSetting = async (internalId: string) => { @@ -54,8 +50,18 @@ export const getAppSetting = async (internalId: string) => { return uncachedSetting?.value ?? null; }; -export const getFullAppSetting = async (ability: UserAbility, internalId: string) => { - checkPrivileges(ability, [{ action: "view", subject: "Setting" }]); +export const getFullAppSetting = async (internalId: string) => { + const { user } = await authorization.canAccessSettings().catch((e) => { + if (e instanceof AccessControlError) { + logEvent( + e.user.id, + { type: "Setting", id: internalId }, + "AccessDenied", + "Attempted to list full app Setting" + ); + } + throw e; + }); // Note: the setting is not cached here because it's not expected to be called frequently const result = prisma.setting .findUnique({ @@ -72,19 +78,10 @@ export const getFullAppSetting = async (ability: UserAbility, internalId: string }, }) .catch((e) => { - if (e instanceof AccessControlError) { - logEvent( - ability.user.id, - { type: "Setting", id: internalId }, - "AccessDenied", - "Attempted to get full setting" - ); - throw e; - } prismaErrors(e, null); }); - logEvent(ability.user.id, { type: "Setting" }, "ListSetting"); + logEvent(user.id, { type: "Setting" }, "ListSetting"); return result; }; @@ -95,47 +92,45 @@ interface SettingUpdateData { descriptionFr?: string; value?: string; } -export const updateAppSetting = async ( - ability: UserAbility, - internalId: string, - settingData: SettingUpdateData -) => { - try { - checkPrivileges(ability, [{ action: "update", subject: "Setting" }]); - - const updatedSetting = await prisma.setting.update({ - where: { - internalId, - }, - data: settingData, - }); - logEvent( - ability.user.id, - { type: "Setting", id: internalId }, - "ChangeSetting", - `Updated setting with ${JSON.stringify(settingData)}` - ); - if (settingData.value) { - settingPut(internalId, settingData.value); - } - return updatedSetting; - } catch (e) { +export const updateAppSetting = async (internalId: string, settingData: SettingUpdateData) => { + const { user } = await authorization.canManageSettings().catch((e) => { if (e instanceof AccessControlError) { logEvent( - ability.user.id, + e.user.id, { type: "Setting", id: internalId }, "AccessDenied", "Attempted to update setting" ); - throw e; } - if (e instanceof Error) { + throw e; + }); + + const updatedSetting = await prisma.setting + .update({ + where: { + internalId, + }, + data: settingData, + }) + .catch((e) => { logMessage.warn( `Could not update setting with internalId: ${internalId} due to error: ${e.message}` ); - } - return null; + return prismaErrors(e, null); + }); + // If there was a prisma error return early + if (updatedSetting === null) return null; + + logEvent( + user.id, + { type: "Setting", id: internalId }, + "ChangeSetting", + `Updated setting with ${JSON.stringify(settingData)}` + ); + if (settingData.value) { + settingPut(internalId, settingData.value); } + return updatedSetting; }; interface SettingCreateData extends SettingUpdateData { @@ -143,67 +138,72 @@ interface SettingCreateData extends SettingUpdateData { nameEn: string; nameFr: string; } -export const createAppSetting = async (ability: UserAbility, settingData: SettingCreateData) => { - try { - checkPrivileges(ability, [{ action: "create", subject: "Setting" }]); - - const createdSetting = await prisma.setting.create({ - data: settingData, - }); - logEvent( - ability.user.id, - { type: "Setting", id: createdSetting.internalId }, - "CreateSetting", - `Created setting with ${JSON.stringify(settingData)}` - ); - if (settingData.value) { - settingPut(settingData.internalId, settingData.value); - } - return createdSetting; - } catch (e) { +export const createAppSetting = async (settingData: SettingCreateData) => { + const { user } = await authorization.canManageSettings().catch((e) => { if (e instanceof AccessControlError) { - logEvent(ability.user.id, { type: "Setting" }, "AccessDenied", "Attempted to create setting"); - throw e; + logEvent(e.user.id, { type: "Setting" }, "AccessDenied", "Attempted to create setting"); } - if (e instanceof Error) { + throw e; + }); + + const createdSetting = await prisma.setting + .create({ + data: settingData, + }) + .catch((e) => { logMessage.warn( `Could not create setting with name: ${settingData.nameEn} due to error: ${e.message}` ); - } - return null; + return prismaErrors(e, null); + }); + // If there was a prisma error return early + if (createdSetting === null) return null; + + logEvent( + user.id, + { type: "Setting", id: createdSetting.internalId }, + "CreateSetting", + `Created setting with ${JSON.stringify(settingData)}` + ); + if (settingData.value) { + settingPut(settingData.internalId, settingData.value); } + return createdSetting; }; -export const deleteAppSetting = async (ability: UserAbility, internalId: string) => { - try { - checkPrivileges(ability, [{ action: "delete", subject: "Setting" }]); - - const deletedSetting = await prisma.setting.delete({ - where: { - internalId, - }, - }); - logEvent( - ability.user.id, - { type: "Setting", id: internalId }, - "DeleteSetting", - `Deleted setting with ${JSON.stringify(deletedSetting)}` - ); - settingDelete(internalId); - } catch (e) { +export const deleteAppSetting = async (internalId: string) => { + const { user } = await authorization.canManageSettings().catch((e) => { if (e instanceof AccessControlError) { logEvent( - ability.user.id, - { id: internalId, type: "Setting" }, + e.user.id, + { type: "Setting", id: internalId }, "AccessDenied", "Attempted to delete setting" ); - throw e; } - if (e instanceof Error) { + throw e; + }); + + const deletedSetting = await prisma.setting + .delete({ + where: { + internalId, + }, + }) + .catch((e) => { logMessage.warn( `Could not delete setting with internalId: ${internalId} due to error: ${e.message}` ); - } - } + return prismaErrors(e, null); + }); + // return early if there was a prisma error + if (deletedSetting === null) return null; + + logEvent( + user.id, + { type: "Setting", id: internalId }, + "DeleteSetting", + `Deleted setting with ${JSON.stringify(deletedSetting)}` + ); + settingDelete(internalId); }; diff --git a/lib/auditLogs.ts b/lib/auditLogs.ts index debc6a5100..33d6e7b54a 100644 --- a/lib/auditLogs.ts +++ b/lib/auditLogs.ts @@ -37,6 +37,8 @@ export enum AuditLogEvent { UserTooManyFailedAttempts = "UserTooManyFailedAttempts", GrantPrivilege = "GrantPrivilege", RevokePrivilege = "RevokePrivilege", + CreateSecurityAnswers = "CreateSecurityAnswers", + ChangeSecurityAnswers = "ChangeSecurityAnswers", // Application events EnableFlag = "EnableFlag", DisableFlag = "DisableFlag", @@ -49,8 +51,9 @@ export enum AuditLogEvent { AccessDenied = "AccessDenied", // API Management CreateAPIKey = "CreateAPIKey", - RefreshAPIKey = "RefreshAPIKey", DeleteAPIKey = "DeleteAPIKey", + IncreaseThrottlingRate = "IncreaseThrottlingRate", + ResetThrottlingRate = "ResetThrottlingRate", } export type AuditLogEventStrings = keyof typeof AuditLogEvent; @@ -86,12 +89,11 @@ const getQueueURL = async () => { }; export const logEvent = async ( - userId: string | Promise, + userId: string, subject: { type: keyof typeof AuditSubjectType; id?: string }, event: AuditLogEventStrings, description?: string ): Promise => { - if (userId instanceof Promise) userId = await userId; const auditLog = JSON.stringify({ userId, event, diff --git a/lib/auth/errors.ts b/lib/auth/errors.ts new file mode 100644 index 0000000000..1b19964d6c --- /dev/null +++ b/lib/auth/errors.ts @@ -0,0 +1,14 @@ +// Creates a new custom Error Class +export class AccessControlError extends Error { + public user: { + id: string; + }; + + constructor(userId: string, message: string) { + super(message); + Object.setPrototypeOf(this, AccessControlError.prototype); + this.user = { + id: userId, + }; + } +} diff --git a/lib/auth/index.ts b/lib/auth/index.ts index 07fb94c327..9df2d1431a 100644 --- a/lib/auth/index.ts +++ b/lib/auth/index.ts @@ -41,21 +41,3 @@ export { } from "./passwordReset"; export { GET, POST, auth, signIn, signOut } from "./nextAuth"; - -import { getAbility } from "@lib/privileges"; - -// Creates a new custom Error Class -export class AccessControlError extends Error { - public user: { - id: Promise; - } = { - id: getAbility() - .then((ability) => ability.user.id) - .catch(() => "unauthenticated"), - }; - - constructor(message: string = "AccessControlError") { - super(message); - Object.setPrototypeOf(this, AccessControlError.prototype); - } -} diff --git a/lib/auth/securityQuestions.ts b/lib/auth/securityQuestions.ts index cff45f865c..640b426570 100644 --- a/lib/auth/securityQuestions.ts +++ b/lib/auth/securityQuestions.ts @@ -1,7 +1,8 @@ import { prisma, prismaErrors } from "@lib/integration/prismaConnector"; -import { UserAbility } from "@lib/types"; import { scrypt, randomBytes } from "crypto"; -import { checkPrivileges } from "@lib/privileges"; +import { authorization } from "@lib/privileges"; +import { AccessControlError } from "@lib/auth/errors"; +import { logEvent } from "@lib/auditLogs"; export type SecurityQuestionId = string; @@ -85,17 +86,16 @@ export async function retrievePoolOfSecurityQuestions(): Promise { - checkPrivileges(ability, [ - { - action: "update", - subject: { type: "User", object: { id: ability.user.id } }, - field: "securityAnswers", - }, - ]); - const userSecurityAnswers = await _retrieveUserSecurityAnswers({ userId: ability.user.id }); + const { user } = await authorization.canUpdateSecurityQuestions().catch((e) => { + if (e instanceof AccessControlError) { + logEvent(e.user.id, { type: "User" }, "AccessDenied", "Attempted to create security answers"); + } + throw e; + }); + + const userSecurityAnswers = await _retrieveUserSecurityAnswers({ userId: user.id }); if (userSecurityAnswers.length > 0) throw new AlreadyHasSecurityAnswers(); const questionIds = questionsWithAssociatedAnswers.map( @@ -124,7 +124,7 @@ export async function createSecurityAnswers( const operationResult = await prisma.user .update({ where: { - id: ability.user.id, + id: user.id, }, data: { securityAnswers: { @@ -135,24 +135,20 @@ export async function createSecurityAnswers( .catch((e) => prismaErrors(e, null)); if (!operationResult) throw new SecurityQuestionDatabaseOperationFailed(); + logEvent(user.id, { type: "User" }, "CreateSecurityAnswers"); } -export async function updateSecurityAnswer( - ability: UserAbility, - command: UpdateSecurityAnswerCommand -): Promise { - checkPrivileges(ability, [ - { - action: "update", - subject: { type: "User", object: { id: ability.user.id } }, - field: "securityAnswers", - }, - ]); - +export async function updateSecurityAnswer(command: UpdateSecurityAnswerCommand): Promise { + const { user } = await authorization.canUpdateSecurityQuestions().catch((e) => { + if (e instanceof AccessControlError) { + logEvent(e.user.id, { type: "User" }, "AccessDenied", "Attempted to update security answers"); + } + throw e; + }); const areQuestionIdsValidResult = await areQuestionIdsValid([command.newQuestionId]); if (!areQuestionIdsValidResult) throw new InvalidSecurityQuestionId(); - const userSecurityAnswers = await _retrieveUserSecurityAnswers({ userId: ability.user.id }); + const userSecurityAnswers = await _retrieveUserSecurityAnswers({ userId: user.id }); if (userSecurityAnswers.length === 0) throw new SecurityAnswersNotFound(); const oldAnswer = userSecurityAnswers.find( @@ -182,6 +178,7 @@ export async function updateSecurityAnswer( .catch((e) => prismaErrors(e, null)); if (!operationResult) throw new SecurityQuestionDatabaseOperationFailed(); + logEvent(user.id, { type: "User" }, "ChangeSecurityAnswers"); } export async function retrieveUserSecurityQuestions({ diff --git a/lib/cache/flags.ts b/lib/cache/flags.ts index d5121e8426..0b7751b8a2 100644 --- a/lib/cache/flags.ts +++ b/lib/cache/flags.ts @@ -1,57 +1,40 @@ import { getRedisInstance } from "@lib/integration/redisConnector"; import flagInitialSettings from "../../flag_initialization/default_flag_settings.json"; -import { checkPrivileges } from "@lib/privileges"; -import { AccessControlError } from "@lib/auth"; +import { authorization } from "@lib/privileges"; +import { AccessControlError } from "@lib/auth/errors"; import { logEvent } from "@lib/auditLogs"; -import { UserAbility } from "@lib/types"; import { FeatureFlagKeys, FeatureFlags, PickFlags } from "./types"; /** * Enables an Application Setting Flag - * @param ability User's Ability Instance * @param key Applicaiton setting flag key */ -export const enableFlag = async (ability: UserAbility, key: string): Promise => { - try { - checkPrivileges(ability, [{ action: "update", subject: "Flag" }]); - const redis = await getRedisInstance(); - await redis.multi().sadd("flags", key).set(`flag:${key}`, "1").exec(); - logEvent(ability.user.id, { type: "Flag", id: key }, "EnableFlag"); - } catch (e) { +export const enableFlag = async (key: string): Promise => { + const { user } = await authorization.canManageFlags().catch((e) => { if (e instanceof AccessControlError) { - logEvent( - ability.user.id, - { type: "Flag", id: key }, - "AccessDenied", - `Attempted to enable ${key}` - ); + logEvent(e.user.id, { type: "Flag", id: key }, "AccessDenied", `Attempted to enable ${key}`); } throw e; - } + }); + const redis = await getRedisInstance(); + await redis.multi().sadd("flags", key).set(`flag:${key}`, "1").exec(); + logEvent(user.id, { type: "Flag", id: key }, "EnableFlag"); }; /** * Disables an Application Setting Flag - * @param ability User's Ability Instance * @param key Application setting flag key */ -export const disableFlag = async (ability: UserAbility, key: string): Promise => { - try { - checkPrivileges(ability, [{ action: "update", subject: "Flag" }]); - const redis = await getRedisInstance(); - await redis.set(`flag:${key}`, "0"); - logEvent(ability.user.id, { type: "Flag", id: key }, "DisableFlag"); - } catch (e) { +export const disableFlag = async (key: string): Promise => { + const { user } = await authorization.canManageFlags().catch((e) => { if (e instanceof AccessControlError) { - logEvent( - ability.user.id, - { type: "Flag", id: key }, - "AccessDenied", - `Attempted to disable ${key}` - ); + logEvent(e.user.id, { type: "Flag", id: key }, "AccessDenied", `Attempted to disable ${key}`); } throw e; - } + }); + const redis = await getRedisInstance(); + await redis.set(`flag:${key}`, "0"); + logEvent(user.id, { type: "Flag", id: key }, "DisableFlag"); }; const getKeys = async (): Promise => { @@ -80,22 +63,16 @@ export const checkOne = async (key: string): Promise => { * @param ability User's Ability Instance * @returns An object of {flag: value ...} */ -export const checkAll = async (ability: UserAbility): Promise<{ [k: string]: boolean }> => { - try { - checkPrivileges(ability, [{ action: "view", subject: "Flag" }]); - const keys = await getKeys(); - logEvent(ability.user.id, { type: "Flag" }, "ListAllFlags"); - return checkMulti(keys); - } catch (e) { - if (e instanceof AccessControlError) - logEvent( - ability.user.id, - { type: "Flag" }, - "AccessDenied", - "Attemped to list all Feature Flags" - ); +export const checkAll = async (): Promise<{ [k: string]: boolean }> => { + const { user } = await authorization.canManageFlags().catch((e) => { + if (e instanceof AccessControlError) { + logEvent(e.user.id, { type: "Flag" }, "AccessDenied", "Attemped to list all Feature Flags"); + } throw e; - } + }); + const keys = await getKeys(); + logEvent(user.id, { type: "Flag" }, "ListAllFlags"); + return checkMulti(keys); }; const checkMulti = async (keys: T): Promise> => { diff --git a/lib/cache/throttlingCache.ts b/lib/cache/throttlingCache.ts index 01efa97cb2..2b07cec736 100644 --- a/lib/cache/throttlingCache.ts +++ b/lib/cache/throttlingCache.ts @@ -1,8 +1,7 @@ -"use server"; - import { logMessage } from "@lib/logger"; import { getRedisInstance } from "../integration/redisConnector"; import { getWeeksInSeconds } from "@lib/utils/date/dateConversions"; +import { authorization } from "@lib/privileges"; const REDIS_RATE_LIMIT_KEY_PREFIX: string = "rate-limit"; @@ -13,11 +12,11 @@ const THROTTLE_SETTING = { export type ThrottleSetting = keyof typeof THROTTLE_SETTING; // For completeness, even though not currently used -// Public facing functions - they can be used by anyone who finds the associated server action identifer - export const getThrottling = async ( formId: string ): Promise<{ rate: string | null; expires: number }> => { + await authorization.canManageAllForms(); + const getParameter = `${REDIS_RATE_LIMIT_KEY_PREFIX}:${formId}`; try { const redis = await getRedisInstance(); @@ -33,6 +32,8 @@ export const getThrottling = async ( // Increases the throttling rate for a limited duration by leveraging the Redis built-in expiration // feature by setting an expiryDelay export const setThrottlingExpiry = async (formId: string, weeks: number) => { + await authorization.canManageAllForms(); + const modifyParameter = `${REDIS_RATE_LIMIT_KEY_PREFIX}:${formId}`; try { const redis = await getRedisInstance(); @@ -52,6 +53,8 @@ export const setThrottlingExpiry = async (formId: string, weeks: number) => { // Permanently increases the throttling rate (uses high capacity token bucket) export const setPermanentThrottling = async (formId: string) => { + await authorization.canManageAllForms(); + const modifyParameter = `${REDIS_RATE_LIMIT_KEY_PREFIX}:${formId}`; try { const redis = await getRedisInstance(); @@ -65,6 +68,8 @@ export const setPermanentThrottling = async (formId: string) => { // Goes back to the initial throttling rate (uses low capacity token bucket) export const resetThrottling = async (formId: string) => { + await authorization.canManageAllForms(); + const modifyParameter = `${REDIS_RATE_LIMIT_KEY_PREFIX}:${formId}`; try { const redis = await getRedisInstance(); diff --git a/lib/invitations/acceptInvitation.ts b/lib/invitations/acceptInvitation.ts index 9354d85298..aae2723785 100644 --- a/lib/invitations/acceptInvitation.ts +++ b/lib/invitations/acceptInvitation.ts @@ -1,25 +1,25 @@ import { prisma } from "@lib/integration/prismaConnector"; -import { FormProperties, UserAbility } from "@lib/types"; +import { FormProperties } from "@lib/types"; import { InvitationIsExpiredError, InvitationNotFoundError, UnableToAssignUserToTemplateError, UserNotFoundError, } from "./exceptions"; -import { checkPrivileges } from "@lib/privileges"; +import { getAbility } from "@lib/privileges"; import { logEvent } from "@lib/auditLogs"; import { notifyOwnersOwnerAdded } from "@lib/templates"; import { logMessage } from "@lib/logger"; +import { AccessControlError } from "@lib/auth/errors"; /** * Accept an invitation. * User has created their account or logged into their existing account. * - * @param ability (logged in user) * @param invitationId * @returns */ -export const acceptInvitation = async (ability: UserAbility, invitationId: string) => { +export const acceptInvitation = async (invitationId: string) => { // Retrieve the invitation const invitation = await prisma.invitation.findUnique({ where: { @@ -55,9 +55,13 @@ export const acceptInvitation = async (ability: UserAbility, invitationId: strin } // Ensures the logged in user is the user that was invited - checkPrivileges(ability, [ - { action: "view", subject: { type: "User", object: { id: user.id } } }, - ]); + const ability = await getAbility(); + if (ability.user.id !== user.id) { + throw new AccessControlError( + ability.user.id, + "You do not have permission to accept this invitation" + ); + } // assign user to form const updatedTemplate = await _assignUserToTemplate(user.id, invitation.templateId).catch((e) => { diff --git a/lib/invitations/cancelInvitation.ts b/lib/invitations/cancelInvitation.ts index 27cf7ff352..23746854db 100644 --- a/lib/invitations/cancelInvitation.ts +++ b/lib/invitations/cancelInvitation.ts @@ -1,17 +1,15 @@ import { prisma } from "@lib/integration/prismaConnector"; -import { InvitationNotFoundError, TemplateNotFoundError } from "./exceptions"; -import { checkPrivileges } from "@lib/privileges"; -import { UserAbility } from "@lib/types"; -import { getTemplateWithAssociatedUsers } from "@lib/templates"; +import { InvitationNotFoundError } from "./exceptions"; +import { authorization } from "@lib/privileges"; import { logEvent } from "@lib/auditLogs"; +import { AccessControlError } from "@lib/auth/errors"; /** * Cancel an invitation * - * @param ability * @param invitationId */ -export const cancelInvitation = async (ability: UserAbility, invitationId: string) => { +export const cancelInvitation = async (invitationId: string) => { // Retrieve the invitation const invitation = await prisma.invitation.findUnique({ where: { @@ -27,24 +25,26 @@ export const cancelInvitation = async (ability: UserAbility, invitationId: strin throw new InvitationNotFoundError(); } - const template = await getTemplateWithAssociatedUsers(invitation.templateId); - - if (!template) { - throw new TemplateNotFoundError(); - } - - checkPrivileges(ability, [ - { action: "update", subject: { type: "FormRecord", object: template } }, - ]); + const { user } = await authorization.canEditForm(invitation.templateId).catch((e) => { + if (e instanceof AccessControlError) { + logEvent( + e.user.id, + { type: "Form", id: invitation.templateId }, + "AccessDenied", + `User ${e.user.id} does not have permission to cancel invitation` + ); + } + throw e; + }); // Delete the invitation await _deleteInvitation(invitationId); logEvent( - ability.user.id, + user.id, { type: "Form", id: invitation.templateId }, "InvitationCancelled", - `${ability.user.id} cancelled invitation for ${invitation.email}` + `${user.id} cancelled invitation for ${invitation.email}` ); }; diff --git a/lib/invitations/declineInvitation.ts b/lib/invitations/declineInvitation.ts index d623ce19ee..4dcfeb6be3 100644 --- a/lib/invitations/declineInvitation.ts +++ b/lib/invitations/declineInvitation.ts @@ -1,19 +1,18 @@ import { prisma } from "@lib/integration/prismaConnector"; -import { UserAbility } from "@lib/types"; import { InvitationNotFoundError, UserNotFoundError } from "./exceptions"; import { getUser } from "@lib/users"; import { logEvent } from "@lib/auditLogs"; -import { checkPrivileges } from "@lib/privileges"; +import { getAbility } from "@lib/privileges"; import { logMessage } from "@lib/logger"; +import { AccessControlError } from "@lib/auth/errors"; /** * Decline an invitation * - * @param ability * @param invitationId * @returns */ -export const declineInvitation = async (ability: UserAbility, invitationId: string) => { +export const declineInvitation = async (invitationId: string) => { const invitation = await prisma.invitation.findUnique({ where: { id: invitationId, @@ -23,15 +22,19 @@ export const declineInvitation = async (ability: UserAbility, invitationId: stri if (!invitation) { throw new InvitationNotFoundError(); } + const ability = await getAbility(); - const user = await getUser(ability, ability.user.id).catch(() => { + const user = await getUser(ability.user.id).catch(() => { throw new UserNotFoundError(); }); // Ensures the logged in user is the user that was invited - checkPrivileges(ability, [ - { action: "view", subject: { type: "User", object: { id: user.id } } }, - ]); + if (ability.user.id !== user.id) { + throw new AccessControlError( + ability.user.id, + "You do not have permission to decline this invitation" + ); + } _deleteInvitation(invitationId).catch((e) => { logMessage.error(`Error deleting invitation: ${e}`); diff --git a/lib/invitations/inviteUserByEmail.ts b/lib/invitations/inviteUserByEmail.ts index 51b65c70a1..846e72328e 100644 --- a/lib/invitations/inviteUserByEmail.ts +++ b/lib/invitations/inviteUserByEmail.ts @@ -1,4 +1,4 @@ -import { FormRecord, UserAbility } from "@lib/types"; +import { FormRecord } from "@lib/types"; import { getUser } from "@lib/users"; import { InvalidDomainError, @@ -17,23 +17,31 @@ import { logMessage } from "@lib/logger"; import { Invitation } from "@prisma/client"; import { logEvent } from "@lib/auditLogs"; import { isValidGovEmail } from "@lib/validation/validation"; +import { authorization } from "@lib/privileges"; +import { AccessControlError } from "@lib/auth/errors"; /** * Invite someone to the form by email * - * @param ability * @param email * @param formId */ -export const inviteUserByEmail = async ( - ability: UserAbility, - email: string, - formId: string, - message: string -) => { +export const inviteUserByEmail = async (email: string, formId: string, message: string) => { let invitation: Invitation; - const sender = await getUser(ability, ability.user.id).catch(() => { + const { user } = await authorization.canEditForm(formId).catch((e) => { + if (e instanceof AccessControlError) { + logEvent( + e.user.id, + { type: "Form", id: formId }, + "AccessDenied", + `User ${e.user.id} does not have permission to invite user` + ); + } + throw e; + }); + + const sender = await getUser(user.id).catch(() => { throw new UserNotFoundError(); }); @@ -75,7 +83,7 @@ export const inviteUserByEmail = async ( await _sendInvitationEmail(sender, invitation, message, template.formRecord); logEvent( - ability.user.id, + user.id, { type: "Form", id: invitation.templateId }, "InvitationCreated", `${sender.id} invited ${invitation.email}` diff --git a/lib/invitations/tests/fixtures/Ability.ts b/lib/invitations/tests/fixtures/Ability.ts deleted file mode 100644 index 376a91e4a6..0000000000 --- a/lib/invitations/tests/fixtures/Ability.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createAbility } from "@lib/privileges"; -import { UserAbility } from "@lib/types"; -import { Base, mockUserPrivileges } from "__utils__/permissions"; -import { Session } from "next-auth"; - -export const mockAbility = ({ userID }: { userID?: string } = {}): UserAbility => { - const fakeSession = { - user: { - id: userID || "test-user-id", - privileges: mockUserPrivileges(Base, { user: { id: userID || "test-user-id" } }), // @TODO: allow override privs - }, - }; - - return createAbility(fakeSession as Session); -}; diff --git a/lib/invitations/tests/invitations.test.ts b/lib/invitations/tests/invitations.test.ts index bfb0924eaf..4e9d19acfa 100644 --- a/lib/invitations/tests/invitations.test.ts +++ b/lib/invitations/tests/invitations.test.ts @@ -1,5 +1,6 @@ import { prisma } from "@lib/integration/prismaConnector"; import { prismaMock } from "@jestUtils"; +import { mockAuthorizationPass, mockGetAbility } from "__utils__/authorization"; import { getUser } from "@lib/users"; import { getTemplateWithAssociatedUsers } from "@lib/templates"; import { sendEmail } from "@lib/integration/notifyConnector"; @@ -15,7 +16,6 @@ import { import { inviteToFormsEmailTemplate } from "../emailTemplates/inviteToFormsEmailTemplate"; import { inviteToCollaborateEmailTemplate } from "../emailTemplates/inviteToCollaborateEmailTemplate"; import { mockAppUser } from "./fixtures/AppUser"; -import { mockAbility } from "./fixtures/Ability"; import { mockTemplateWithUsers } from "./fixtures/TemplateWithUsers"; import { mockInvitation } from "./fixtures/Invitation"; import { mockUser } from "./fixtures/User"; @@ -26,7 +26,7 @@ import { cancelInvitation } from "../cancelInvitation"; import { declineInvitation } from "../declineInvitation"; import { logEvent } from "@lib/auditLogs"; import { mockTemplate } from "./fixtures/Template"; - +jest.mock("@lib/privileges"); jest.mock("@lib/integration/prismaConnector"); jest.mock("@lib/integration/notifyConnector"); jest.mock("@lib/logger"); @@ -37,12 +37,14 @@ jest.mock("@lib/invitations/emailTemplates/inviteToFormsEmailTemplate"); jest.mock("@lib/invitations/emailTemplates/inviteToCollaborateEmailTemplate"); jest.mock("@lib/invitations/emailTemplates/ownerAddedEmailTemplate"); +const userId = "test-user-id"; jest.mock("@lib/origin", () => ({ getOrigin: jest.fn().mockReturnValue("http://localhost:3000"), })); describe("Invitations", () => { beforeEach(() => { + mockAuthorizationPass(userId); jest.clearAllMocks(); }); @@ -54,9 +56,9 @@ describe("Invitations", () => { getTemplateWithAssociatedUsers as jest.MockedFunction ).mockResolvedValue(mockTemplateWithUsers()); - await expect( - inviteUserByEmail(mockAbility(), "test@cds-snc.ca", "form-id", "message") - ).rejects.toThrow(UserAlreadyHasAccessError); + await expect(inviteUserByEmail("test@cds-snc.ca", "form-id", "message")).rejects.toThrow( + UserAlreadyHasAccessError + ); }); it("should throw MismatchedEmailDomainError if email domain does not match sender's domain", async () => { @@ -71,7 +73,7 @@ describe("Invitations", () => { ).mockResolvedValue(mockTemplateWithUsers()); await expect( - inviteUserByEmail(mockAbility(), "test@servicecanada.gc.ca", "form-id", "message") + inviteUserByEmail("test@servicecanada.gc.ca", "form-id", "message") ).rejects.toThrow(MismatchedEmailDomainError); }); @@ -82,9 +84,9 @@ describe("Invitations", () => { getTemplateWithAssociatedUsers as jest.MockedFunction ).mockResolvedValue(mockTemplateWithUsers()); - await expect( - inviteUserByEmail(mockAbility(), "test@notagovdomain", "form-id", "message") - ).rejects.toThrow(InvalidDomainError); + await expect(inviteUserByEmail("test@notagovdomain", "form-id", "message")).rejects.toThrow( + InvalidDomainError + ); }); it("should throw TemplateNotFoundError if template is not found", async () => { @@ -92,9 +94,9 @@ describe("Invitations", () => { getTemplateWithAssociatedUsers as jest.MockedFunction ).mockResolvedValue(null); - await expect( - inviteUserByEmail(mockAbility(), "test@example.com", "form-id", "message") - ).rejects.toThrow(TemplateNotFoundError); + await expect(inviteUserByEmail("test@example.com", "form-id", "message")).rejects.toThrow( + TemplateNotFoundError + ); }); it("should invite a user who doesn't have a Forms account", async () => { @@ -120,7 +122,7 @@ describe("Invitations", () => { (inviteToFormsEmailTemplate as jest.Mock).mockReturnValue("email contents"); - await inviteUserByEmail(mockAbility(), "invited@cds-snc.ca", "form-id", "message"); + await inviteUserByEmail("invited@cds-snc.ca", "form-id", "message"); expect(prisma.invitation.create).toHaveBeenCalledTimes(1); @@ -170,7 +172,7 @@ describe("Invitations", () => { > ).mockReturnValue("email contents"); - await inviteUserByEmail(mockAbility(), "invited@cds-snc.ca", "form-id", "message"); + await inviteUserByEmail("invited@cds-snc.ca", "form-id", "message"); expect(prisma.invitation.create).toHaveBeenCalledTimes(1); expect(inviteToCollaborateEmailTemplate).toHaveBeenCalledTimes(1); @@ -233,7 +235,7 @@ describe("Invitations", () => { inviteToFormsEmailTemplate as jest.MockedFunction ).mockReturnValue("email contents"); - await inviteUserByEmail(mockAbility(), "invited2@cds-snc.ca", "form-id", "message"); + await inviteUserByEmail("invited2@cds-snc.ca", "form-id", "message"); expect(prisma.invitation.delete).toHaveBeenCalledTimes(1); // delete expired expect(prisma.invitation.delete).toHaveBeenCalledWith({ where: { id: "invitation-id" } }); @@ -254,9 +256,7 @@ describe("Invitations", () => { describe("acceptInvitation", () => { it("should throw InvitationNotFoundError if invitation is not found", async () => { prismaMock.invitation.findUnique.mockResolvedValue(null); - await expect(acceptInvitation(mockAbility(), "invitation-id")).rejects.toThrow( - InvitationNotFoundError - ); + await expect(acceptInvitation("invitation-id")).rejects.toThrow(InvitationNotFoundError); }); it("should throw InvitationIsExpiredError if invitation is expired", async () => { @@ -267,12 +267,12 @@ describe("Invitations", () => { expires: new Date(Date.now() - 10000), }) ); - await expect(acceptInvitation(mockAbility(), "invitation-id")).rejects.toThrow( - InvitationIsExpiredError - ); + await expect(acceptInvitation("invitation-id")).rejects.toThrow(InvitationIsExpiredError); }); it("should accept an invitation", async () => { + mockGetAbility("invited-user-id"); + prismaMock.invitation.findUnique.mockResolvedValueOnce( mockInvitation({ id: "invitation-id", @@ -305,9 +305,7 @@ describe("Invitations", () => { ownerAddedEmailTemplate as jest.MockedFunction ).mockReturnValue("email contents"); - const ability = mockAbility({ userID: "invited-user-id" }); - - await acceptInvitation(ability, "invitation-id"); + await acceptInvitation("invitation-id"); // Delete the invitation expect(prisma.invitation.delete).toHaveBeenCalledWith({ @@ -342,6 +340,8 @@ describe("Invitations", () => { describe("cancelInvitation", () => { it("should cancel an invitation", async () => { + mockAuthorizationPass("user-id"); + prismaMock.invitation.findUnique.mockResolvedValue( mockInvitation({ id: "invitation-id", @@ -364,7 +364,7 @@ describe("Invitations", () => { }) ); - await cancelInvitation(mockAbility({ userID: "user-id" }), "invitation-id"); + await cancelInvitation("invitation-id"); expect(prismaMock.invitation.delete).toHaveBeenCalledWith({ where: { id: "invitation-id" }, @@ -374,25 +374,22 @@ describe("Invitations", () => { it("should throw InvitationNotFoundError if invitation is not found", async () => { prismaMock.invitation.findUnique.mockResolvedValue(null); - await expect(cancelInvitation(mockAbility(), "invitation-id")).rejects.toThrow( - InvitationNotFoundError - ); + await expect(cancelInvitation("invitation-id")).rejects.toThrow(InvitationNotFoundError); }); }); describe("declineInvitation", () => { it("should decline an invitation", async () => { + mockGetAbility("test-user-id"); prismaMock.invitation.findUnique.mockResolvedValue( mockInvitation({ id: "invitation-id", email: "test@cds-snc.ca", }) ); - prismaMock.user.findFirst.mockResolvedValueOnce( - mockUser({ id: "user-id", email: "test@cds-snc.ca" }) - ); + (getUser as jest.MockedFunction).mockResolvedValue(mockAppUser()); - await declineInvitation(mockAbility(), "invitation-id"); + await declineInvitation("invitation-id"); expect(prismaMock.invitation.delete).toHaveBeenCalled(); }); @@ -400,9 +397,7 @@ describe("Invitations", () => { it("should throw InvitationNotFoundError if invitation is not found", async () => { prismaMock.invitation.findUnique.mockResolvedValueOnce(null); - await expect(declineInvitation(mockAbility(), "invitation-id")).rejects.toThrow( - InvitationNotFoundError - ); + await expect(declineInvitation("invitation-id")).rejects.toThrow(InvitationNotFoundError); }); it("should throw UserNotFoundError if user is not found", async () => { @@ -415,9 +410,7 @@ describe("Invitations", () => { (getUser as jest.Mock).mockRejectedValueOnce(new UserNotFoundError()); - await expect(declineInvitation(mockAbility(), "invitation-id")).rejects.toThrow( - UserNotFoundError - ); + await expect(declineInvitation("invitation-id")).rejects.toThrow(UserNotFoundError); }); }); }); diff --git a/lib/pages/auth.ts b/lib/pages/auth.ts new file mode 100644 index 0000000000..bc874a5e00 --- /dev/null +++ b/lib/pages/auth.ts @@ -0,0 +1,83 @@ +import { redirect } from "next/navigation"; +import { getCurrentLanguage } from "@i18n"; +import { auth } from "@lib/auth/nextAuth"; +import { JSX } from "react"; +import { Session } from "next-auth"; + +type WithSession = T & { session: Session }; +type Layout = { + children: React.ReactNode; + params: Promise<{ [key: string]: string | string[]; locale: string } & T>; +}; +type Page = { + params: Promise<{ [key: string]: string | string[]; locale: string } & T>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}; + +export function AuthenticatedPage( + page: (props: WithSession>) => Promise +): (props: WithSession>) => Promise; +export function AuthenticatedPage( + authorizations: (() => Promise)[], + page: (props: WithSession>) => Promise +): (props: WithSession>) => Promise; +export function AuthenticatedPage( + arg1: (() => Promise)[] | ((props: WithSession>) => Promise), + arg2?: (props: WithSession>) => Promise +): (props: WithSession>) => Promise { + return async (props: WithSession>) => { + const session = await auth(); + const language: string = await getCurrentLanguage(); + if (session === null) { + redirect(`/${language}/auth/login`); + } + + if (typeof arg1 === "function") { + return arg1({ ...props, session }); + } else { + if (arg2 === undefined) { + throw new Error("Page function is undefined"); + } + await Promise.all(arg1.map((authorization) => authorization())).catch(() => { + // redirect to unauthorized page + redirect(`/${language}/unauthorized`); + }); + return arg2({ ...props, session }); + } + }; +} + +// Creating a second function that seperates the concern for Layouts +// in case we need to change or tweak implementation between pages and layouts in the future. + +export function AuthenticatedLayout( + page: (props: WithSession>) => Promise +): (props: WithSession>) => Promise; +export function AuthenticatedLayout( + authorizations: (() => Promise)[], + page: (props: WithSession>) => Promise +): (props: WithSession>) => Promise; +export function AuthenticatedLayout( + arg1: (() => Promise)[] | ((props: WithSession>) => Promise), + arg2?: (props: WithSession>) => Promise +) { + return async (props: WithSession>) => { + const session = await auth(); + const language: string = await getCurrentLanguage(); + if (session === null) { + redirect(`/${language}/auth/login`); + } + if (typeof arg1 === "function") { + return arg1({ ...props, session }); + } else { + if (arg2 === undefined) { + throw new Error("Page function is undefined"); + } + await Promise.all(arg1.map((authorization) => authorization())).catch(() => { + // redirect to unauthorized page + redirect(`/${language}/unauthorized`); + }); + return arg2({ ...props, session }); + } + }; +} diff --git a/lib/privileges.ts b/lib/privileges.ts index 8a77397c86..c96b7c605d 100644 --- a/lib/privileges.ts +++ b/lib/privileges.ts @@ -12,7 +12,6 @@ import { Privilege, Action, Subject, - ForcedSubjectType, AnyObject, UserAbility, } from "@lib/types/privileges-types"; @@ -23,10 +22,12 @@ import get from "lodash/get"; import { logMessage } from "./logger"; import { logEvent } from "./auditLogs"; -import { redirect } from "next/navigation"; import { checkOne } from "./cache/flags"; import { InMemoryCache } from "./cache/inMemoryCache"; -import { auth, AccessControlError } from "@lib/auth"; +import { auth } from "@lib/auth/nextAuth"; +import { AccessControlError } from "@lib/auth/errors"; +import { getCurrentLanguage } from "@i18n"; +import { redirect } from "next/navigation"; /* This file contains references to server side only modules. Any attempt to import these functions into a browser will cause compilation failures @@ -144,18 +145,26 @@ export const getPrivilegeRulesForUser = async (userId: string) => { /** * Update and overwrite existing privileges on a User - * @param ability Ability instance for session * @param userID id of the user to be updated * @param privileges Array of privileges to be connect to user * @returns */ export const updatePrivilegesForUser = async ( - ability: UserAbility, userID: string, privileges: { id: string; action: "add" | "remove" }[] ) => { try { - checkPrivileges(ability, [{ action: "update", subject: "User" }]); + const { user: abilityUser } = await authorization.canManageUser(userID).catch((e) => { + if (e instanceof AccessControlError) { + logEvent( + e.user.id, + { type: "Privilege" }, + "AccessDenied", + `Attempted to modify privilege on user ${userID}` + ); + } + throw e; + }); const addPrivileges: { id: string }[] = []; const removePrivileges: { id: string }[] = []; @@ -195,7 +204,7 @@ export const updatePrivilegesForUser = async ( }), prisma.user.findUniqueOrThrow({ where: { - id: ability.user.id, + id: abilityUser.id, }, select: { email: true, @@ -211,7 +220,7 @@ export const updatePrivilegesForUser = async ( "GrantPrivilege", `Granted privilege : ${privilegesInfo.find((p) => p.id === privilege.id)?.name} to ${ user.email - } (userID: ${user.id}) by ${privilegedUser?.email} (userID: ${ability.user.id})` + } (userID: ${user.id}) by ${privilegedUser?.email} (userID: ${abilityUser.id})` ) ); @@ -222,7 +231,7 @@ export const updatePrivilegesForUser = async ( "RevokePrivilege", `Revoked privilege : ${privilegesInfo.find((p) => p.id === privilege.id)?.name} from ${ user.email - } (userID: ${user.id}) by ${privilegedUser?.email} (userID: ${ability.user.id})` + } (userID: ${user.id}) by ${privilegedUser?.email} (userID: ${abilityUser.id})` ) ); @@ -236,14 +245,7 @@ export const updatePrivilegesForUser = async ( // Error P2025: Record to update not found. return null; } - if (error instanceof AccessControlError) { - logEvent( - ability.user.id, - { type: "Privilege" }, - "AccessDenied", - `Attempted to modify privilege on user ${userID}` - ); - } + throw error; } }; @@ -252,9 +254,9 @@ export const updatePrivilegesForUser = async ( * Get all privileges availabe in the application * @returns an array of privealges */ -export const getAllPrivileges = async (ability: UserAbility) => { +export const getAllPrivileges = async () => { try { - checkPrivileges(ability, [{ action: "view", subject: "Privilege" }]); + await authorization.canAccessPrivileges(); return await prisma.privilege.findMany({ select: { id: true, @@ -273,9 +275,9 @@ export const getAllPrivileges = async (ability: UserAbility) => { } }; -export const getPrivilege = async (ability: UserAbility, where: Prisma.PrivilegeWhereInput) => { +export const getPrivilege = async (where: Prisma.PrivilegeWhereInput) => { try { - checkPrivileges(ability, [{ action: "view", subject: "Privilege" }]); + await authorization.canAccessPrivileges(); return await prisma.privilege.findFirst({ where, select: { @@ -295,108 +297,6 @@ export const getPrivilege = async (ability: UserAbility, where: Prisma.Privilege } }; -/** - * Helper function to determine which Subject Type is being passed - * @param subject Rule subject - * @returns True is subject is of type ForcedSubjectType - */ -function _isForceTyping(subject: Subject | ForcedSubjectType): subject is ForcedSubjectType { - return ( - (subject as ForcedSubjectType).type !== undefined && - (subject as ForcedSubjectType).object !== undefined - ); -} - -/** - * Checks the privileges requested against an ability instance and throws and error if the action is not permitted. - * @param ability The ability instance associated to a User - * @param rules An array of rules to verify - * @param logic Use an AND or OR logic comparison - */ -export const checkPrivileges = ( - ability: UserAbility, - rules: { - action: Action; - subject: Subject | ForcedSubjectType; - field?: string | string[]; - }[], - logic: "all" | "one" = "all" -): void => { - // helper to define if we are force typing a passed object - try { - const result = rules.map(({ action, subject, field }) => { - let ruleResult = false; - if (Array.isArray(field)) { - field.forEach((f) => { - try { - checkPrivileges(ability, [{ action, subject, field: f }], logic); - ruleResult = true; - } catch (error) { - ruleResult = false; - } - }); - return ruleResult; - } - if (_isForceTyping(subject)) { - ruleResult = ability.can(action, setSubjectType(subject.type, subject.object), field); - logMessage.debug( - `Privilege Check ${ruleResult ? "PASS" : "FAIL"}: Can ${action} on ${subject.type} ` - ); - } else { - if (typeof subject !== "string") { - throw new Error("Subject must be a string or ForcedSubjectType"); - } - // If the object is not forced typed, we need to pass in an empty object to ensure a global privilege check - ruleResult = ability.can(action, setSubjectType(subject, {}), field); - logMessage.debug( - `Privilege Check ${ruleResult ? "PASS" : "FAIL"}: Can ${action} on ${subject} ` - ); - } - return ruleResult; - }); - - let accessAllowed = false; - - switch (logic) { - case "all": - // The initial value needs to be true because of the AND logic - accessAllowed = result.reduce((prev, curr) => prev && curr, true); - break; - case "one": - accessAllowed = result.reduce((prev, curr) => prev || curr, false); - break; - } - if (!accessAllowed) { - throw new AccessControlError(`Access Control Forbidden Action`); - } - } catch { - // If there is any error in privilege checking default to forbidden - // Do not create an audit log as the error is with the system itself - throw new AccessControlError(`Access Control Forbidden Action`); - } -}; - -export const checkPrivilegesAsBoolean = ( - ability: UserAbility, - rules: { - action: Action; - subject: Subject | ForcedSubjectType; - field?: string; - }[], - options?: { - logic?: "all" | "one"; - redirect?: boolean; - } -): boolean => { - try { - checkPrivileges(ability, rules, options?.logic ?? "all"); - return true; - } catch (error) { - if (options?.redirect) redirect(`/admin/unauthorized`); - return false; - } -}; - const _getSubject = async (subject: { type: Extract; id: string }) => { if (subject.id === "all") { return {}; @@ -541,7 +441,7 @@ const _authorizationCheck = async ( const data = JSON.stringify(rules); logMessage.error(`Error in privilege check: ${e} data: ${data}`); // On any error in the promise chain, default to forbidden - throw new AccessControlError(`Access Control Forbidden Action`); + throw new AccessControlError(ability.user.id, "Access Control Forbidden Action"); }); let accessAllowed = false; @@ -556,7 +456,7 @@ const _authorizationCheck = async ( break; } if (!accessAllowed) { - throw new AccessControlError(`Access Control Forbidden Action`); + throw new AccessControlError(ability.user.id, "Access Control Forbidden Action"); } return { user: ability.user }; }; @@ -604,6 +504,24 @@ export const authorization = { "one" ); }, + /** + * Verify if the user has the privilege to publish forms + * @returns boolean + */ + hasPublishFormsPrivilege: async () => { + const ability = await getAbility(); + const result = await prisma.user.count({ + where: { + id: ability.user.id, + privileges: { + some: { + name: "PublishForms", + }, + }, + }, + }); + return result > 0; + }, /** * Can the user create a new form */ @@ -668,7 +586,7 @@ export const authorization = { * Can the user view all forms in the application */ canViewAllForms: async () => { - return authorization.check([ + return _authorizationCheck([ { action: "view", subject: { type: "FormRecord", scope: "all" }, @@ -686,6 +604,30 @@ export const authorization = { }, ]); }, + /** + * Can view a users information + * @param userId the ID of the user + */ + canViewUser: async (userId: string) => { + return _authorizationCheck([ + { + action: "view", + subject: { type: "User", scope: { subjectId: userId } }, + }, + ]); + }, + /** + * Can view all users information + * @param userId the ID of the user + */ + canViewAllUsers: async () => { + return _authorizationCheck([ + { + action: "view", + subject: { type: "User", scope: "all" }, + }, + ]); + }, /** * Can the user administratively manage this specific user * @param userId The ID of the user @@ -701,26 +643,27 @@ export const authorization = { }, /** * Can the user update security questions on this specific user - * @param userId The ID of the user */ - canUpdateSecurityQuestions: async (userId: string) => { + canUpdateSecurityQuestions: async () => { + const ability = await getAbility(); return _authorizationCheck([ { action: "update", - subject: { type: "User", scope: { subjectId: userId } }, + subject: { type: "User", scope: { subjectId: ability.user.id } }, fields: ["securityAnswers"], }, ]); }, + /** * Can the user update the name on this specific user - * @param userId The ID of the user */ - canChangeUserName: async (userId: string) => { + canChangeUserName: async () => { + const ability = await getAbility(); return _authorizationCheck([ { action: "update", - subject: { type: "User", scope: { subjectId: userId } }, + subject: { type: "User", scope: { subjectId: ability.user.id } }, fields: ["name"], }, ]); @@ -791,4 +734,8 @@ export const authorization = { }, ]); }, + unauthorizedRedirect: async () => { + const language = await getCurrentLanguage(); + redirect(`/${language}/unauthorized`); + }, }; diff --git a/lib/serviceAccount.ts b/lib/serviceAccount.ts index ddf936dcfe..e51146b500 100644 --- a/lib/serviceAccount.ts +++ b/lib/serviceAccount.ts @@ -163,75 +163,6 @@ export const checkKeyExists = async (templateId: string) => { } }; -export const refreshKey = async (templateId: string) => { - const { user } = await authorization.canEditForm(templateId); - - // Check if we're in a weird state and return an error to get support involved - const remoteServiceAccountId = await checkMachineUserExists(templateId).then( - (serviceAccountId) => { - if (!serviceAccountId) { - throw new Error( - `Service Account User for template ${templateId} does not exist when trying to refresh API Key` - ); - } - return serviceAccountId; - } - ); - - const { id: serviceAccountId, publicKeyId } = - (await prisma.apiServiceAccount.findUnique({ - where: { - templateId: templateId, - }, - select: { id: true, publicKeyId: true }, - })) ?? {}; - - if (!serviceAccountId || !publicKeyId) { - throw new Error(`No Key Exists in GCForms DB for template ${templateId}`); - } - - if (serviceAccountId !== remoteServiceAccountId) { - // The app and IDP are our of sync. - // This is a critical error and should be investigated by support - throw new Error( - `Service Account User ID for template ${templateId} is out of sync between GCForms and Zitadel` - ); - } - - const zitadel = await getZitadelClient(); - await zitadel - .removeMachineKey({ - userId: serviceAccountId, - keyId: publicKeyId, - }) - .catch((err) => { - logMessage.error(err); - throw new Error(`Failed to delete key in Zitadel for template ${templateId}`); - }); - - const { privateKey, publicKey } = generateKeys(); - - const keyId = await uploadKey(publicKey, serviceAccountId); - await prisma.apiServiceAccount.update({ - where: { - templateId, - }, - data: { - publicKey: publicKey, - publicKeyId: keyId, - }, - }); - - logEvent( - user.id, - { type: "ServiceAccount" }, - "RefreshAPIKey", - `User :${user.id} refreshed API key for service account ${serviceAccountId}` - ); - - return buildApiPrivateKeyData(keyId, privateKey, serviceAccountId, templateId); -}; - export const createKey = async (templateId: string) => { const { user } = await authorization.canEditForm(templateId); diff --git a/lib/templates.ts b/lib/templates.ts index cd942889ac..e724184d8d 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -19,6 +19,7 @@ import { sendEmail } from "./integration/notifyConnector"; import { youHaveBeenRemovedEmailTemplate } from "./invitations/emailTemplates/youHaveBeenRemovedEmailTemplate"; import { ownerAddedEmailTemplate } from "./invitations/emailTemplates/ownerAddedEmailTemplate"; import { isValidISODate } from "./utils/date/isValidISODate"; +import { dateHasPast } from "@lib/utils"; // ****************************************** // Internal Module Functions @@ -1289,3 +1290,38 @@ export const updateSecurityAttribute = async (formID: string, securityAttribute: return _parseTemplate(updatedTemplate); }; + +export const checkIfClosed = async (formId: string) => { + try { + let isPastClosingDate = false; + + // Note these are the only fields we need from the template + // They are public fields so no privilege check is needed + const template = await prisma.template + .findUnique({ + where: { + id: formId, + }, + select: { + closingDate: true, + closedDetails: true, + }, + }) + .catch((e) => prismaErrors(e, null)); + + if (!template) { + throw new Error("Template not found"); + } + + if (template.closingDate) { + isPastClosingDate = dateHasPast(Date.parse(String(template.closingDate))); + } + + return { + isPastClosingDate, + closedDetails: template.closedDetails as ClosedDetails, + }; + } catch (e) { + return null; + } +}; diff --git a/lib/tests/appSettings.test.ts b/lib/tests/appSettings.test.ts index fde62f9278..4c08a6ee9e 100644 --- a/lib/tests/appSettings.test.ts +++ b/lib/tests/appSettings.test.ts @@ -6,20 +6,16 @@ import { updateAppSetting, deleteAppSetting, } from "@lib/appSettings"; -import { createAbility } from "@lib/privileges"; -import { AccessControlError } from "@lib/auth"; -import { - Base, - mockUserPrivileges, - ViewApplicationSettings, - ManageApplicationSettings, -} from "__utils__/permissions"; + +import { AccessControlError } from "@lib/auth/errors"; + import * as settingCache from "@lib/cache/settingCache"; -import { Session } from "next-auth"; + import { logEvent } from "@lib/auditLogs"; +import { mockAuthorizationFail, mockAuthorizationPass } from "__utils__/authorization"; const mockedLogEvent = jest.mocked(logEvent, { shallow: true }); -jest.mock("@lib/auth"); +jest.mock("@lib/privileges"); // Needed because of a TypeScript error not allowing for non-default exported spyOn items. jest.mock("@lib/cache/settingCache", () => ({ @@ -27,10 +23,12 @@ jest.mock("@lib/cache/settingCache", () => ({ ...jest.requireActual("@lib/cache/settingCache"), })); -const viewPrivilege = Base.concat(ViewApplicationSettings); -const managePrivilege = Base.concat(ViewApplicationSettings, ManageApplicationSettings); +const userId = "1"; describe("Application Settings", () => { + beforeEach(() => { + mockAuthorizationPass(userId); + }); test("Get an application setting", async () => { const cacheSpy = jest.spyOn(settingCache, "settingCheck"); prismaMock.setting.findUnique.mockResolvedValue({ @@ -50,64 +48,53 @@ describe("Application Settings", () => { expect(mockedLogEvent).not.toHaveBeenCalled(); expect(setting).toEqual("123"); }); - test.each([[viewPrivilege], [managePrivilege]])( - "Get all application settings", - async (privilege) => { - const fakeSession = { - user: { id: "1", privileges: mockUserPrivileges(privilege, { user: { id: "1" } }) }, - }; - const ability = createAbility(fakeSession as Session); - - prismaMock.setting.findMany.mockResolvedValue([ - { - internalId: "testSetting", - nameEn: "Test Setting", - nameFr: "[FR] Test Setting", - descriptionEn: null, - descriptionFr: null, - value: "123", - }, - { - internalId: "testSetting 2", - nameEn: "Test Setting 2", - nameFr: "[FR] Test Setting 2", - descriptionEn: null, - descriptionFr: null, - value: "456", - }, - ]); - const settings = await getAllAppSettings(ability); - // Ensure audit logging is called - expect(mockedLogEvent).toHaveBeenCalledTimes(1); - expect(mockedLogEvent).toHaveBeenCalledWith("1", { type: "Setting" }, "ListAllSettings"); + test("Get all application settings", async () => { + prismaMock.setting.findMany.mockResolvedValue([ + { + internalId: "testSetting", + nameEn: "Test Setting", + nameFr: "[FR] Test Setting", + descriptionEn: null, + descriptionFr: null, + value: "123", + }, + { + internalId: "testSetting 2", + nameEn: "Test Setting 2", + nameFr: "[FR] Test Setting 2", + descriptionEn: null, + descriptionFr: null, + value: "456", + }, + ]); + const settings = await getAllAppSettings(); + // Ensure audit logging is called + expect(mockedLogEvent).toHaveBeenCalledTimes(1); + expect(mockedLogEvent).toHaveBeenCalledWith("1", { type: "Setting" }, "ListAllSettings"); - expect(settings).toMatchObject([ - { - internalId: "testSetting", - nameEn: "Test Setting", - nameFr: "[FR] Test Setting", - descriptionEn: null, - descriptionFr: null, - value: "123", - }, - { - internalId: "testSetting 2", - nameEn: "Test Setting 2", - nameFr: "[FR] Test Setting 2", - descriptionEn: null, - descriptionFr: null, - value: "456", - }, - ]); - } - ); + expect(settings).toMatchObject([ + { + internalId: "testSetting", + nameEn: "Test Setting", + nameFr: "[FR] Test Setting", + descriptionEn: null, + descriptionFr: null, + value: "123", + }, + { + internalId: "testSetting 2", + nameEn: "Test Setting 2", + nameFr: "[FR] Test Setting 2", + descriptionEn: null, + descriptionFr: null, + value: "456", + }, + ]); + }); describe("Create an application setting", () => { test("Create an application setting sucessfully", async () => { const cacheSpy = jest.spyOn(settingCache, "settingPut"); - const fakeSession = { - user: { id: "1", privileges: mockUserPrivileges(managePrivilege, { user: { id: "1" } }) }, - }; - const ability = createAbility(fakeSession as Session); + const data = { internalId: "testSetting", nameEn: "Test Setting", @@ -121,12 +108,12 @@ describe("Application Settings", () => { descriptionFr: null, }); - const newSetting = await createAppSetting(ability, data); + const newSetting = await createAppSetting(data); expect(cacheSpy).toHaveBeenCalledWith("testSetting", "123"); // Ensure audit logging is called expect(mockedLogEvent).toHaveBeenCalledTimes(1); expect(mockedLogEvent).toHaveBeenCalledWith( - "1", + userId, { id: "testSetting", type: "Setting" }, "CreateSetting", 'Created setting with {"internalId":"testSetting","nameEn":"Test Setting","nameFr":"[FR] Test Setting","value":"123"}' @@ -141,14 +128,9 @@ describe("Application Settings", () => { }); }); test("Only users with correct privileges can create app settings", async () => { + mockAuthorizationFail(userId); const cacheSpy = jest.spyOn(settingCache, "settingPut"); - const fakeSession = { - user: { - id: "1", - privileges: mockUserPrivileges(viewPrivilege, { user: { id: "1" } }), - }, - }; - const ability = createAbility(fakeSession as Session); + const data = { internalId: "testSetting", nameEn: "Test Setting", @@ -156,13 +138,11 @@ describe("Application Settings", () => { value: "123", }; - await expect(async () => { - await createAppSetting(ability, data); - }).rejects.toBeInstanceOf(AccessControlError); + await expect(createAppSetting(data)).rejects.toBeInstanceOf(AccessControlError); // Ensure audit logging is called expect(mockedLogEvent).toHaveBeenCalledTimes(1); expect(mockedLogEvent).toHaveBeenCalledWith( - "1", + userId, { type: "Setting" }, "AccessDenied", "Attempted to create setting" @@ -173,13 +153,7 @@ describe("Application Settings", () => { describe("Update an application setting", () => { test("Update an application setting sucessfully", async () => { const cacheSpy = jest.spyOn(settingCache, "settingPut"); - const fakeSession = { - user: { - id: "1", - privileges: mockUserPrivileges(managePrivilege, { user: { id: "1" } }), - }, - }; - const ability = createAbility(fakeSession as Session); + const data = { internalId: "testSetting", nameEn: "Test Setting", @@ -193,12 +167,12 @@ describe("Application Settings", () => { descriptionFr: null, }); - const newSetting = await updateAppSetting(ability, data.internalId, data); + const newSetting = await updateAppSetting(data.internalId, data); expect(cacheSpy).toHaveBeenCalledWith("testSetting", "123"); // Ensure audit logging is called expect(mockedLogEvent).toHaveBeenCalledTimes(1); expect(mockedLogEvent).toHaveBeenCalledWith( - "1", + userId, { id: "testSetting", type: "Setting" }, "ChangeSetting", 'Updated setting with {"internalId":"testSetting","nameEn":"Test Setting","nameFr":"[FR] Test Setting","value":"123"}' @@ -214,13 +188,8 @@ describe("Application Settings", () => { }); test("Only users with correct privileges can update app settings", async () => { const cacheSpy = jest.spyOn(settingCache, "settingPut"); - const fakeSession = { - user: { - id: "1", - privileges: mockUserPrivileges(viewPrivilege, { user: { id: "1" } }), - }, - }; - const ability = createAbility(fakeSession as Session); + mockAuthorizationFail(userId); + const data = { internalId: "testSetting", nameEn: "Test Setting", @@ -228,15 +197,15 @@ describe("Application Settings", () => { value: "123", }; - await expect(async () => { - await updateAppSetting(ability, data.internalId, data); - }).rejects.toBeInstanceOf(AccessControlError); + await expect(updateAppSetting(data.internalId, data)).rejects.toBeInstanceOf( + AccessControlError + ); expect(cacheSpy).not.toHaveBeenCalled(); // Ensure audit logging is called expect(mockedLogEvent).toHaveBeenCalledTimes(1); expect(mockedLogEvent).toHaveBeenCalledWith( - "1", + userId, { id: "testSetting", type: "Setting" }, "AccessDenied", "Attempted to update setting" @@ -246,13 +215,6 @@ describe("Application Settings", () => { describe("Delete an application setting", () => { test("Delete an application setting sucessfully", async () => { const cacheSpy = jest.spyOn(settingCache, "settingDelete"); - const fakeSession = { - user: { - id: "1", - privileges: mockUserPrivileges(managePrivilege, { user: { id: "1" } }), - }, - }; - const ability = createAbility(fakeSession as Session); prismaMock.setting.delete.mockResolvedValue({ internalId: "testSetting", @@ -263,12 +225,12 @@ describe("Application Settings", () => { value: "123", }); - await deleteAppSetting(ability, "testSetting"); + await deleteAppSetting("testSetting"); expect(cacheSpy).toHaveBeenCalledWith("testSetting"); // Ensure audit logging is called expect(mockedLogEvent).toHaveBeenCalledTimes(1); expect(mockedLogEvent).toHaveBeenCalledWith( - "1", + userId, { id: "testSetting", type: "Setting" }, "DeleteSetting", 'Deleted setting with {"internalId":"testSetting","nameEn":"Test Setting","nameFr":"[FR] Test Setting","descriptionEn":null,"descriptionFr":null,"value":"123"}' @@ -276,23 +238,15 @@ describe("Application Settings", () => { }); test("Only users with correct privileges can delete app settings", async () => { const cacheSpy = jest.spyOn(settingCache, "settingDelete"); - const fakeSession = { - user: { - id: "1", - privileges: mockUserPrivileges(viewPrivilege, { user: { id: "1" } }), - }, - }; - const ability = createAbility(fakeSession as Session); + mockAuthorizationFail(userId); - await expect(async () => { - await deleteAppSetting(ability, "testSetting"); - }).rejects.toBeInstanceOf(AccessControlError); + await expect(deleteAppSetting("testSetting")).rejects.toBeInstanceOf(AccessControlError); expect(cacheSpy).not.toHaveBeenCalled(); // Ensure audit logging is called expect(mockedLogEvent).toHaveBeenCalledTimes(1); expect(mockedLogEvent).toHaveBeenCalledWith( - "1", + userId, { id: "testSetting", type: "Setting" }, "AccessDenied", "Attempted to delete setting" diff --git a/lib/tests/privileges.test.ts b/lib/tests/privileges.test.ts index f1821fea86..bf56558fc4 100644 --- a/lib/tests/privileges.test.ts +++ b/lib/tests/privileges.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { interpolatePermissionCondition, authorization } from "@lib/privileges"; -import { AccessControlError, auth } from "@lib/auth"; +import { auth } from "@lib/auth/nextAuth"; +import { AccessControlError } from "@lib/auth/errors"; import { Base, mockUserPrivileges, @@ -18,13 +19,7 @@ import { Action } from "@lib/types/privileges-types"; import { checkOne } from "@lib/cache/flags"; jest.mock("@lib/cache/flags"); -jest.mock("@lib/auth", () => { - const originalModule = jest.requireActual("@lib/auth"); - return { - ...originalModule, - auth: jest.fn(), - }; -}); +jest.mock("@lib/auth/nextAuth"); const mockedCheckOne = checkOne as jest.MockedFunction; const mockedAuth = auth as unknown as jest.MockedFunction<() => Promise>; @@ -838,26 +833,24 @@ describe("Authorization Helpers", () => { it("Can update security question on user", async () => { (prismaMock.user.findUniqueOrThrow as jest.MockedFunction).mockResolvedValue(user1.db); mockedAuth.mockResolvedValue(user1.session); - await authorization.canUpdateSecurityQuestions(user1.session.user.id); + await authorization.canUpdateSecurityQuestions(); }); it("Can not update security questions on another user", async () => { (prismaMock.user.findUniqueOrThrow as jest.MockedFunction).mockResolvedValue(user1.db); mockedAuth.mockResolvedValue(user2.session); - await expect( - authorization.canUpdateSecurityQuestions(user1.session.user.id) - ).rejects.toBeInstanceOf(AccessControlError); + await expect(authorization.canUpdateSecurityQuestions()).rejects.toBeInstanceOf( + AccessControlError + ); }); it("Can update name on user", async () => { (prismaMock.user.findUniqueOrThrow as jest.MockedFunction).mockResolvedValue(user1.db); mockedAuth.mockResolvedValue(user1.session); - await authorization.canChangeUserName(user1.session.user.id); + await authorization.canChangeUserName(); }); it("Can not update name on another user", async () => { (prismaMock.user.findUniqueOrThrow as jest.MockedFunction).mockResolvedValue(user1.db); mockedAuth.mockResolvedValue(user2.session); - await expect(authorization.canChangeUserName(user1.session.user.id)).rejects.toBeInstanceOf( - AccessControlError - ); + await expect(authorization.canChangeUserName()).rejects.toBeInstanceOf(AccessControlError); }); it("Can manage all users", async () => { adminUser.session.user.privileges = mockUserPrivileges(ManageUsers, { diff --git a/lib/tests/securityQuestions.test.ts b/lib/tests/securityQuestions.test.ts index 3fccd03c47..81b40e43e0 100644 --- a/lib/tests/securityQuestions.test.ts +++ b/lib/tests/securityQuestions.test.ts @@ -9,17 +9,14 @@ import { DuplicatedQuestionsNotAllowed, SecurityAnswersNotFound, } from "@lib/auth/securityQuestions"; -import { createAbility } from "@lib/privileges"; -import { Base, mockUserPrivileges } from "__utils__/permissions"; -import { Session } from "next-auth"; +import { mockAuthorizationPass } from "__utils__/authorization"; + +jest.mock("@lib/privileges"); +const userId = "1"; +mockAuthorizationPass(userId); describe("Create Security Questions", () => { it("Allows a user to create their security questions", async () => { - const fakeSession = { - user: { id: "1", privileges: mockUserPrivileges(Base, { user: { id: "1" } }) }, - }; - const ability = createAbility(fakeSession as Session); - //_retrieveUserSecurityAnswers (prismaMock.user.findUnique as jest.MockedFunction).mockResolvedValue({ securityAnswers: [], @@ -32,10 +29,10 @@ describe("Create Security Questions", () => { const userUpdate = (prismaMock.user.update as jest.MockedFunction).mockResolvedValue({}); - await createSecurityAnswers(ability, [{ questionId: "10", answer: "answer" }]); + await createSecurityAnswers([{ questionId: "10", answer: "answer" }]); expect(userUpdate).toHaveBeenCalledTimes(1); expect(userUpdate).toHaveBeenCalledWith({ - where: { id: "1" }, + where: { id: userId }, data: { securityAnswers: { create: [ @@ -49,11 +46,6 @@ describe("Create Security Questions", () => { }); }); it("Throws an error if the user already has security questions", async () => { - const fakeSession = { - user: { id: "1", privileges: mockUserPrivileges(Base, { user: { id: "1" } }) }, - }; - const ability = createAbility(fakeSession as Session); - //_retrieveUserSecurityAnswers (prismaMock.user.findUnique as jest.MockedFunction).mockResolvedValue({ securityAnswers: [ @@ -65,15 +57,10 @@ describe("Create Security Questions", () => { ], }); await expect(async () => { - await createSecurityAnswers(ability, [{ questionId: "10", answer: "answer" }]); + await createSecurityAnswers([{ questionId: "10", answer: "answer" }]); }).rejects.toThrow(new AlreadyHasSecurityAnswers()); }); it("Throws an error if the user tries to create security questions with invalid question ids", async () => { - const fakeSession = { - user: { id: "1", privileges: mockUserPrivileges(Base, { user: { id: "1" } }) }, - }; - const ability = createAbility(fakeSession as Session); - //_retrieveUserSecurityAnswers (prismaMock.user.findUnique as jest.MockedFunction).mockResolvedValue({ securityAnswers: [], @@ -86,17 +73,12 @@ describe("Create Security Questions", () => { const userUpdate = (prismaMock.user.update as jest.MockedFunction).mockResolvedValue({}); await expect(async () => { - await createSecurityAnswers(ability, [{ questionId: "10", answer: "answer" }]); + await createSecurityAnswers([{ questionId: "10", answer: "answer" }]); }).rejects.toThrow(new InvalidSecurityQuestionId()); expect(userUpdate).toHaveBeenCalledTimes(0); }); it("Throws and error if the user tries to create security questions with duplicated question ids", async () => { - const fakeSession = { - user: { id: "1", privileges: mockUserPrivileges(Base, { user: { id: "1" } }) }, - }; - const ability = createAbility(fakeSession as Session); - //_retrieveUserSecurityAnswers (prismaMock.user.findUnique as jest.MockedFunction).mockResolvedValue({ securityAnswers: [], @@ -109,7 +91,7 @@ describe("Create Security Questions", () => { const userUpdate = (prismaMock.user.update as jest.MockedFunction).mockResolvedValue({}); await expect(async () => { - await createSecurityAnswers(ability, [ + await createSecurityAnswers([ { questionId: "10", answer: "answer" }, { questionId: "10", answer: "answer" }, ]); @@ -119,11 +101,6 @@ describe("Create Security Questions", () => { }); describe("Update Security Questions", () => { it("Allows a user to update their security questions", async () => { - const fakeSession = { - user: { id: "1", privileges: mockUserPrivileges(Base, { user: { id: "1" } }) }, - }; - const ability = createAbility(fakeSession as Session); - //_retrieveUserSecurityAnswers (prismaMock.user.findUnique as jest.MockedFunction).mockResolvedValue({ securityAnswers: [ @@ -144,7 +121,7 @@ describe("Update Security Questions", () => { prismaMock.securityAnswer.update as jest.MockedFunction ).mockResolvedValue({}); - await updateSecurityAnswer(ability, { + await updateSecurityAnswer({ oldQuestionId: "10", newQuestionId: "10", newAnswer: "answer", @@ -159,11 +136,6 @@ describe("Update Security Questions", () => { }); }); it("Allows a user to change their security questions", async () => { - const fakeSession = { - user: { id: "1", privileges: mockUserPrivileges(Base, { user: { id: "1" } }) }, - }; - const ability = createAbility(fakeSession as Session); - //_retrieveUserSecurityAnswers (prismaMock.user.findUnique as jest.MockedFunction).mockResolvedValue({ securityAnswers: [ @@ -185,7 +157,7 @@ describe("Update Security Questions", () => { prismaMock.securityAnswer.update as jest.MockedFunction ).mockResolvedValue({}); - await updateSecurityAnswer(ability, { + await updateSecurityAnswer({ oldQuestionId: "10", newQuestionId: "9", newAnswer: "answer", @@ -200,11 +172,6 @@ describe("Update Security Questions", () => { }); }); it("Throws an error if securityQuestions are not yet set", async () => { - const fakeSession = { - user: { id: "1", privileges: mockUserPrivileges(Base, { user: { id: "1" } }) }, - }; - const ability = createAbility(fakeSession as Session); - //_retrieveUserSecurityAnswers (prismaMock.user.findUnique as jest.MockedFunction).mockResolvedValue({ securityAnswers: [], @@ -220,7 +187,7 @@ describe("Update Security Questions", () => { ).mockResolvedValue({}); await expect(async () => { - await updateSecurityAnswer(ability, { + await updateSecurityAnswer({ oldQuestionId: "10", newQuestionId: "10", newAnswer: "answer", @@ -229,11 +196,6 @@ describe("Update Security Questions", () => { expect(userUpdate).toHaveBeenCalledTimes(0); }); it("Throws an error if the user tries to update security questions with invalid question ids", async () => { - const fakeSession = { - user: { id: "1", privileges: mockUserPrivileges(Base, { user: { id: "1" } }) }, - }; - const ability = createAbility(fakeSession as Session); - //_retrieveUserSecurityAnswers (prismaMock.user.findUnique as jest.MockedFunction).mockResolvedValue({ securityAnswers: [ @@ -252,7 +214,7 @@ describe("Update Security Questions", () => { ]); await expect(async () => { - await updateSecurityAnswer(ability, { + await updateSecurityAnswer({ oldQuestionId: "9", newQuestionId: "6", newAnswer: "answer", @@ -260,11 +222,6 @@ describe("Update Security Questions", () => { }).rejects.toThrow(new InvalidSecurityQuestionId()); }); it("Throws an error if the user tries to create security questions with duplicated question ids", async () => { - const fakeSession = { - user: { id: "1", privileges: mockUserPrivileges(Base, { user: { id: "1" } }) }, - }; - const ability = createAbility(fakeSession as Session); - //_retrieveUserSecurityAnswers (prismaMock.user.findUnique as jest.MockedFunction).mockResolvedValue({ securityAnswers: [ @@ -289,7 +246,7 @@ describe("Update Security Questions", () => { const userUpdate = (prismaMock.user.update as jest.MockedFunction).mockResolvedValue({}); await expect(async () => { - await updateSecurityAnswer(ability, { + await updateSecurityAnswer({ oldQuestionId: "9", newQuestionId: "10", newAnswer: "answer", diff --git a/lib/tests/serviceAccount.test.ts b/lib/tests/serviceAccount.test.ts index 9c91a4d7b8..7327624410 100644 --- a/lib/tests/serviceAccount.test.ts +++ b/lib/tests/serviceAccount.test.ts @@ -5,12 +5,11 @@ import { checkMachineUserExists, checkKeyExists, createKey, - refreshKey, deleteKey, } from "@lib/serviceAccount"; import { mockAuthorizationPass, mockAuthorizationFail } from "__utils__/authorization"; -import { AccessControlError } from "@lib/auth"; +import { AccessControlError } from "@lib/auth/errors"; import { prismaMock } from "@jestUtils"; import { logEvent } from "@lib/auditLogs"; @@ -165,89 +164,7 @@ describe("Service Account functions", () => { await expect(createKey("templateId")).rejects.toThrow(AccessControlError); }); }); - describe("refreshKey", () => { - it("should refresh a key", async () => { - const serviceAccountID = "123412341234"; - - mockedZitadel.mockResolvedValue({ - getUserByLoginNameGlobal: jest.fn().mockResolvedValue({ user: { id: serviceAccountID } }), - addMachineKey: jest.fn().mockResolvedValue({ keyId: "keyId" }), - removeMachineKey: jest.fn().mockResolvedValue({}), - }); - (prismaMock.apiServiceAccount.findUnique as jest.MockedFunction).mockResolvedValue({ - id: serviceAccountID, - publicKeyId: "1234", - }); - const result = await refreshKey("templateId"); - expect(result).toMatchObject({ - keyId: "keyId", - type: "serviceAccount", - userId: "123412341234", - formId: "templateId", - }); - expect(mockedLogEvent).toHaveBeenCalledWith( - "1", - { type: "ServiceAccount" }, - "RefreshAPIKey", - "User :1 refreshed API key for service account 123412341234" - ); - }); - it("should throw if the service account does not exist on the IDP", async () => { - mockedZitadel.mockResolvedValue({ - getUserByLoginNameGlobal: jest.fn().mockResolvedValue({ user: undefined }), - }); - - await expect(refreshKey("templateId")).rejects.toThrow( - "Service Account User for template templateId does not exist when trying to refresh API Key" - ); - }); - it("should throw if the service account does not exist in the database", async () => { - const serviceAccountID = "123412341234"; - mockedZitadel.mockResolvedValue({ - getUserByLoginNameGlobal: jest.fn().mockResolvedValue({ user: { id: serviceAccountID } }), - }); - (prismaMock.apiServiceAccount.findUnique as jest.MockedFunction).mockResolvedValue(null); - await expect(refreshKey("templateId")).rejects.toThrow( - "No Key Exists in GCForms DB for template templateId" - ); - }); - it("should throw if the service account id's in the Database and IDP do not match", async () => { - const serviceAccountID = "123412341234"; - mockedZitadel.mockResolvedValue({ - getUserByLoginNameGlobal: jest.fn().mockResolvedValue({ user: { id: serviceAccountID } }), - }); - (prismaMock.apiServiceAccount.findUnique as jest.MockedFunction).mockResolvedValue({ - id: "aDifferentID", - publicKeyId: "1234", - }); - await expect(refreshKey("templateId")).rejects.toThrow( - "Service Account User ID for template templateId is out of sync between GCForms and Zitadel" - ); - }); - it("should throw if it could not remove the old key", async () => { - const serviceAccountID = "123412341234"; - mockedZitadel.mockResolvedValue({ - getUserByLoginNameGlobal: jest.fn().mockResolvedValue({ user: { id: serviceAccountID } }), - removeMachineKey: jest.fn().mockRejectedValue(new Error("Zitadel Error Message")), - }); - (prismaMock.apiServiceAccount.findUnique as jest.MockedFunction).mockResolvedValue({ - id: serviceAccountID, - publicKeyId: "1234", - }); - await expect(refreshKey("templateId")).rejects.toThrow( - "Failed to delete key in Zitadel for template templateId" - ); - }); - it("should throw and error is user is not authentiated to perform the action", async () => { - mockAuthorizationFail(userId); - await expect(refreshKey("templateId")).rejects.toThrow(AccessControlError); - }); - it("should throw and error is user is not authorized to perform the action", async () => { - mockAuthorizationFail(userId); - await expect(refreshKey("templateId")).rejects.toThrow(AccessControlError); - }); - }); describe("deleteKey", () => { it("should delete a key if there is an existing user in the IDP", async () => { const serviceAccountID = "123412341234"; diff --git a/lib/tests/templates.test.ts b/lib/tests/templates.test.ts index 7147181938..2ebf710e04 100644 --- a/lib/tests/templates.test.ts +++ b/lib/tests/templates.test.ts @@ -28,7 +28,7 @@ import { Prisma } from "@prisma/client"; import { logEvent } from "@lib/auditLogs"; import { unprocessedSubmissions } from "@lib/vault"; import { deleteKey } from "@lib/serviceAccount"; -import { AccessControlError } from "@lib/auth"; +import { AccessControlError } from "@lib/auth/errors"; import { mockAuthorizationPass, mockAuthorizationFail, @@ -649,7 +649,7 @@ describe("Template CRUD functions", () => { ).rejects.toThrow(AccessControlError); expect(mockedLogEvent).toHaveBeenNthCalledWith( 1, - Promise.resolve(userID), + userID, { type: "Form" }, "AccessDenied", "Attempted to create a Form" @@ -661,7 +661,7 @@ describe("Template CRUD functions", () => { expect(result).toEqual([]); expect(mockedLogEvent).toHaveBeenNthCalledWith( 1, - Promise.resolve(userID), + userID, { type: "Form" }, "AccessDenied", "Attempted to access All System Forms" @@ -672,7 +672,7 @@ describe("Template CRUD functions", () => { expect(result).toBe(null); expect(mockedLogEvent).toHaveBeenNthCalledWith( 1, - Promise.resolve(userID), + userID, { id: "1", type: "Form" }, "AccessDenied", "Attemped to read form object" @@ -688,7 +688,7 @@ describe("Template CRUD functions", () => { ).rejects.toThrow(AccessControlError); expect(mockedLogEvent).toHaveBeenNthCalledWith( 1, - Promise.resolve(userID), + userID, { id: "test1", type: "Form" }, "AccessDenied", "Attempted to update Form" @@ -700,7 +700,7 @@ describe("Template CRUD functions", () => { ); expect(mockedLogEvent).toHaveBeenNthCalledWith( 1, - Promise.resolve(userID), + userID, { id: "formtestID", type: "Form" }, "AccessDenied", "Attempted to publish form" @@ -711,7 +711,7 @@ describe("Template CRUD functions", () => { await expect(removeDeliveryOption("formtestID")).rejects.toThrow(AccessControlError); expect(mockedLogEvent).toHaveBeenNthCalledWith( 1, - Promise.resolve(userID), + userID, { id: "formtestID", type: "Form" }, "AccessDenied", "Attempted to set Delivery Option to the Vault" @@ -722,7 +722,7 @@ describe("Template CRUD functions", () => { await expect(deleteTemplate("formtestID")).rejects.toThrow(AccessControlError); expect(mockedLogEvent).toHaveBeenNthCalledWith( 1, - Promise.resolve(userID), + userID, { id: "formtestID", type: "Form" }, "AccessDenied", "Attempted to delete Form" diff --git a/lib/tests/users.test.ts b/lib/tests/users.test.ts index 4e3cb2387d..5b273e5bda 100644 --- a/lib/tests/users.test.ts +++ b/lib/tests/users.test.ts @@ -3,17 +3,26 @@ import { prismaMock } from "@jestUtils"; import { getUsers, getOrCreateUser } from "@lib/users"; import { Prisma } from "@prisma/client"; -import { createAbility } from "@lib/privileges"; -import { AccessControlError } from "@lib/auth"; -import { ManageUsers, ViewUserPrivileges, Base } from "__utils__/permissions"; -import { Session } from "next-auth"; + +import { AccessControlError } from "@lib/auth/errors"; +import { ManageUsers, Base } from "__utils__/permissions"; + import { logEvent } from "@lib/auditLogs"; jest.mock("@lib/auditLogs"); -jest.mock("@lib/auth"); -const mockedLogEvent = jest.mocked(logEvent, { shallow: true }); +jest.mock("@lib/privileges"); + import { JWT } from "next-auth/jwt"; +import { mockAuthorizationFail, mockAuthorizationPass } from "__utils__/authorization"; + +const userId = "1"; + +jest.mock("@lib/auditLogs"); +const mockedLogEvent = jest.mocked(logEvent, { shallow: true }); describe("User query tests should fail gracefully", () => { + beforeEach(() => { + mockAuthorizationPass(userId); + }); it("getOrCreateUser should fail gracefully - create", async () => { prismaMock.user.findUnique.mockResolvedValue(null); (prismaMock.privilege.findUnique as jest.MockedFunction).mockResolvedValue({ id: "2" }); @@ -49,11 +58,6 @@ describe("User query tests should fail gracefully", () => { }); it("getUsers should fail silenty", async () => { - const fakeSession = { - user: { id: "1", privileges: ManageUsers }, - }; - const ability = createAbility(fakeSession as Session); - prismaMock.user.findMany.mockRejectedValue( new Prisma.PrismaClientKnownRequestError("Timed out", { code: "P2024", @@ -61,7 +65,7 @@ describe("User query tests should fail gracefully", () => { }) ); - const result = await getUsers(ability); + const result = await getUsers(); expect(result).toHaveLength(0); expect(mockedLogEvent).not.toHaveBeenCalled(); }); @@ -131,11 +135,7 @@ describe("getOrCreateUser", () => { }); describe("getUsers", () => { - it.each([[ViewUserPrivileges], [ManageUsers]])("Returns a list of users", async (privileges) => { - const fakeSession = { - user: { id: "1", privileges }, - }; - const ability = createAbility(fakeSession as Session); + it("Returns a list of users", async () => { const returnedUsers = [ { id: "3", @@ -153,23 +153,19 @@ describe("getUsers", () => { (prismaMock.user.findMany as jest.MockedFunction).mockResolvedValue(returnedUsers); - const result = await getUsers(ability); + const result = await getUsers(); expect(result).toMatchObject(returnedUsers); }); }); describe("Users CRUD functions should throw an error if user does not have any permissions", () => { + beforeEach(() => { + mockAuthorizationFail(userId); + }); it("User with no permission should not be able to use CRUD functions", async () => { - const fakeSession = { - user: { id: "1", privileges: Base }, - }; - const ability = createAbility(fakeSession as Session); - - await expect(async () => { - await getUsers(ability); - }).rejects.toBeInstanceOf(AccessControlError); + await expect(getUsers()).rejects.toThrow(AccessControlError); expect(mockedLogEvent).toHaveBeenCalledWith( - fakeSession.user.id, + userId, { type: "User" }, "AccessDenied", "Attempted to list users" diff --git a/lib/tests/vault.test.ts b/lib/tests/vault.test.ts index d39d494193..2e28e406e4 100644 --- a/lib/tests/vault.test.ts +++ b/lib/tests/vault.test.ts @@ -3,7 +3,7 @@ import Redis from "ioredis-mock"; import { mockClient } from "aws-sdk-client-mock"; import { prismaMock } from "@jestUtils"; import { DynamoDBDocumentClient, QueryCommand, BatchWriteCommand } from "@aws-sdk/lib-dynamodb"; -import { AccessControlError } from "@lib/auth"; +import { AccessControlError } from "@lib/auth/errors"; import { deleteDraftFormResponses, unprocessedSubmissions } from "@lib/vault"; import formConfiguration from "@jestFixtures/cdsIntakeTestForm.json"; import { DeliveryOption } from "@lib/types"; diff --git a/lib/users.ts b/lib/users.ts index b8b8ac08ab..9c91a95ea1 100644 --- a/lib/users.ts +++ b/lib/users.ts @@ -1,8 +1,7 @@ import { prisma, prismaErrors } from "@lib/integration/prismaConnector"; - -import { checkPrivileges } from "@lib/privileges"; -import { AccessControlError } from "@lib/auth"; -import { NagwareResult, UserAbility } from "./types"; +import { authorization } from "@lib/privileges"; +import { AccessControlError } from "@lib/auth/errors"; +import { NagwareResult } from "./types"; import { logEvent } from "./auditLogs"; import { logMessage } from "@lib/logger"; import { Privilege, Prisma } from "@prisma/client"; @@ -125,14 +124,52 @@ export const getOrCreateUser = async ({ * Get User by id * @returns User if found */ -export const getUser = async (ability: UserAbility, id: string): Promise => { - try { - checkPrivileges(ability, [{ action: "view", subject: { type: "User", object: { id } } }]); +export const getUser = async (id: string): Promise => { + await authorization.canViewUser(id).catch((e) => { + if (e instanceof AccessControlError) { + logEvent(e.user.id, { type: "User" }, "AccessDenied", `Attempted to get user by id ${id}`); + } + throw e; + }); - const user = await prisma.user.findFirstOrThrow({ - where: { - id: id, + return prisma.user.findFirstOrThrow({ + where: { + id: id, + }, + select: { + id: true, + name: true, + email: true, + active: true, + createdAt: true, + notes: true, + privileges: { + select: { + id: true, + name: true, + descriptionEn: true, + descriptionFr: true, + }, }, + }, + }); +}; + +/** + * Get all Users + * @returns An array of all Users + */ +export const getUsers = async (where?: Prisma.UserWhereInput): Promise => { + await authorization.canViewAllUsers().catch((e) => { + if (e instanceof AccessControlError) { + logEvent(e.user.id, { type: "User" }, "AccessDenied", "Attempted to list users"); + } + throw e; + }); + + const users = await prisma.user + .findMany({ + ...(where && { where }), select: { id: true, name: true, @@ -149,70 +186,13 @@ export const getUser = async (ability: UserAbility, id: string): Promise => { - try { - checkPrivileges(ability, [ - { - action: "view", - subject: "User", + orderBy: { + id: "asc", }, - ]); - - const users = await prisma.user - .findMany({ - ...(where && { where }), - select: { - id: true, - name: true, - email: true, - active: true, - createdAt: true, - notes: true, - privileges: { - select: { - id: true, - name: true, - descriptionEn: true, - descriptionFr: true, - }, - }, - }, - orderBy: { - id: "asc", - }, - }) - .catch((e) => prismaErrors(e, [])); + }) + .catch((e) => prismaErrors(e, [])); - return users; - } catch (e) { - if (e instanceof AccessControlError) { - logEvent(ability.user.id, { type: "User" }, "AccessDenied", "Attempted to list users"); - } - throw e; - } + return users; }; /** @@ -221,14 +201,19 @@ export const getUsers = async ( * @param active activate or deactivate user * @returns User */ -export const updateActiveStatus = async (ability: UserAbility, userID: string, active: boolean) => { +export const updateActiveStatus = async (userID: string, active: boolean) => { try { - checkPrivileges(ability, [ - { - action: "update", - subject: "User", - }, - ]); + const { user: abilityUser } = await authorization.canManageAllUsers().catch((e) => { + if (e instanceof AccessControlError) { + logEvent( + e.user.id, + { type: "User" }, + "AccessDenied", + `Attempted to update user ${userID} active status` + ); + } + throw e; + }); const [user, privilegedUser] = await Promise.all([ prisma.user.update({ @@ -246,7 +231,7 @@ export const updateActiveStatus = async (ability: UserAbility, userID: string, a }), prisma.user.findUniqueOrThrow({ where: { - id: ability.user.id, + id: abilityUser.id, }, select: { email: true, @@ -264,7 +249,7 @@ export const updateActiveStatus = async (ability: UserAbility, userID: string, a active ? "UserActivated" : "UserDeactivated", `User ${user.email} (userID: ${userID}) was ${active ? "activated" : "deactivated"} by user ${ privilegedUser.email - } (userID: ${ability.user.id})` + } (userID: ${abilityUser.id})` ); if (!active && user.email) { @@ -273,15 +258,6 @@ export const updateActiveStatus = async (ability: UserAbility, userID: string, a return user; } catch (error) { - if (error instanceof AccessControlError) { - logEvent( - ability.user.id, - { type: "User" }, - "AccessDenied", - `Attempted to get user by id ${userID}` - ); - } - logMessage.error(error as Error); throw error; } diff --git a/lib/vault.ts b/lib/vault.ts index a4a52a9a66..b10398bbfc 100644 --- a/lib/vault.ts +++ b/lib/vault.ts @@ -21,7 +21,7 @@ import { import { dynamoDBDocumentClient } from "./integration/awsServicesConnector"; import { logMessage } from "./logger"; import { authorization } from "./privileges"; -import { AccessControlError } from "@lib/auth"; +import { AccessControlError } from "@lib/auth/errors"; import { chunkArray } from "@lib/utils"; import { TemplateAlreadyPublishedError } from "@lib/templates"; import { getAppSetting } from "./appSettings";