This commit is contained in:
Fred KISSIE
2026-02-26 19:20:15 +01:00
parent 4d803a40c9
commit c5231d37f6
19 changed files with 4177 additions and 116 deletions

View File

@@ -238,6 +238,8 @@
"rules": "Rules",
"resourceSettingDescription": "Configure the settings on the resource",
"resourceSetting": "{resourceName} Settings",
"resourcePolicySettingDescription": "Configure the settings on the resource policy",
"resourcePolicySetting": "{policyName} Settings",
"alwaysAllow": "Bypass Auth",
"alwaysDeny": "Block Access",
"passToAuth": "Pass to Auth",

View File

@@ -133,6 +133,7 @@ export enum ActionsEnum {
listApprovals = "listApprovals",
updateApprovals = "updateApprovals",
listResourcePolicies = "listResourcePolicies",
getResourcePolicy = "getResourcePolicy",
createResourcePolicy = "createResourcePolicy",
updateResourcePolicy = "updateResourcePolicy",
deleteResourcePolicy = "deleteResourcePolicy",

View File

@@ -14,3 +14,4 @@ export * from "./verifyApiKeyApiKeyAccess";
export * from "./verifyApiKeyClientAccess";
export * from "./verifyApiKeySiteResourceAccess";
export * from "./verifyApiKeyIdpAccess";
export * from "./verifyApiKeyResourcePolicyAccess";

View File

@@ -0,0 +1,92 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { resourcePolicies, apiKeyOrg } from "@server/db";
import { eq, and } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export async function verifyApiKeyResourcePolicyAccess(
req: Request,
res: Response,
next: NextFunction
) {
const apiKey = req.apiKey;
const resourcePolicyId =
req.params.resourcePolicyId ||
req.body.resourcePolicyId ||
req.query.resourcePolicyId;
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
try {
// Retrieve the resource policy
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
.limit(1);
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource policy with ID ${resourcePolicyId} not found`
)
);
}
if (apiKey.isRoot) {
// Root keys can access any resource policy in any org
return next();
}
if (!policy.orgId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Resource policy with ID ${resourcePolicyId} does not have an organization ID`
)
);
}
// Verify that the API key is linked to the resource policy's organization
if (!req.apiKeyOrg) {
const apiKeyOrgResult = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, policy.orgId)
)
)
.limit(1);
if (apiKeyOrgResult.length > 0) {
req.apiKeyOrg = apiKeyOrgResult[0];
}
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying resource policy access"
)
);
}
}

View File

@@ -10,7 +10,6 @@ import {
resourcePolicies,
rolePolicies,
userPolicies,
type ResourcePolicy,
type ResourcePolicy
} from "@server/db";
import { and, eq } from "drizzle-orm";

View File

@@ -3,6 +3,7 @@ import config from "@server/lib/config";
import * as site from "./site";
import * as org from "./org";
import * as resource from "./resource";
import * as policy from "./policy";
import * as domain from "./domain";
import * as target from "./target";
import * as user from "./user";
@@ -521,6 +522,7 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.getResource),
resource.getResource
);
authenticated.post(
"/resource/:resourceId",
verifyResourceAccess,
@@ -627,6 +629,15 @@ authenticated.post(
logActionAudit(ActionsEnum.updateRole),
role.updateRole
);
authenticated.get(
"/org/:orgId/resource-policy/:niceId",
verifyOrgAccess,
verifyResourcePolicyAccess,
verifyUserHasAction(ActionsEnum.getResourcePolicy),
policy.getResourcePolicy
);
// authenticated.get(
// "/role/:roleId",
// verifyRoleAccess,

View File

@@ -2,6 +2,7 @@ import * as site from "./site";
import * as org from "./org";
import * as blueprints from "./blueprints";
import * as resource from "./resource";
import * as policy from "./policy";
import * as domain from "./domain";
import * as target from "./target";
import * as user from "./user";
@@ -27,7 +28,8 @@ import {
verifyApiKeyClientAccess,
verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceClients,
verifyLimits
verifyLimits,
verifyApiKeyResourcePolicyAccess
} from "@server/middlewares";
import HttpCode from "@server/types/HttpCode";
import { Router } from "express";
@@ -392,6 +394,13 @@ authenticated.get(
resource.getResource
);
authenticated.get(
"/resource-policy/:resourcePolicyId",
verifyApiKeyResourcePolicyAccess,
verifyApiKeyHasAction(ActionsEnum.getResourcePolicy),
policy.getResourcePolicy
);
authenticated.post(
"/resource/:resourceId",
verifyApiKeyResourceAccess,

View File

@@ -0,0 +1,123 @@
import { db, resourcePolicies, rolePolicies, userPolicies } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import { and, eq, type SQL } 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 getResourcePolicySchema = z
.strictObject({
niceId: z.string(),
orgId: z.string()
})
.or(
z.strictObject({
resourcePolicyId: z.coerce.number<string>().int().positive()
})
);
async function query(params: z.infer<typeof getResourcePolicySchema>) {
const conditions: SQL<unknown>[] = [];
if ("resourcePolicyId" in params) {
conditions.push(
eq(resourcePolicies.resourcePolicyId, params.resourcePolicyId)
);
} else {
conditions.push(
eq(resourcePolicies.niceId, params.niceId),
eq(resourcePolicies.orgId, params.orgId)
);
}
const [res] = await db
.select({
policy: resourcePolicies,
userPolicies,
rolePolicies
})
.from(resourcePolicies)
.leftJoin(
userPolicies,
eq(userPolicies.resourcePolicyId, resourcePolicies.resourcePolicyId)
)
.leftJoin(
rolePolicies,
eq(rolePolicies.resourcePolicyId, resourcePolicies.resourcePolicyId)
)
.where(and(...conditions))
.limit(1);
return res;
}
export type GetResourcePolicyResponse = Awaited<ReturnType<typeof query>>;
registry.registerPath({
method: "get",
path: "/org/{orgId}/resource-policy/{niceId}",
description:
"Get a resource policy by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.",
tags: [OpenAPITags.Org, OpenAPITags.Policy],
request: {
params: z.object({
orgId: z.string(),
niceId: z.string()
})
},
responses: {}
});
registry.registerPath({
method: "get",
path: "/resource-policy/{resourcePolicyId}",
description: "Get a resource policy by its resourcePolicyId.",
tags: [OpenAPITags.Policy],
request: {
params: z.object({
resourcePolicyId: z.number()
})
},
responses: {}
});
export async function getResourcePolicy(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getResourcePolicySchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const policy = await query(parsedParams.data);
if (!policy) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
);
}
return response<GetResourcePolicyResponse>(res, {
data: policy,
success: true,
error: false,
message: "Resource Policy retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1 @@
export * from "./getResourcePolicy";

View File

@@ -0,0 +1,23 @@
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import OrgProvider from "@app/providers/OrgProvider";
import type { GetOrgResponse } from "@server/routers/org";
import { redirect } from "next/navigation";
export interface PolicyLayoutPageProps {
params: Promise<{ orgId: string }>;
children: React.ReactNode;
}
export default async function PolicyLayoutPage(props: PolicyLayoutPageProps) {
const params = await props.params;
let org: GetOrgResponse | null = null;
try {
const res = await getCachedOrg(params.orgId);
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings`);
}
return <OrgProvider org={org}>{props.children}</OrgProvider>;
}

View File

@@ -0,0 +1,58 @@
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import type { ResourcePolicy } from "@server/db";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { redirect } from "next/navigation";
export interface EditPolicyPageProps {
params: Promise<{ niceId: string; orgId: string }>;
}
export default async function EditPolicyPage(props: EditPolicyPageProps) {
const params = await props.params;
const t = await getTranslations();
let policy: ResourcePolicy | null = null;
try {
const res = await internal.get<
AxiosResponse<GetResourcePolicyResponse>
>(
`/org/${params.orgId}/resource-policy/${params.niceId}`,
await authCookieHeader()
);
policy = res.data.data.policy;
} catch {
redirect(`/${params.orgId}/settings/policies/resource`);
}
if (!policy) {
redirect(`/${params.orgId}/settings/policies/resource`);
}
return (
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={t("resourcePolicySetting", {
policyName: policy.name
})}
description={t("resourcePolicySettingDescription")}
/>
<Button asChild variant="outline">
<Link href={`/${params.orgId}/settings/policies/resource`}>
{t("resourcePoliciesSeeAll")}
</Link>
</Button>
</div>
<EditPolicyForm policy={policy} />
</>
);
}

View File

@@ -1,12 +1,8 @@
import { CreatePolicyForm } from "@app/components/resource-policy/CreatePolicyForm";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import OrgProvider from "@app/providers/OrgProvider";
import type { GetOrgResponse } from "@server/routers/org";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { redirect } from "next/navigation";
export interface CreateResourcePolicyPageProps {
params: Promise<{ orgId: string }>;
@@ -18,13 +14,6 @@ export default async function CreateResourcePolicyPage(
const params = await props.params;
const t = await getTranslations();
let org: GetOrgResponse | null = null;
try {
const res = await getCachedOrg(params.orgId);
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
return (
<>
<div className="flex justify-between">
@@ -34,15 +23,13 @@ export default async function CreateResourcePolicyPage(
/>
<Button asChild variant="outline">
<Link href={`/${params.orgId}/settings/resources/policies`}>
<Link href={`/${params.orgId}/settings/policies/resource`}>
{t("resourcePoliciesSeeAll")}
</Link>
</Button>
</div>
<OrgProvider org={org}>
<CreatePolicyForm />
</OrgProvider>
<CreatePolicyForm />
</>
);
}

View File

@@ -55,17 +55,15 @@ export default async function ResourcePoliciesPage(
description={t("resourcePoliciesDescription")}
/>
<OrgProvider org={org}>
<ResourcePoliciesTable
policies={policies}
orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</OrgProvider>
<ResourcePoliciesTable
policies={policies}
orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}

View File

@@ -132,7 +132,7 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
items: [
{
title: "sidebarResourcePolicies",
href: "/{orgId}/settings/policies/resources",
href: "/{orgId}/settings/policies/resource",
icon: (
<GlobeIcon className="size-4 flex-none" />
)

View File

@@ -103,7 +103,7 @@ export function ResourcePoliciesTable({
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${policyRow.orgId}/settings/resources/proxy/${policyRow.niceId}`}
href={`/${policyRow.orgId}/settings/policies/resource/${policyRow.niceId}`}
>
<DropdownMenuItem>
{t("viewSettings")}
@@ -122,7 +122,7 @@ export function ResourcePoliciesTable({
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${policyRow.orgId}/settings/resources/proxy/${policyRow.niceId}`}
href={`/${policyRow.orgId}/settings/policies/resource/${policyRow.niceId}`}
>
<Button variant={"outline"}>
{t("edit")}
@@ -165,7 +165,7 @@ export function ResourcePoliciesTable({
onAdd={() =>
startNavigation(() =>
router.push(
`/${orgId}/settings/policies/resources/create`
`/${orgId}/settings/policies/resource/create`
)
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -121,6 +121,62 @@ type LocalRule = {
updated?: boolean;
};
// ─── PolicyNameSection ──────────────────────────────────────────────────
type PolicyNameSectionProps = {
form: UseFormReturn<PolicyFormValues, any, any>;
isEditing?: boolean;
};
export function PolicyNameSection({ form }: PolicyNameSectionProps) {
const t = useTranslations();
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourcePolicyName")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourcePolicyNameDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name")}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"resourcePolicyNamePlaceholder"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex py-6 justify-end">
<Button
type="submit"
// loading={isSubmitting}
// disabled={isSubmitting}
>
{t("resourcePoliciesCreate")}
</Button>
</div>
</SettingsSection>
);
}
// ─── PolicyUsersRolesSection ──────────────────────────────────────────────────
type PolicyUsersRolesSectionProps = {
form: UseFormReturn<PolicyFormValues, any, any>;
allRoles: { id: string; text: string }[];
@@ -128,8 +184,6 @@ type PolicyUsersRolesSectionProps = {
allIdps: { id: number; text: string }[];
};
// ─── PolicyUsersRolesSection ──────────────────────────────────────────────────
export function PolicyUsersRolesSection({
form,
allRoles,

View File

@@ -45,3 +45,21 @@ export const createPolicySchema = z.object({
});
export type PolicyFormValues = z.infer<typeof createPolicySchema>;
export const addRuleSchema = z.object({
action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.string(),
value: z.string(),
priority: z.coerce.number<number>().int().optional()
});
export type LocalRule = {
ruleId: number;
action: "ACCEPT" | "DROP" | "PASS";
match: string;
value: string;
priority: number;
enabled: boolean;
new?: boolean;
updated?: boolean;
};