From 61ec938b0012404b70a912eb08bbbd671fdf6084 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 10 Mar 2026 18:54:26 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 6 + server/auth/actions.ts | 3 +- server/routers/external.ts | 7 + server/routers/integration.ts | 7 + server/routers/policy/getResourcePolicy.ts | 8 +- .../routers/resource/getResourcePolicies.ts | 88 +++++ server/routers/resource/index.ts | 1 + solo.yml | 3 + .../proxy/[niceId]/authentication/page.tsx | 352 +++--------------- src/components/StrategySelect.tsx | 16 +- src/lib/queries.ts | 12 + 11 files changed, 191 insertions(+), 312 deletions(-) create mode 100644 server/routers/resource/getResourcePolicies.ts create mode 100644 solo.yml diff --git a/messages/en-US.json b/messages/en-US.json index a6d2d36c4..106fc400a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -766,6 +766,12 @@ "resourcePincodeSetupTitle": "Set Pincode", "resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource", "resourceRoleDescription": "Admins can always access this resource.", + "resourcePolicySelectTitle": "Resource Access Policy", + "resourcePolicySelectDescription": "Select the resource policy type for authentication", + "resourcePolicyInline": "Inline Resource Policy", + "resourcePolicyInlineDescription": "Access Policy scoped to only this resource", + "resourcePolicyShared": "Shared Resource Policy", + "resourcePolicySharedDescription": "Access Policy shared accross multiple resources", "resourceUsersRoles": "Access Controls", "resourceUsersRolesDescription": "Configure which users and roles can visit this resource", "resourceUsersRolesSubmit": "Save Access Controls", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index b34b3fe57..5549380ab 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -146,7 +146,8 @@ export enum ActionsEnum { setResourcePolicyPincode = "setResourcePolicyPincode", setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth", setResourcePolicyWhitelist = "setResourcePolicyWhitelist", - setResourcePolicyRules = "setResourcePolicyRules" + setResourcePolicyRules = "setResourcePolicyRules", + getResourcePolicies = "getResourcePolicies" } export async function checkUserActionPermission( diff --git a/server/routers/external.ts b/server/routers/external.ts index 671bd4ac7..130ebbbb4 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -636,6 +636,13 @@ authenticated.get( policy.getResourcePolicy ); +authenticated.get( + "/resource/:resourceId/policies", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.getResourcePolicies), + resource.getResourcePolicies +); + authenticated.put( "/resource-policy/:resourcePolicyId", verifyResourcePolicyAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 92f1531ee..cadda13cb 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -453,6 +453,13 @@ authenticated.get( policy.getResourcePolicy ); +authenticated.get( + "/resource/:resourceId/policies", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.getResourcePolicies), + resource.getResourcePolicies +); + authenticated.post( "/resource/:resourceId", verifyApiKeyResourceAccess, diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts index 2a975dde7..d7513d58d 100644 --- a/server/routers/policy/getResourcePolicy.ts +++ b/server/routers/policy/getResourcePolicy.ts @@ -40,7 +40,9 @@ const getResourcePolicySchema = z }) ); -async function query(params: z.infer) { +export async function queryResourcePolicy( + params: z.infer +) { const conditions: SQL[] = []; if ("resourcePolicyId" in params) { conditions.push( @@ -158,7 +160,7 @@ async function query(params: z.infer) { } export type GetResourcePolicyResponse = NonNullable< - Awaited> + Awaited> >; registry.registerPath({ @@ -205,7 +207,7 @@ export async function getResourcePolicy( ); } - const policy = await query(parsedParams.data); + const policy = await queryResourcePolicy(parsedParams.data); if (!policy) { return next( diff --git a/server/routers/resource/getResourcePolicies.ts b/server/routers/resource/getResourcePolicies.ts new file mode 100644 index 000000000..c51e6f1fd --- /dev/null +++ b/server/routers/resource/getResourcePolicies.ts @@ -0,0 +1,88 @@ +import { db, resources } from "@server/db"; +import { + queryResourcePolicy, + type GetResourcePolicyResponse +} from "@server/routers/policy/getResourcePolicy"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { eq } from "drizzle-orm"; +import type { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import z from "zod"; +import { fromError } from "zod-validation-error"; + +const getResourcePoliciesParamsSchema = z.strictObject({ + resourceId: z.string().transform(Number).pipe(z.int().positive()) +}); + +export type GetResourcePoliciesResponse = { + defaultPolicy: GetResourcePolicyResponse | null; +}; + +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/policies", + description: "Get the default policy for a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Policy], + request: { + params: getResourcePoliciesParamsSchema + }, + responses: {} +}); + +export async function getResourcePolicies( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getResourcePoliciesParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select({ + defaultResourcePolicyId: resources.defaultResourcePolicyId + }) + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const defaultPolicy = resource.defaultResourcePolicyId + ? await queryResourcePolicy({ + resourcePolicyId: resource.defaultResourcePolicyId + }) + : null; + + return response(res, { + data: { defaultPolicy }, + success: true, + error: false, + message: "Resource policies retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 3ada13d85..78803105f 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -31,3 +31,4 @@ export * from "./addUserToResource"; export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; +export * from "./getResourcePolicies"; diff --git a/solo.yml b/solo.yml new file mode 100644 index 000000000..1a6f9f331 --- /dev/null +++ b/solo.yml @@ -0,0 +1,3 @@ +name: pangolin +icon: public/logo/pangolin_profile_picture.png +processes: {} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index a533fb6c3..e2e315d35 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -12,6 +12,10 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; +import { + StrategySelect, + type StrategyOption +} from "@app/components/StrategySelect"; import { SwitchInput } from "@app/components/SwitchInput"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; @@ -42,6 +46,7 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries, resourceQueries } from "@app/lib/queries"; +import { ResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -86,6 +91,8 @@ const whitelistSchema = z.object({ ) }); +type ResourcePolicyType = StrategyOption<"inline" | "shared">; + export default function ResourceAuthenticationPage() { const { org } = useOrgContext(); const { resource, updateResource, authInfo, updateAuthInfo } = @@ -118,6 +125,11 @@ export default function ResourceAuthenticationPage() { resourceId: resource.resourceId }) ); + const { data: policies, isLoading: isLoadingPolicies } = useQuery( + resourceQueries.policies({ + resourceId: resource.resourceId + }) + ); const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( orgQueries.roles({ @@ -142,7 +154,8 @@ export default function ResourceAuthenticationPage() { isLoadingResourceRoles || isLoadingResourceUsers || isLoadingWhiteList || - isLoadingOrgIdps; + isLoadingOrgIdps || + isLoadingPolicies; const allRoles = useMemo(() => { return orgRoles @@ -413,6 +426,22 @@ export default function ResourceAuthenticationPage() { .finally(() => setLoadingRemoveResourceHeaderAuth(false)); } + const resourcePolicyTypes: Array = [ + { + id: "inline", + title: t("resourcePolicyInline"), + description: t("resourcePolicyInlineDescription") + }, + { + id: "shared", + title: t("resourcePolicyShared"), + description: t("resourcePolicySharedDescription") + } + ]; + + const [selectedResourceType, setSelectedResourceType] = + useState("inline"); + if (pageLoading) { return <>; } @@ -465,324 +494,39 @@ export default function ResourceAuthenticationPage() { - {t("resourceUsersRoles")} + {t("resourcePolicySelectTitle")} - {t("resourceUsersRolesDescription")} + {t("resourcePolicySelectDescription")} - - setSsoEnabled(val)} - /> - -
- - {ssoEnabled && ( - <> - ( - - - {t("roles")} - - - { - usersRolesForm.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allRoles - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - {t( - "resourceRoleDescription" - )} - - - )} - /> - ( - - - {t("users")} - - - { - usersRolesForm.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allUsers - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - )} - - {ssoEnabled && allIdps.length > 0 && ( -
- - -

- {t( - "defaultIdentityProviderDescription" - )} -

-
- )} - - -
+ { + // baseForm.setValue( + // "http", + // value === "http" + // ); + // // Update method default when switching resource type + }} + cols={2} + />
+ {/* - - - - {t("resourceAuthMethods")} - - - {t("resourceAuthMethodsDescriptions")} - - - - - {/* Password Protection */} -
-
- - - {t("resourcePasswordProtection", { - status: authInfo.password - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* PIN Code Protection */} -
-
- - - {t("resourcePincodeProtection", { - status: authInfo.pincode - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Header Authentication Protection */} -
-
- - - {authInfo.headerAuth - ? t( - "resourceHeaderAuthProtectionEnabled" - ) - : t( - "resourceHeaderAuthProtectionDisabled" - )} - -
- -
-
-
-
- - +
*/} ); diff --git a/src/components/StrategySelect.tsx b/src/components/StrategySelect.tsx index 7f747360f..b4cd961d4 100644 --- a/src/components/StrategySelect.tsx +++ b/src/components/StrategySelect.tsx @@ -25,11 +25,15 @@ export function StrategySelect({ value: controlledValue, defaultValue, onChange, - cols + cols = 1 }: StrategySelectProps) { - const [uncontrolledSelected, setUncontrolledSelected] = useState(defaultValue); + const [uncontrolledSelected, setUncontrolledSelected] = useState< + TValue | undefined + >(defaultValue); const isControlled = controlledValue !== undefined; - const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected; + const selected = isControlled + ? (controlledValue ?? undefined) + : uncontrolledSelected; return ( ({ if (!isControlled) setUncontrolledSelected(typedValue); onChange?.(typedValue); }} - className={`grid md:grid-cols-${cols ? cols : 1} gap-4`} + style={{ + // @ts-expect-error + "--cols": `repeat(${cols}, 1fr)` + }} + className="grid md:grid-cols-(--cols) gap-4" > {options.map((option: StrategyOption) => (