Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .ci/pipelines/cluster/aks/aks-operator-deployment.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ initiate_rbac_aks_operator_deployment() {

namespace::configure "${namespace}"
# deploy_test_backstage_customization_provider "${namespace}" # Doesn't work on K8s
config::create_conditional_policies_operator /tmp/conditional-policies.yaml
config::prepare_operator_app_config "${DIR}/resources/config_map/app-config-rhdh-rbac.yaml"
apply_yaml_files "${DIR}" "${namespace}" "${rhdh_base_url}"

Expand Down
1 change: 0 additions & 1 deletion .ci/pipelines/cluster/eks/eks-operator-deployment.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ initiate_rbac_eks_operator_deployment() {

namespace::configure "${namespace}"
# deploy_test_backstage_customization_provider "${namespace}" # Doesn't work on K8s
config::create_conditional_policies_operator /tmp/conditional-policies.yaml
config::prepare_operator_app_config "${DIR}/resources/config_map/app-config-rhdh-rbac.yaml"
apply_yaml_files "${DIR}" "${namespace}" "${rhdh_base_url}"

Expand Down
1 change: 0 additions & 1 deletion .ci/pipelines/cluster/gke/gke-operator-deployment.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ initiate_rbac_gke_operator_deployment() {

namespace::configure "${namespace}"
# deploy_test_backstage_customization_provider "${namespace}" # Doesn't work on K8s
config::create_conditional_policies_operator /tmp/conditional-policies.yaml
config::prepare_operator_app_config "${DIR}/resources/config_map/app-config-rhdh-rbac.yaml"
apply_yaml_files "${DIR}" "${namespace}" "${rhdh_base_url}"
apply_gke_frontend_config "${namespace}"
Expand Down
2 changes: 0 additions & 2 deletions .ci/pipelines/jobs/ocp-operator.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ initiate_operator_deployments() {
log::warn "Skipping orchestrator plugins and workflows deployment on Operator $NAME_SPACE deployment"

namespace::configure "${NAME_SPACE_RBAC}"
config::create_conditional_policies_operator /tmp/conditional-policies.yaml
config::prepare_operator_app_config "${DIR}/resources/config_map/app-config-rhdh-rbac.yaml"
local rbac_rhdh_base_url="https://backstage-${RELEASE_NAME_RBAC}-${NAME_SPACE_RBAC}.${K8S_CLUSTER_ROUTER_BASE}"
apply_yaml_files "${DIR}" "${NAME_SPACE_RBAC}" "${rbac_rhdh_base_url}"
Expand Down Expand Up @@ -66,7 +65,6 @@ initiate_operator_deployments_osd_gcp() {
log::warn "Skipping orchestrator plugins and workflows deployment on OSD-GCP environment"

namespace::configure "${NAME_SPACE_RBAC}"
config::create_conditional_policies_operator /tmp/conditional-policies.yaml
config::prepare_operator_app_config "${DIR}/resources/config_map/app-config-rhdh-rbac.yaml"
local rbac_rhdh_base_url="https://backstage-${RELEASE_NAME_RBAC}-${NAME_SPACE_RBAC}.${K8S_CLUSTER_ROUTER_BASE}"
apply_yaml_files "${DIR}" "${NAME_SPACE_RBAC}" "${rbac_rhdh_base_url}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ permission:
- kubernetes
- scorecard
- orchestrator
- adoption-insights
admin:
users:
- name: user:default/rhdh-qe
Expand Down
4 changes: 4 additions & 0 deletions .ci/pipelines/resources/config_map/rbac-policy.csv
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ p, role:default/qe_rbac_admin, kubernetes.clusters.read, read, allow
p, role:default/qe_rbac_admin, catalog.entity.create, create, allow
p, role:default/qe_rbac_admin, catalog.location.create, create, allow
p, role:default/qe_rbac_admin, catalog.location.read, read, allow
p, role:default/qe_rbac_admin, adoption-insights.events.read, read, allow
p, role:default/qe_rbac_admin, scaffolder.task.create, create, allow
p, role:default/qe_rbac_admin, scaffolder.task.read, read, allow
p, role:default/qe_rbac_admin, scaffolder.action.execute, use, allow

p, role:default/bulk_import, bulk.import, use, allow
p, role:default/bulk_import, catalog.location.create, create, allow
Expand Down
12 changes: 3 additions & 9 deletions .ci/pipelines/utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -314,15 +314,9 @@ apply_yaml_files() {
common::create_configmap_from_file "dynamic-plugins-config" "$project" \
"dynamic-plugins-config.yaml" "$dir/resources/config_map/dynamic-plugins-config.yaml"

if [[ "$JOB_NAME" == *operator* ]] && [[ "${project}" == *rbac* ]]; then
common::create_configmap_from_files "rbac-policy" "$project" \
"rbac-policy.csv=$dir/resources/config_map/rbac-policy.csv" \
"conditional-policies.yaml=/tmp/conditional-policies.yaml"
else
common::create_configmap_from_files "rbac-policy" "$project" \
"rbac-policy.csv=$dir/resources/config_map/rbac-policy.csv" \
"conditional-policies.yaml=$dir/resources/config_map/conditional-policies.yaml"
fi
common::create_configmap_from_files "rbac-policy" "$project" \
"rbac-policy.csv=$dir/resources/config_map/rbac-policy.csv" \
"conditional-policies.yaml=$dir/resources/config_map/conditional-policies.yaml"

# configuration for testing global floating action button.
common::create_configmap_from_file "dynamic-global-floating-action-button-config" "$project" \
Expand Down
8 changes: 7 additions & 1 deletion e2e-tests/playwright/e2e/audit-log/auditor-rbac.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,12 +240,18 @@

for (const s of conditionRead) {
test(`condition-read → ${s.name}`, async () => {
await s.call();
const response = await s.call();
// Condition by-id may return 404 if no conditions exist (e.g., empty
// conditional-policies.yaml). The audit log still records the event
// but with status "failed" instead of "succeeded".
const status = response.ok() ? "succeeded" : "failed";

Check warning on line 247 in e2e-tests/playwright/e2e/audit-log/auditor-rbac.spec.ts

View workflow job for this annotation

GitHub Actions / TSC, ESLint and Prettier

Avoid having conditionals in tests
await validateRbacLogEvent(
"condition-read",
USER_ENTITY_REF,
{ method: "GET", url: s.url },
s.meta,
undefined,
status,
);
});
}
Expand Down
2 changes: 1 addition & 1 deletion e2e-tests/playwright/e2e/audit-log/log-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ export class LogUtils {
retryDelay: number = 2000,
): Promise<string> {
const deploySelector = getBackstageDeploySelector();
const tailNumber = 100;
const tailNumber = 500;

// Resolve the deployment by its metadata labels, then fetch logs from it.
// This works for both Helm and Operator since both set app.kubernetes.io/name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,9 @@ test.describe("Change app-config at e2e test runtime", () => {

// Start with a common name, but let KubeClient find the actual ConfigMap
const configMapName = "app-config-rhdh";
// eslint-disable-next-line playwright/no-conditional-in-test

const namespace = process.env.NAME_SPACE_RUNTIME || "showcase-runtime";
const deploymentName =
// eslint-disable-next-line playwright/no-conditional-in-test
(process.env.RELEASE_NAME || "rhdh") + "-developer-hub";

const kubeUtils = new KubeClient();
Expand Down
9 changes: 8 additions & 1 deletion e2e-tests/playwright/e2e/plugins/bulk-import.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,14 @@ spec:
"Preview file",
);

await expect(await uiHelper.clickButton("Save")).toBeHidden();
const saveButton = page.getByRole("button", { name: "Save" });
await saveButton.scrollIntoViewIfNeeded();
// Use dispatchEvent because the Save button is inside a drawer with its own
// scroll context and remains outside the main viewport even after scrolling.
// click({ force: true }) still fails with "outside of the viewport" in this case.
// The subsequent toBeHidden() assertion validates the click was effective.
await saveButton.dispatchEvent("click");
await expect(saveButton).toBeHidden();
await expect(await uiHelper.clickButton("Import")).toBeDisabled();
});

Expand Down
31 changes: 24 additions & 7 deletions e2e-tests/playwright/e2e/plugins/rbac/rbac.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ test.describe("Test RBAC", () => {
await uiHelper.verifyText("csv permission policy file");

await uiHelper.verifyHeading("1 group");
await uiHelper.verifyHeading("3 Permissions");
await uiHelper.verifyHeading("Permission Policies");
const permissionPoliciesColumnsText =
Roles.getPermissionPoliciesListColumnsText();
await uiHelper.verifyColumnHeading(permissionPoliciesColumnsText);
Expand Down Expand Up @@ -390,6 +390,8 @@ test.describe("Test RBAC", () => {
await saveButton.click();
await uiHelper.verifyText(
"Role role:default/test-role updated successfully",
true,
15000,
);

await page.getByPlaceholder("Filter").waitFor({
Expand Down Expand Up @@ -447,24 +449,33 @@ test.describe("Test RBAC", () => {
await expect(nextButton2).toBeEnabled();
await nextButton2.click();
// Wait for Save button which only appears on the review step
await expect(page.getByRole("button", { name: "Save" })).toBeVisible({
const saveButton1 = page.getByRole("button", { name: "Save" });
await expect(saveButton1).toBeVisible({
timeout: 15000,
});
await uiHelper.clickButton("Save");
// Dismiss quickstart overlay if visible — it can intercept button clicks
await uiHelper.hideQuickstartIfVisible();
await expect(saveButton1).toBeEnabled();
await saveButton1.click();
await uiHelper.verifyText(
"Role role:default/test-role1 updated successfully",
true,
15000,
);
await uiHelper.verifyHeading(rbacPo.regexpShortUsersAndGroups(1, 1));

await page
.getByTestId(ROLE_OVERVIEW_COMPONENTS_TEST_ID.updatePolicies)
.click();
// Wait for the permissions section update button to be available
const updatePoliciesButton = page.getByTestId(
ROLE_OVERVIEW_COMPONENTS_TEST_ID.updatePolicies,
);
await expect(updatePoliciesButton).toBeVisible({ timeout: 15000 });
await updatePoliciesButton.click();
await uiHelper.verifyHeading("Edit Role");
await rbacPo.selectPluginsCombobox.click();
await rbacPo.selectOption("scaffolder");

// Close the plugins dropdown to access the permissions table
await page.getByRole("button", { name: "Close" }).click();
await page.keyboard.press("Escape");

// Expand the Scaffolder row to access its permissions
await page
Expand All @@ -480,9 +491,13 @@ test.describe("Test RBAC", () => {
await expect(page.getByRole("button", { name: "Save" })).toBeVisible({
timeout: 15000,
});
// Dismiss quickstart overlay if visible — it can intercept button clicks
await uiHelper.hideQuickstartIfVisible();
await uiHelper.clickButton("Save");
await uiHelper.verifyText(
"Role role:default/test-role1 updated successfully",
true,
15000,
);
await uiHelper.verifyHeading("2 permissions");

Expand Down Expand Up @@ -873,6 +888,8 @@ test.describe("Test RBAC", () => {
await saveButton.click();
await uiHelper.verifyText(
"Role role:default/test-role updated successfully",
true,
15000,
);

await page.getByPlaceholder("Filter").waitFor({
Expand Down
10 changes: 9 additions & 1 deletion e2e-tests/playwright/support/api/rbac-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default class RhdhRbacApi {
Authorization: string;
};
private myContext: APIRequestContext;
private readonly roleRegex = /^[a-zA-Z]+\/[a-zA-Z_]+$/;
private readonly roleRegex = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/;

private constructor(private readonly token: string) {
this.authHeader = {
Expand Down Expand Up @@ -113,6 +113,14 @@ export default class RhdhRbacApi {
return await this.myContext.get(`roles/conditions/${id}`);
}

public async deleteConditionById(id: number): Promise<APIResponse> {
return await this.myContext.delete(`roles/conditions/${id}`);
}

public async dispose(): Promise<void> {
await this.myContext.dispose();
}

private checkRoleFormat(role: string) {
if (!this.roleRegex.test(role))
throw Error(
Expand Down
101 changes: 82 additions & 19 deletions e2e-tests/playwright/support/page-objects/rbac-po.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
ROLES_PAGE_COMPONENTS,
} from "./page-obj";
import { type RoleBasedPolicy } from "@backstage-community/plugin-rbac-common";
import { RhdhAuthApiHack } from "../api/rhdh-auth-api-hack";
import RhdhRbacApi from "../api/rbac-api";

type PermissionPolicyType = "anyOf" | "not";

Expand Down Expand Up @@ -281,23 +283,40 @@ export class RbacPo extends PageObject {
await this.verifyPermissionPoliciesHeader(policies.length);
await this.create();

// Check for error alert first
// Wait for either success message or error alert.
// Wrap both waitFor calls so the losing promise cannot reject unhandled.
const successLocator = this.page
.getByText(`Role role:default/${name} created successfully`, {
exact: true,
})
.first();
const errorAlert = this.page
.getByRole("alert")
.filter({ hasText: /error/i });
const errorCount = await errorAlert.count();

if (errorCount > 0) {
const outcome = await Promise.race([
successLocator
.waitFor({ state: "visible", timeout: 30000 })
.then(() => "success" as const)
.catch(() => "success_timeout" as const),
errorAlert
.waitFor({ state: "visible", timeout: 30000 })
.then(() => "error" as const)
.catch(() => "error_timeout" as const),
]);

if (outcome === "error") {
const errorMessage = await errorAlert.textContent();
throw new Error(
`Failed to create role: ${errorMessage}. This may indicate insufficient permissions.`,
`Failed to create role: ${errorMessage}. This may indicate insufficient permissions or a leftover role from a previous test run.`,
);
}

// Wait for success message before proceeding to roles list
await this.uiHelper.verifyText(
`Role role:default/${name} created successfully`,
);
if (outcome !== "success") {
throw new Error(
`Role creation timed out: neither success message nor error alert appeared within 30s.`,
);
}

// Now we should be on the roles list page
await this.page.getByPlaceholder("Filter").waitFor({ state: "visible" });
Expand Down Expand Up @@ -364,6 +383,8 @@ export class RbacPo extends PageObject {
await this.uiHelper.clickButton("Create");
await this.uiHelper.verifyText(
`Role role:default/${name} created successfully`,
true,
15000,
);
} else if (permissionPolicyType === "not") {
// Conditional Scenario 2: Permission policies using Not
Expand Down Expand Up @@ -391,18 +412,60 @@ export class RbacPo extends PageObject {
}

async tryDeleteRole(name: string): Promise<void> {
await this.page.goto("/rbac");
await this.uiHelper.searchInputAriaLabel(name);
const deleteButton = this.page.locator(
ROLES_PAGE_COMPONENTS.deleteRole(name),
);
if ((await deleteButton.count()) > 0) {
await deleteButton.click();
await this.uiHelper.verifyHeading("Delete this role?");
await this.page.fill(DELETE_ROLE_COMPONENTS.roleName, name);
await this.uiHelper.clickButton("Delete");
await this.uiHelper.verifyText(`Role ${name} deleted successfully`);
// Use the RBAC REST API for reliable cleanup — the UI-based approach
// can silently fail if the page hasn't fully loaded or the filter
// doesn't match, leaving a leftover role that blocks recreation.
try {
const token = await RhdhAuthApiHack.getToken(this.page);
const rbacApi = await RhdhRbacApi.build(token);
// name is fully qualified like "role:default/test-role1"
// The API expects just "default/test-role1"
const apiRoleName = name.replace(/^role:/, "");

// Delete policies associated with the role first
const policiesResponse = await rbacApi.getPoliciesByRole(apiRoleName);
if (policiesResponse.ok()) {
const policies = await policiesResponse.json();
if (policies.length > 0) {
await rbacApi.deletePolicy(apiRoleName, policies);
console.log(
`Deleted ${policies.length} leftover policies for ${name} via API`,
);
}
}

// Delete conditions associated with the role
const conditionsResponse = await rbacApi.getConditionByQuery({
roleEntityRef: name,
});
if (conditionsResponse.ok()) {
const conditions = await conditionsResponse.json();
for (const condition of conditions) {
const delResponse = await rbacApi.deleteConditionById(condition.id);
if (delResponse.ok()) {
console.log(
`Deleted leftover condition ${condition.id} for ${name} via API`,
);
}
}
}

// Delete the role itself
const response = await rbacApi.deleteRole(apiRoleName);
if (response.ok()) {
console.log(`Successfully deleted leftover role ${name} via API`);
} else if (response.status() === 404) {
console.log(`Role ${name} does not exist, no cleanup needed`);
} else {
console.warn(
`Unexpected status ${response.status()} when deleting role ${name} via API`,
);
}
} catch (error) {
console.warn(`API cleanup of role ${name} failed: ${error}`);
}
// Navigate to RBAC page for the subsequent test steps
await this.page.goto("/rbac");
}

async deleteRole(name: string, header: string = "All roles (0)") {
Expand Down
Loading
Loading