Compare commits

...
Author SHA1 Message Date
Alan Buscaglia d8016dba9a feat(ui): add Registry permission support
- Propagate Registry access through Cloud role and session models
- Preserve safe defaults and non-Cloud permission behavior
- Add default-off environment typing and focused coverage
2026-08-20 15:05:16 +02:00
Alan Buscaglia 8ab575f181 chore(ui): initialize Registry feature chain 2026-08-20 15:05:06 +02:00
21 changed files with 235 additions and 2 deletions
+27 -2
View File
@@ -23,7 +23,7 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({
import { createNewUser, getUserByMe } from "./auth";
const userMeResponse = (roleAttributes: Record<string, boolean>) => ({
const userMeResponse = (roleAttributes: Record<string, unknown>) => ({
data: {
type: "users",
id: "019b1234-5678-7abc-9def-0123456789ab",
@@ -43,7 +43,7 @@ const userMeResponse = (roleAttributes: Record<string, boolean>) => ({
],
});
const mockUserMe = (roleAttributes: Record<string, boolean>) => {
const mockUserMe = (roleAttributes: Record<string, unknown>) => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify(userMeResponse(roleAttributes)), {
status: 200,
@@ -154,4 +154,29 @@ describe("auth actions", () => {
expect(result.permissions.manage_lighthouse_ai_configuration).toBe(false);
expect(result.permissions.manage_users).toBe(true);
});
it("should carry an exact manage_registry permission into the session", async () => {
// Given
mockUserMe({ manage_registry: true });
// When
const result = await getUserByMe("access-token");
// Then
expect(result.permissions.manage_registry).toBe(true);
});
it.each([undefined, "true", "TRUE", 1])(
"should deny a malformed manage_registry value of %j",
async (manageRegistry) => {
// Given
mockUserMe({ manage_registry: manageRegistry });
// When
const result = await getUserByMe("access-token");
// Then
expect(result.permissions.manage_registry).toBe(false);
},
);
});
+1
View File
@@ -183,6 +183,7 @@ export const getUserByMe = async (accessToken: string) => {
manage_alerts: userRole.attributes.manage_alerts || false,
manage_lighthouse_ai_configuration:
userRole.attributes.manage_lighthouse_ai_configuration || false,
manage_registry: userRole.attributes.manage_registry === true,
unlimited_visibility: userRole.attributes.unlimited_visibility || false,
};
+31
View File
@@ -50,6 +50,7 @@ const makeRoleFormData = () => {
formData.set("manage_scans", "false");
formData.set("manage_alerts", "true");
formData.set("manage_lighthouse_ai_configuration", "true");
formData.set("manage_registry", "true");
formData.set("unlimited_visibility", "false");
return formData;
};
@@ -73,6 +74,36 @@ describe("role actions", () => {
vi.unstubAllEnvs();
});
it("includes manage_registry when creating and updating a role in Prowler Cloud", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
await addRole(makeRoleFormData());
const createAttributes = lastRequestBody().data.attributes;
await updateRole(makeRoleFormData(), "role-1");
const updateAttributes = lastRequestBody().data.attributes;
// Then
expect(createAttributes.manage_registry).toBe(true);
expect(updateAttributes.manage_registry).toBe(true);
});
it("omits manage_registry when creating and updating a role outside Prowler Cloud", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
await addRole(makeRoleFormData());
const createAttributes = lastRequestBody().data.attributes;
await updateRole(makeRoleFormData(), "role-1");
const updateAttributes = lastRequestBody().data.attributes;
// Then
expect(createAttributes).not.toHaveProperty("manage_registry");
expect(updateAttributes).not.toHaveProperty("manage_registry");
});
it("includes manage_alerts when creating a role in Prowler Cloud", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
+4
View File
@@ -116,6 +116,8 @@ export const addRole = async (formData: FormData) => {
formData.get("manage_alerts") === "true";
payload.data.attributes.manage_lighthouse_ai_configuration =
formData.get("manage_lighthouse_ai_configuration") === "true";
payload.data.attributes.manage_registry =
formData.get("manage_registry") === "true";
}
// Add provider groups relationships only if there are items
@@ -175,6 +177,8 @@ export const updateRole = async (formData: FormData, roleId: string) => {
formData.get("manage_alerts") === "true";
payload.data.attributes.manage_lighthouse_ai_configuration =
formData.get("manage_lighthouse_ai_configuration") === "true";
payload.data.attributes.manage_registry =
formData.get("manage_registry") === "true";
}
// Add provider groups relationships only if there are items
+21
View File
@@ -36,12 +36,14 @@ const RESTRICTED_PERMISSIONS: RolePermissionAttributes = {
manage_scans: false,
manage_integrations: false,
manage_alerts: false,
manage_registry: false,
unlimited_visibility: false,
};
const ELEVATED_PERMISSIONS: RolePermissionAttributes = {
...RESTRICTED_PERMISSIONS,
manage_users: true,
manage_registry: true,
manage_scans: true,
};
@@ -141,6 +143,25 @@ describe("authConfig JWT callback", () => {
});
});
it("should default manage_registry to false when a sign-in user omits it", async () => {
// Given
const jwtCallback = authConfig.callbacks?.jwt;
if (!jwtCallback) throw new Error("JWT callback is not configured");
// When
const result = await jwtCallback({
token: {},
account: {} as Parameters<typeof jwtCallback>[0]["account"],
user: {
accessToken: "access-token",
refreshToken: "refresh-token",
} as Parameters<typeof jwtCallback>[0]["user"],
});
// Then
expect(result.user?.permissions.manage_registry).toBe(false);
});
it("should report a tenant switch failure while preserving the current session", async () => {
// Given
vi.spyOn(console, "warn").mockImplementation(() => undefined);
+1
View File
@@ -55,6 +55,7 @@ const DEFAULT_PERMISSIONS: RolePermissionAttributes = {
manage_billing: false,
manage_alerts: false,
manage_lighthouse_ai_configuration: false,
manage_registry: false,
unlimited_visibility: false,
};
@@ -68,6 +68,11 @@ vi.mock("@/lib", () => ({
description:
"Allows configuring Lighthouse AI, including its provider credentials, default model and business context",
},
{
field: "manage_registry",
label: "Manage Registry",
description: "Allows managing tenant Registry credentials and artifacts",
},
{
field: "manage_billing",
label: "Manage Billing",
@@ -147,9 +152,25 @@ describe("AddRoleForm", () => {
// Then
expect(screen.queryByText("Manage Alerts")).not.toBeInTheDocument();
expect(screen.queryByText("Manage Lighthouse AI")).not.toBeInTheDocument();
expect(screen.queryByText("Manage Registry")).not.toBeInTheDocument();
expect(screen.queryByText("Manage Billing")).not.toBeInTheDocument();
});
it("submits manage_registry when granted in Prowler Cloud", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
render(<AddRoleForm groups={[]} />);
// When
await user.type(screen.getByPlaceholderText("Enter role name"), "New role");
await user.click(screen.getByRole("checkbox", { name: "Manage Registry" }));
await user.click(screen.getByRole("button", { name: "Add Role" }));
// Then
expect(submittedFormData().get("manage_registry")).toBe("true");
});
it("submits manage_lighthouse_ai_configuration when granted in Prowler Cloud", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
@@ -193,6 +214,7 @@ describe("AddRoleForm", () => {
expect(submittedFormData().has("manage_lighthouse_ai_configuration")).toBe(
false,
);
expect(submittedFormData().has("manage_registry")).toBe(false);
});
it("navigates back to roles when cancel is clicked", async () => {
@@ -28,6 +28,7 @@ export const AddRoleForm = ({ groups }: { groups: RoleGroupOption[] }) => {
manage_billing: false,
manage_alerts: false,
manage_lighthouse_ai_configuration: false,
manage_registry: false,
}),
};
@@ -56,6 +57,7 @@ export const AddRoleForm = ({ groups }: { groups: RoleGroupOption[] }) => {
"manage_lighthouse_ai_configuration",
String(values.manage_lighthouse_ai_configuration),
);
formData.append("manage_registry", String(values.manage_registry));
}
if (values.groups && values.groups.length > 0) {
@@ -68,6 +68,11 @@ vi.mock("@/lib", () => ({
description:
"Allows configuring Lighthouse AI, including its provider credentials, default model and business context",
},
{
field: "manage_registry",
label: "Manage Registry",
description: "Allows managing tenant Registry credentials and artifacts",
},
{
field: "manage_billing",
label: "Manage Billing",
@@ -97,9 +102,11 @@ beforeAll(() => {
const roleData = ({
manageProviders = false,
manageRegistry = false,
unlimitedVisibility = false,
}: {
manageProviders?: boolean;
manageRegistry?: boolean;
unlimitedVisibility?: boolean;
} = {}) => ({
data: {
@@ -109,6 +116,7 @@ const roleData = ({
manage_account: false,
manage_providers: manageProviders,
manage_integrations: false,
manage_registry: manageRegistry,
manage_scans: false,
unlimited_visibility: unlimitedVisibility,
groups: [],
@@ -139,6 +147,19 @@ describe("EditRoleForm", () => {
vi.unstubAllEnvs();
});
it("retains manage_registry when updating a role in Prowler Cloud", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const user = userEvent.setup();
renderEditRoleForm({ manageRegistry: true });
// When
await user.click(screen.getByRole("button", { name: "Update Role" }));
// Then
expect(submittedFormData().get("manage_registry")).toBe("true");
});
it("submits manage_lighthouse_ai_configuration when granted in Prowler Cloud", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
@@ -186,6 +207,7 @@ describe("EditRoleForm", () => {
expect(submittedFormData().has("manage_lighthouse_ai_configuration")).toBe(
false,
);
expect(submittedFormData().has("manage_registry")).toBe(false);
});
it("shows the subtle Unlimited Visibility description inside Visibility", () => {
@@ -35,6 +35,9 @@ export const EditRoleForm = ({
const defaultValues: DefaultValues<RoleFormValues> = {
...roleData.data.attributes,
...(isCloudEnvironment && {
manage_registry: roleData.data.attributes.manage_registry ?? false,
}),
groups:
roleData.data.relationships?.provider_groups?.data.map((g) => g.id) || [],
};
@@ -62,6 +65,7 @@ export const EditRoleForm = ({
updatedFields.manage_alerts = values.manage_alerts;
updatedFields.manage_lighthouse_ai_configuration =
values.manage_lighthouse_ai_configuration;
updatedFields.manage_registry = values.manage_registry;
}
if (
+1
View File
@@ -15,6 +15,7 @@ export function useAuth() {
manage_billing: false,
manage_alerts: false,
manage_lighthouse_ai_configuration: false,
manage_registry: false,
unlimited_visibility: false,
};
+13
View File
@@ -141,6 +141,19 @@ describe("getErrorMessage", () => {
});
describe("permissionFormFields", () => {
it("describes Manage Registry", () => {
// Given
const field = permissionFormFields.find(
({ field }) => field === "manage_registry",
);
// When / Then
expect(field).toMatchObject({
label: "Manage Registry",
description: expect.stringContaining("Registry"),
});
});
it("describes Unlimited Visibility as organization-wide", () => {
// Given
const field = permissionFormFields.find(
+5
View File
@@ -475,6 +475,11 @@ export const permissionFormFields: PermissionInfo[] = [
description:
"Allows configuring Lighthouse AI, including its provider credentials, default model and business context",
},
{
field: "manage_registry",
label: "Manage Registry",
description: "Allows managing tenant Registry credentials and artifacts",
},
{
field: "manage_billing",
+29
View File
@@ -13,6 +13,7 @@ const attributes = {
manage_billing: false,
manage_alerts: true,
manage_lighthouse_ai_configuration: true,
manage_registry: true,
unlimited_visibility: false,
} satisfies RolePermissionAttributes;
@@ -21,6 +22,34 @@ describe("getRolePermissions", () => {
vi.unstubAllEnvs();
});
it("includes Manage Registry in Prowler Cloud when role attributes provide it", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
// When
const permissions = getRolePermissions(attributes);
// Then
expect(permissions).toContainEqual({
key: "manage_registry",
label: "Manage Registry",
enabled: true,
});
});
it("hides Manage Registry outside Prowler Cloud", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
const permissions = getRolePermissions(attributes);
// Then
expect(
permissions.some((permission) => permission.key === "manage_registry"),
).toBe(false);
});
it("includes Manage Alerts in Prowler Cloud when role attributes provide it", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
+5
View File
@@ -72,6 +72,11 @@ export const getRolePermissions = (attributes: RolePermissionAttributes) => {
label: "Manage Lighthouse AI",
enabled: attributes.manage_lighthouse_ai_configuration ?? false,
},
{
key: "manage_registry",
label: "Manage Registry",
enabled: attributes.manage_registry === true,
},
]
: []),
{
+1
View File
@@ -4,6 +4,7 @@ const hiddenOutsideCloudFields = [
"manage_billing",
"manage_alerts",
"manage_lighthouse_ai_configuration",
"manage_registry",
];
export const getVisiblePermissionFormFields = (isCloudEnvironment: boolean) =>
+17
View File
@@ -111,6 +111,23 @@ describe("readBoolEnv", () => {
expect(readBoolEnv("UI_SENTRY_ENABLED")).toBe(false);
});
it("defaults UI_REGISTRY_ENABLED to disabled and enables its true value", () => {
const cases: Array<[string | undefined, boolean]> = [
[undefined, false],
["true", true],
["TRUE", false],
["1", false],
];
for (const [value, enabled] of cases) {
// Given
vi.stubEnv("UI_REGISTRY_ENABLED", value);
// When / Then
expect(readBoolEnv("UI_REGISTRY_ENABLED")).toBe(enabled);
}
});
it('is false for other truthy-looking values ("TRUE", "1", "yes")', () => {
for (const value of ["TRUE", "1", "yes"]) {
// Given
+1
View File
@@ -30,6 +30,7 @@ declare global {
// Prowler Cloud deployment flag — runtime read (server env, client island).
UI_CLOUD_ENABLED?: "true" | "false";
UI_REGISTRY_ENABLED?: "true" | "false";
CLOUD_BILLING_ENABLED?: "legacy" | "metronome" | "false";
+25
View File
@@ -8,6 +8,7 @@ import {
addCredentialsRoleFormSchema,
addProviderFormSchema,
KUBECONFIG_UNSUPPORTED_COMMAND_AUTHENTICATION_ERROR,
roleFormSchema,
samlConfigFormSchema,
} from "./formSchemas";
@@ -20,6 +21,30 @@ const BASE_AWS_ROLE_VALUES = {
[ProviderCredentialFields.CREDENTIALS_TYPE]: "access-secret-key",
} as const;
describe("roleFormSchema", () => {
it("defaults manage_registry to false", () => {
// Given / When
const result = roleFormSchema.parse({ name: "Registry manager" });
// Then
expect(result.manage_registry).toBe(false);
});
it.each(["true", 1, null])(
"rejects malformed manage_registry values of %j",
(manageRegistry) => {
// Given / When
const result = roleFormSchema.safeParse({
name: "Registry manager",
manage_registry: manageRegistry,
});
// Then
expect(result.success).toBe(false);
},
);
});
describe("addCredentialsRoleFormSchema", () => {
it("accepts AWS role credentials when access and secret keys are present", () => {
const schema = addCredentialsRoleFormSchema("aws");
+1
View File
@@ -56,6 +56,7 @@ export const roleFormSchema = z.object({
manage_scans: z.boolean().default(false),
manage_alerts: z.boolean().default(false),
manage_lighthouse_ai_configuration: z.boolean().default(false),
manage_registry: z.boolean().default(false),
unlimited_visibility: z.boolean().default(false),
groups: z.array(z.string()).optional(),
});
+2
View File
@@ -91,6 +91,7 @@ export const PERMISSION_KEY = {
MANAGE_BILLING: "manage_billing",
MANAGE_ALERTS: "manage_alerts",
MANAGE_LIGHTHOUSE_AI_CONFIGURATION: "manage_lighthouse_ai_configuration",
MANAGE_REGISTRY: "manage_registry",
UNLIMITED_VISIBILITY: "unlimited_visibility",
} as const;
@@ -123,6 +124,7 @@ export interface RoleDetail {
manage_billing?: boolean;
manage_alerts?: boolean;
manage_lighthouse_ai_configuration?: boolean;
manage_registry?: boolean;
unlimited_visibility: boolean;
permission_state?: string;
inserted_at?: string;