This commit is contained in:
Fred KISSIE
2026-03-10 18:54:26 +01:00
parent 6686de6788
commit 61ec938b00
11 changed files with 191 additions and 312 deletions

View File

@@ -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",

View File

@@ -146,7 +146,8 @@ export enum ActionsEnum {
setResourcePolicyPincode = "setResourcePolicyPincode",
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
setResourcePolicyRules = "setResourcePolicyRules"
setResourcePolicyRules = "setResourcePolicyRules",
getResourcePolicies = "getResourcePolicies"
}
export async function checkUserActionPermission(

View File

@@ -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,

View File

@@ -453,6 +453,13 @@ authenticated.get(
policy.getResourcePolicy
);
authenticated.get(
"/resource/:resourceId/policies",
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.getResourcePolicies),
resource.getResourcePolicies
);
authenticated.post(
"/resource/:resourceId",
verifyApiKeyResourceAccess,

View File

@@ -40,7 +40,9 @@ const getResourcePolicySchema = z
})
);
async function query(params: z.infer<typeof getResourcePolicySchema>) {
export async function queryResourcePolicy(
params: z.infer<typeof getResourcePolicySchema>
) {
const conditions: SQL<unknown>[] = [];
if ("resourcePolicyId" in params) {
conditions.push(
@@ -158,7 +160,7 @@ async function query(params: z.infer<typeof getResourcePolicySchema>) {
}
export type GetResourcePolicyResponse = NonNullable<
Awaited<ReturnType<typeof query>>
Awaited<ReturnType<typeof queryResourcePolicy>>
>;
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(

View File

@@ -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<any> {
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<GetResourcePoliciesResponse>(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")
);
}
}

View File

@@ -31,3 +31,4 @@ export * from "./addUserToResource";
export * from "./removeUserFromResource";
export * from "./listAllResourceNames";
export * from "./removeEmailFromResourceWhitelist";
export * from "./getResourcePolicies";

3
solo.yml Normal file
View File

@@ -0,0 +1,3 @@
name: pangolin
icon: public/logo/pangolin_profile_picture.png
processes: {}

View File

@@ -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<ResourcePolicyType> = [
{
id: "inline",
title: t("resourcePolicyInline"),
description: t("resourcePolicyInlineDescription")
},
{
id: "shared",
title: t("resourcePolicyShared"),
description: t("resourcePolicySharedDescription")
}
];
const [selectedResourceType, setSelectedResourceType] =
useState<ResourcePolicyType["id"]>("inline");
if (pageLoading) {
return <></>;
}
@@ -465,324 +494,39 @@ export default function ResourceAuthenticationPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceUsersRoles")}
{t("resourcePolicySelectTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceUsersRolesDescription")}
{t("resourcePolicySelectDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<SwitchInput
id="sso-toggle"
label={t("ssoUse")}
checked={ssoEnabled}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
<Form {...usersRolesForm}>
<form
action={submitUserRolesForm}
id="users-roles-form"
className="space-y-4"
>
{ssoEnabled && (
<>
<FormField
control={usersRolesForm.control}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
{t("roles")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeRolesTagIndex
}
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder={t(
"accessRoleSelect2"
)}
size="sm"
tags={
usersRolesForm.getValues()
.roles
}
setTags={(
newRoles
) => {
usersRolesForm.setValue(
"roles",
newRoles as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allRoles
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourceRoleDescription"
)}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={usersRolesForm.control}
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
{t("users")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeUsersTagIndex
}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder={t(
"accessUserSelect"
)}
tags={
usersRolesForm.getValues()
.users
}
size="sm"
setTags={(
newUsers
) => {
usersRolesForm.setValue(
"users",
newUsers as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allUsers
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{ssoEnabled && allIdps.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium">
{t("defaultIdentityProvider")}
</label>
<Select
onValueChange={(value) => {
if (value === "none") {
setSelectedIdpId(null);
} else {
setSelectedIdpId(
parseInt(value)
);
}
}}
value={
selectedIdpId
? selectedIdpId.toString()
: "none"
}
>
<SelectTrigger className="w-full mt-1">
<SelectValue
placeholder={t(
"selectIdpPlaceholder"
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("none")}
</SelectItem>
{allIdps.map((idp) => (
<SelectItem
key={idp.id}
value={idp.id.toString()}
>
{idp.text}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{t(
"defaultIdentityProviderDescription"
)}
</p>
</div>
)}
</form>
</Form>
</SettingsSectionForm>
<StrategySelect
options={resourcePolicyTypes}
defaultValue="inline"
onChange={(value) => {
// baseForm.setValue(
// "http",
// value === "http"
// );
// // Update method default when switching resource type
}}
cols={2}
/>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={loadingSaveUsersRoles}
disabled={loadingSaveUsersRoles}
form="users-roles-form"
disabled
form="policies-type-form"
>
{t("resourceUsersRolesSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
{/* <ResourcePolicyContext value={policies?.defaultPolicy}>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceAuthMethods")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceAuthMethodsDescriptions")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
{/* Password Protection */}
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
<div
className={`flex items-center ${!authInfo.password ? "" : "text-green-500"} text-sm space-x-2`}
>
<Key size="14" />
<span>
{t("resourcePasswordProtection", {
status: authInfo.password
? t("enabled")
: t("disabled")
})}
</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={
authInfo.password
? removeResourcePassword
: () => setIsSetPasswordOpen(true)
}
loading={loadingRemoveResourcePassword}
>
{authInfo.password
? t("passwordRemove")
: t("passwordAdd")}
</Button>
</div>
{/* PIN Code Protection */}
<div className="flex items-center justify-between border rounded-md p-2">
<div
className={`flex items-center ${!authInfo.pincode ? "" : "text-green-500"} space-x-2 text-sm`}
>
<Binary size="14" />
<span>
{t("resourcePincodeProtection", {
status: authInfo.pincode
? t("enabled")
: t("disabled")
})}
</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={
authInfo.pincode
? removeResourcePincode
: () => setIsSetPincodeOpen(true)
}
loading={loadingRemoveResourcePincode}
>
{authInfo.pincode
? t("pincodeRemove")
: t("pincodeAdd")}
</Button>
</div>
{/* Header Authentication Protection */}
<div className="flex items-center justify-between border rounded-md p-2">
<div
className={`flex items-center ${!authInfo.headerAuth ? "" : "text-green-500"} space-x-2 text-sm`}
>
<Bot size="14" />
<span>
{authInfo.headerAuth
? t(
"resourceHeaderAuthProtectionEnabled"
)
: t(
"resourceHeaderAuthProtectionDisabled"
)}
</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={
authInfo.headerAuth
? removeResourceHeaderAuth
: () => setIsSetHeaderAuthOpen(true)
}
loading={loadingRemoveResourceHeaderAuth}
>
{authInfo.headerAuth
? t("headerAuthRemove")
: t("headerAuthAdd")}
</Button>
</div>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<OneTimePasswordFormSection
resource={resource}
updateResource={updateResource}
whitelist={whitelist}
isLoadingWhiteList={isLoadingWhiteList}
/>
</ResourcePolicyContext> */}
</SettingsContainer>
</>
);

View File

@@ -25,11 +25,15 @@ export function StrategySelect<TValue extends string>({
value: controlledValue,
defaultValue,
onChange,
cols
cols = 1
}: StrategySelectProps<TValue>) {
const [uncontrolledSelected, setUncontrolledSelected] = useState<TValue | undefined>(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 (
<RadioGroup
@@ -39,7 +43,11 @@ export function StrategySelect<TValue extends string>({
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<TValue>) => (
<label

View File

@@ -4,6 +4,7 @@ import type { ListClientsResponse } from "@server/routers/client";
import type { ListDomainsResponse } from "@server/routers/domain";
import type {
GetResourceWhitelistResponse,
GetResourcePoliciesResponse,
ListResourceNamesResponse,
ListResourcesResponse,
ListResourceRolesResponse,
@@ -322,6 +323,17 @@ export const resourceQueries = {
return res.data.data.whitelist;
}
}),
policies: ({ resourceId }: { resourceId: number }) =>
queryOptions({
queryKey: ["RESOURCES", resourceId, "POLICIES"] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<GetResourcePoliciesResponse>
>(`/resource/${resourceId}/policies`, { signal });
return res.data.data;
}
}),
listNamesPerOrg: (orgId: string) =>
queryOptions({
queryKey: ["RESOURCES_NAMES", orgId] as const,