From d513a1a06095718fbefd079bb383e29433ca16a8 Mon Sep 17 00:00:00 2001 From: ArtjomsPorss Date: Fri, 22 May 2026 19:25:17 +0100 Subject: [PATCH] ui - display icon beside roles, services, policies that have ownership set; show zms-cli command when attempting to make change to these resources Signed-off-by: ArtjomsPorss --- .../components/denali/icons/Icon.test.js | 13 + .../ManagedResourceIcon.test.js | 39 ++ .../ResourceOwnershipCliSuggestion.test.js | 101 +++++ .../ResourceOwnershipModalFeedback.test.js | 51 +++ .../AddAssertionForRole.test.js.snap | 52 +-- .../AddRuleFormForRole.test.js.snap | 52 +-- .../role/__snapshots__/RoleRow.test.js.snap | 24 +- .../__snapshots__/RoleSectionRow.test.js.snap | 18 +- .../role/__snapshots__/RoleTable.test.js.snap | 28 +- .../components/service/PublicKeyTable.test.js | 78 +++- .../components/service/ServiceList.test.js | 43 ++ .../__snapshots__/PublicKeyTable.test.js.snap | 30 -- .../__snapshots__/ServiceList.test.js.snap | 10 +- .../__snapshots__/ServiceRow.test.js.snap | 2 +- .../utils/resourceOwnership.test.js | 103 +++++ .../utils/resourceOwnershipUi.test.js | 79 ++++ .../components/utils/zmsCliCommands.test.js | 111 +++++ .../__snapshots__/service.test.js.snap | 4 +- ui/src/__tests__/server/handlers/api.test.js | 37 ++ .../spec/libs/resourceOwnershipHelpers.js | 138 +++++++ .../spec/tests/resource-ownership.spec.js | 357 ++++++++++++++++ ui/src/__tests__/spec/wdio.conf.js | 8 +- ui/src/components/denali/icons/Icons.js | 4 + ui/src/components/group/GroupRoleTable.js | 7 +- ui/src/components/header/NameHeader.js | 25 ++ ui/src/components/header/ServiceNameHeader.js | 19 +- ui/src/components/member/AddMember.js | 94 ++++- ui/src/components/member/MemberList.js | 3 + ui/src/components/member/MemberRow.js | 63 ++- ui/src/components/member/MemberTable.js | 1 + .../microsegmentation/AddStaticInstances.js | 26 +- ui/src/components/modal/AddModal.js | 11 +- ui/src/components/modal/DeleteModal.js | 11 +- ui/src/components/modal/UpdateModal.js | 11 +- ui/src/components/policy/AddAssertion.js | 54 ++- ui/src/components/policy/AddPolicy.js | 53 ++- ui/src/components/policy/PolicyList.js | 63 ++- ui/src/components/policy/PolicyRow.js | 107 ++++- ui/src/components/policy/PolicyRuleTable.js | 61 ++- .../resource-ownership/ManagedResourceIcon.js | 78 ++++ .../ResourceOwnershipCliSuggestion.js | 212 ++++++++++ .../ResourceOwnershipModalFeedback.js | 38 ++ .../role-policy/AddAssertionForRole.js | 73 +++- .../role-policy/AddRuleFormForRole.js | 16 +- .../components/role-policy/RolePolicyList.js | 36 +- .../components/role-policy/RolePolicyRow.js | 53 ++- .../role-policy/RolePolicyRuleTable.js | 48 ++- ui/src/components/role/RoleGroup.js | 1 + ui/src/components/role/RoleRow.js | 91 +++- ui/src/components/role/RoleSectionRow.js | 114 ++++- ui/src/components/role/RoleTable.js | 12 +- ui/src/components/service/AddKey.js | 44 +- ui/src/components/service/InstanceList.js | 8 +- ui/src/components/service/PublicKeyTable.js | 41 +- ui/src/components/service/ServiceList.js | 57 ++- ui/src/components/service/ServiceRow.js | 49 ++- ui/src/components/settings/SettingTable.js | 38 +- ui/src/components/utils/resourceOwnership.js | 133 ++++++ .../components/utils/resourceOwnershipUi.js | 100 +++++ ui/src/components/utils/roleIconStrip.js | 59 +++ ui/src/components/utils/zmsCliCommands.js | 388 ++++++++++++++++++ ui/src/config/default-config.js | 28 ++ .../domain/[domain]/role/[role]/history.js | 3 + .../domain/[domain]/role/[role]/members.js | 3 + .../domain/[domain]/role/[role]/policy.js | 3 + .../domain/[domain]/role/[role]/review.js | 3 + .../domain/[domain]/role/[role]/settings.js | 3 + .../pages/domain/[domain]/role/[role]/tags.js | 3 + ui/src/redux/reducers/domains.js | 2 + ui/src/redux/selectors/domains.js | 18 + ui/src/server/handlers/api.js | 18 +- ui/src/tests_utils/ComponentsTestUtils.js | 3 + ui/src/utils/zmsCliUrl.js | 26 ++ 73 files changed, 3544 insertions(+), 249 deletions(-) create mode 100644 ui/src/__tests__/components/resource-ownership/ManagedResourceIcon.test.js create mode 100644 ui/src/__tests__/components/resource-ownership/ResourceOwnershipCliSuggestion.test.js create mode 100644 ui/src/__tests__/components/resource-ownership/ResourceOwnershipModalFeedback.test.js create mode 100644 ui/src/__tests__/components/utils/resourceOwnership.test.js create mode 100644 ui/src/__tests__/components/utils/resourceOwnershipUi.test.js create mode 100644 ui/src/__tests__/components/utils/zmsCliCommands.test.js create mode 100644 ui/src/__tests__/spec/libs/resourceOwnershipHelpers.js create mode 100644 ui/src/__tests__/spec/tests/resource-ownership.spec.js create mode 100644 ui/src/components/resource-ownership/ManagedResourceIcon.js create mode 100644 ui/src/components/resource-ownership/ResourceOwnershipCliSuggestion.js create mode 100644 ui/src/components/resource-ownership/ResourceOwnershipModalFeedback.js create mode 100644 ui/src/components/utils/resourceOwnership.js create mode 100644 ui/src/components/utils/resourceOwnershipUi.js create mode 100644 ui/src/components/utils/roleIconStrip.js create mode 100644 ui/src/components/utils/zmsCliCommands.js create mode 100644 ui/src/utils/zmsCliUrl.js diff --git a/ui/src/__tests__/components/denali/icons/Icon.test.js b/ui/src/__tests__/components/denali/icons/Icon.test.js index a9b0e42ed39..107e2c4336b 100644 --- a/ui/src/__tests__/components/denali/icons/Icon.test.js +++ b/ui/src/__tests__/components/denali/icons/Icon.test.js @@ -37,4 +37,17 @@ describe('Icon', () => { const icon = getByTestId('icon'); expect(icon).toMatchSnapshot(); }); + + it('should render default managed-resource icon key with 24x24 viewBox', () => { + const { getByTestId } = render( + + ); + const icon = getByTestId('icon'); + expect(icon.getAttribute('viewBox')).toBe('0 0 24 24'); + }); }); diff --git a/ui/src/__tests__/components/resource-ownership/ManagedResourceIcon.test.js b/ui/src/__tests__/components/resource-ownership/ManagedResourceIcon.test.js new file mode 100644 index 00000000000..a80e9b4672a --- /dev/null +++ b/ui/src/__tests__/components/resource-ownership/ManagedResourceIcon.test.js @@ -0,0 +1,39 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { ManagedResourceIcon } from '../../../components/resource-ownership/ManagedResourceIcon'; + +describe('ManagedResourceIcon', () => { + it('renders nothing when show is false', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders icon with stable data-wdio when shown', () => { + const { container } = render( + + ); + const svg = container.querySelector( + '[data-wdio="resource-ownership-managed"]' + ); + expect(svg).toBeTruthy(); + }); +}); diff --git a/ui/src/__tests__/components/resource-ownership/ResourceOwnershipCliSuggestion.test.js b/ui/src/__tests__/components/resource-ownership/ResourceOwnershipCliSuggestion.test.js new file mode 100644 index 00000000000..e2228942b94 --- /dev/null +++ b/ui/src/__tests__/components/resource-ownership/ResourceOwnershipCliSuggestion.test.js @@ -0,0 +1,101 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import { ResourceOwnershipCliSuggestion } from '../../../components/resource-ownership/ResourceOwnershipCliSuggestion'; + +describe('ResourceOwnershipCliSuggestion', () => { + const writeText = jest.fn(); + + beforeEach(() => { + jest.useFakeTimers(); + writeText.mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('copies command and resets copied state after delay', async () => { + const { getByTestId } = render( + + ); + await act(async () => { + fireEvent.click(getByTestId('resource-ownership-cli-copy')); + }); + expect(writeText).toHaveBeenCalledWith('zms-cli -d my.domain'); + expect(getByTestId('resource-ownership-cli-copy').title).toBe('Copied'); + await act(async () => { + jest.advanceTimersByTime(2000); + }); + expect(getByTestId('resource-ownership-cli-copy').title).toBe( + 'Copy command' + ); + }); + + it('clears copied timer on unmount', async () => { + const setState = jest.spyOn( + ResourceOwnershipCliSuggestion.prototype, + 'setState' + ); + const { getByTestId, unmount } = render( + + ); + await act(async () => { + fireEvent.click(getByTestId('resource-ownership-cli-copy')); + }); + unmount(); + setState.mockClear(); + jest.advanceTimersByTime(2000); + expect(setState).not.toHaveBeenCalled(); + setState.mockRestore(); + }); + + it('renders configurable owner label in warning copy', () => { + const { getByTestId } = render( + + ); + expect( + getByTestId('resource-ownership-cli-suggestion').textContent + ).toContain('OpenTofu-managed'); + }); + + it('does not show copied state when clipboard write fails', async () => { + writeText.mockRejectedValue(new Error('denied')); + const { getByTestId } = render( + + ); + await act(async () => { + fireEvent.click(getByTestId('resource-ownership-cli-copy')); + }); + expect(getByTestId('resource-ownership-cli-copy').title).toBe( + 'Copy command' + ); + }); +}); diff --git a/ui/src/__tests__/components/resource-ownership/ResourceOwnershipModalFeedback.test.js b/ui/src/__tests__/components/resource-ownership/ResourceOwnershipModalFeedback.test.js new file mode 100644 index 00000000000..dc01c9d87a0 --- /dev/null +++ b/ui/src/__tests__/components/resource-ownership/ResourceOwnershipModalFeedback.test.js @@ -0,0 +1,51 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; +import ResourceOwnershipModalFeedback from '../../../components/resource-ownership/ResourceOwnershipModalFeedback'; + +const store = createStore(() => ({ + domains: { headerDetails: {} }, +})); + +function renderWithStore(ui) { + return render({ui}); +} + +describe('ResourceOwnershipModalFeedback', () => { + it('shows CLI suggestion instead of raw error when command is set', () => { + const { getByTestId, queryByText } = renderWithStore( + + ); + expect(getByTestId('resource-ownership-cli-suggestion')).toBeTruthy(); + expect(queryByText('Forbidden')).toBeNull(); + }); + + it('shows error when no CLI command', () => { + const { getByText } = renderWithStore( + + ); + expect(getByText('Forbidden')).toBeTruthy(); + }); +}); diff --git a/ui/src/__tests__/components/role-policy/__snapshots__/AddAssertionForRole.test.js.snap b/ui/src/__tests__/components/role-policy/__snapshots__/AddAssertionForRole.test.js.snap index f2a13f40242..a8aac978f51 100644 --- a/ui/src/__tests__/components/role-policy/__snapshots__/AddAssertionForRole.test.js.snap +++ b/ui/src/__tests__/components/role-policy/__snapshots__/AddAssertionForRole.test.js.snap @@ -499,13 +499,13 @@ exports[`AddAssertionForRole should render 1`] = ` > @@ -515,13 +515,13 @@ exports[`AddAssertionForRole should render 1`] = ` data-testid="radiobutton-wrapper" > @@ -548,8 +548,8 @@ exports[`AddAssertionForRole should render 1`] = ` @@ -579,8 +579,8 @@ exports[`AddAssertionForRole should render 1`] = ` @@ -603,12 +603,12 @@ exports[`AddAssertionForRole should render 1`] = ` > @@ -1132,13 +1132,13 @@ exports[`AddAssertionForRole should render failed to submit action is required 1 > @@ -1148,13 +1148,13 @@ exports[`AddAssertionForRole should render failed to submit action is required 1 data-testid="radiobutton-wrapper" > @@ -1181,8 +1181,8 @@ exports[`AddAssertionForRole should render failed to submit action is required 1 @@ -1212,8 +1212,8 @@ exports[`AddAssertionForRole should render failed to submit action is required 1 @@ -1236,12 +1236,12 @@ exports[`AddAssertionForRole should render failed to submit action is required 1 > diff --git a/ui/src/__tests__/components/role-policy/__snapshots__/AddRuleFormForRole.test.js.snap b/ui/src/__tests__/components/role-policy/__snapshots__/AddRuleFormForRole.test.js.snap index 024ce14334b..d2fc027a420 100644 --- a/ui/src/__tests__/components/role-policy/__snapshots__/AddRuleFormForRole.test.js.snap +++ b/ui/src/__tests__/components/role-policy/__snapshots__/AddRuleFormForRole.test.js.snap @@ -392,13 +392,13 @@ exports[`AddRuleForm should render 1`] = ` > @@ -408,13 +408,13 @@ exports[`AddRuleForm should render 1`] = ` data-testid="radiobutton-wrapper" > @@ -441,8 +441,8 @@ exports[`AddRuleForm should render 1`] = ` @@ -472,8 +472,8 @@ exports[`AddRuleForm should render 1`] = ` @@ -496,12 +496,12 @@ exports[`AddRuleForm should render 1`] = ` > @@ -903,13 +903,13 @@ exports[`AddRuleForm should render failed to load roles 1`] = ` > @@ -919,13 +919,13 @@ exports[`AddRuleForm should render failed to load roles 1`] = ` data-testid="radiobutton-wrapper" > @@ -952,8 +952,8 @@ exports[`AddRuleForm should render failed to load roles 1`] = ` @@ -983,8 +983,8 @@ exports[`AddRuleForm should render failed to load roles 1`] = ` @@ -1007,12 +1007,12 @@ exports[`AddRuleForm should render failed to load roles 1`] = ` > diff --git a/ui/src/__tests__/components/role/__snapshots__/RoleRow.test.js.snap b/ui/src/__tests__/components/role/__snapshots__/RoleRow.test.js.snap index aa4637b4c34..64b45ec25f8 100644 --- a/ui/src/__tests__/components/role/__snapshots__/RoleRow.test.js.snap +++ b/ui/src/__tests__/components/role/__snapshots__/RoleRow.test.js.snap @@ -15,29 +15,39 @@ exports[`RoleRow should render 1`] = ` .emotion-2 { background-color: #3570f40D; text-align: left; - padding: 5px 0 5px 15px; + padding: 5px 0 5px 0px; vertical-align: middle; word-break: break-all; width: 28%; } .emotion-4 { - padding-left: 20px; + display: inline-flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + gap: 2px; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + vertical-align: middle; + box-sizing: border-box; } .emotion-6 { background-color: #3570f40D; text-align: left; - padding: 5px 0 5px 15px; + padding: 5px 0 5px 0px; vertical-align: middle; word-break: break-all; - width: 16%; + width: 15%; } .emotion-10 { background-color: #3570f40D; text-align: center; - padding: 5px 0 5px 15px; + padding: 5px 0 5px 0px; vertical-align: middle; word-break: break-all; width: 7%; @@ -60,7 +70,9 @@ exports[`RoleRow should render 1`] = ` > + style="min-width: calc(3 * 1.35em + 4px);" + /> + ztssia_cert_rotate diff --git a/ui/src/__tests__/components/role/__snapshots__/RoleSectionRow.test.js.snap b/ui/src/__tests__/components/role/__snapshots__/RoleSectionRow.test.js.snap index f0bdb31dc77..bca86129e05 100644 --- a/ui/src/__tests__/components/role/__snapshots__/RoleSectionRow.test.js.snap +++ b/ui/src/__tests__/components/role/__snapshots__/RoleSectionRow.test.js.snap @@ -2,7 +2,9 @@ exports[`RoleRow should render 1`] = ` .emotion-0 { + box-sizing: border-box; display: flex; + padding-left: 15px; } .emotion-2 { @@ -15,7 +17,17 @@ exports[`RoleRow should render 1`] = ` } .emotion-4 { - padding-left: 20px; + display: inline-flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + gap: 2px; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + vertical-align: middle; + box-sizing: border-box; } .emotion-6 { @@ -52,7 +64,9 @@ exports[`RoleRow should render 1`] = ` > + style="min-width: calc(3 * 1.35em + 4px);" + /> + ztssia_cert_rotate diff --git a/ui/src/__tests__/components/role/__snapshots__/RoleTable.test.js.snap b/ui/src/__tests__/components/role/__snapshots__/RoleTable.test.js.snap index 509a94da9dd..ddbe6ab7ff6 100644 --- a/ui/src/__tests__/components/role/__snapshots__/RoleTable.test.js.snap +++ b/ui/src/__tests__/components/role/__snapshots__/RoleTable.test.js.snap @@ -47,27 +47,37 @@ exports[`RoleTable should render 1`] = ` .emotion-26 { text-align: left; - padding: 5px 0 5px 15px; + padding: 5px 0 5px 0px; vertical-align: middle; word-break: break-all; width: 28%; } .emotion-28 { - padding-left: 20px; + display: inline-flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + gap: 2px; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + vertical-align: middle; + box-sizing: border-box; } .emotion-30 { text-align: left; - padding: 5px 0 5px 15px; + padding: 5px 0 5px 0px; vertical-align: middle; word-break: break-all; - width: 16%; + width: 15%; } .emotion-34 { text-align: center; - padding: 5px 0 5px 15px; + padding: 5px 0 5px 0px; vertical-align: middle; word-break: break-all; width: 7%; @@ -147,7 +157,9 @@ exports[`RoleTable should render 1`] = ` > + style="min-width: 0;" + /> + a @@ -348,7 +360,9 @@ exports[`RoleTable should render 1`] = ` > + style="min-width: 0;" + /> + b diff --git a/ui/src/__tests__/components/service/PublicKeyTable.test.js b/ui/src/__tests__/components/service/PublicKeyTable.test.js index 3e6926818af..41d52f5a529 100644 --- a/ui/src/__tests__/components/service/PublicKeyTable.test.js +++ b/ui/src/__tests__/components/service/PublicKeyTable.test.js @@ -221,8 +221,11 @@ describe('PublicKeyTable', () => { await waitFor(() => getByTestId('delete-modal-delete')) ); expect( - await waitFor(() => getByTestId('error-message')) - ).toMatchSnapshot(); + await waitFor(() => + getByText('Session expired. Please refresh the page.') + ) + ).toBeInTheDocument(); + expect(getByTestId('public-key-table')).toBeInTheDocument(); }); it('should render error if there is an error in submitDeleteKey(other)', async () => { @@ -275,8 +278,70 @@ describe('PublicKeyTable', () => { await waitFor(() => getByTestId('delete-modal-delete')) ); expect( - await waitFor(() => getByTestId('error-message')) - ).toMatchSnapshot(); + await waitFor(() => getByText('Status: 1. Message: test-error')) + ).toBeInTheDocument(); + expect(getByTestId('public-key-table')).toBeInTheDocument(); + }); + + it('should show resource-ownership zms-cli hint when delete fails for externally managed public key', async () => { + const services = buildServicesForState( + { + [serviceFullName]: { + name: 'home.user1.openhouse', + description: 'This is a default service for Openhouse.', + modified: '2017-12-19T20:24:41.195Z', + publicKeys: { + 'test-id': { + id: 'test-id', + key: 'test-value', + }, + }, + resourceOwnership: { publicKeysOwner: 'TF2' }, + }, + }, + domain + ); + const color = ''; + const api = { + deleteKey: function (domainName, serviceName, keyId, _csrf) { + return new Promise((resolve, reject) => { + reject({ + statusCode: 409, + output: { + message: 'Service has a resource owner: TF2', + }, + }); + }); + }, + }; + MockApi.setMockApi(api); + + const { getByTestId, getByTitle } = renderWithRedux( + + + + + + +
, + getStateWithServices(services) + ); + fireEvent.click(getByTitle('trash')); + fireEvent.click( + await waitFor(() => getByTestId('delete-modal-delete')) + ); + + const suggestion = await waitFor(() => + getByTestId('resource-ownership-cli-suggestion') + ); + expect(suggestion.textContent).toContain('delete-public-key'); + expect(suggestion.textContent).toContain(service); + expect(suggestion.textContent).toContain('test-id'); + expect(getByTestId('public-key-table')).toBeInTheDocument(); }); it('should reloadService after successful delete', async () => { @@ -391,7 +456,8 @@ describe('PublicKeyTable', () => { await waitFor(() => getByTestId('delete-modal-delete')) ); expect( - await waitFor(() => getByTestId('error-message')) - ).toMatchSnapshot(); + await waitFor(() => getByText('Status: 404. Message: not found')) + ).toBeInTheDocument(); + expect(getByTestId('public-key-table')).toBeInTheDocument(); }); }); diff --git a/ui/src/__tests__/components/service/ServiceList.test.js b/ui/src/__tests__/components/service/ServiceList.test.js index e85a3463989..e3b0e3a3ce6 100644 --- a/ui/src/__tests__/components/service/ServiceList.test.js +++ b/ui/src/__tests__/components/service/ServiceList.test.js @@ -45,6 +45,17 @@ const servicesForState = buildServicesForState( 'home.test' ); +const externallyManagedServicesForState = buildServicesForState( + { + [fullServiceName]: { + name: fullServiceName, + modified: '2020-02-08T00:02:49.477Z', + resourceOwnership: { objectOwner: 'terraform' }, + }, + }, + 'home.test' +); + describe('ServiceList', () => { afterEach(() => { MockApi.cleanMockApi(); @@ -138,6 +149,38 @@ describe('ServiceList', () => { ).toMatchSnapshot(); }); + it('should show resource-ownership zms-cli hint when delete fails for externally managed service', async () => { + const api = { + deleteService: function (domain, deleteServiceName, _csrf) { + return new Promise((resolve, reject) => { + reject({ + statusCode: 403, + body: { + message: 'test-error', + }, + }); + }); + }, + }; + MockApi.setMockApi(api); + + const { getByTestId, getByTitle } = renderWithRedux( + , + getStateWithServices(externallyManagedServicesForState) + ); + fireEvent.click(getByTitle('trash')); + + await waitFor(() => + fireEvent.click(getByTestId('delete-modal-delete')) + ); + + const suggestion = await waitFor(() => + getByTestId('resource-ownership-cli-suggestion') + ); + expect(suggestion.textContent).toContain('delete-service'); + expect(suggestion.textContent).toContain('openhouse'); + }); + it('should render serviceList again after confirm delete', async () => { const api = { deleteService: function (domain, deleteServiceName, _csrf) { diff --git a/ui/src/__tests__/components/service/__snapshots__/PublicKeyTable.test.js.snap b/ui/src/__tests__/components/service/__snapshots__/PublicKeyTable.test.js.snap index aa6b2adb1d9..aa01520aad5 100644 --- a/ui/src/__tests__/components/service/__snapshots__/PublicKeyTable.test.js.snap +++ b/ui/src/__tests__/components/service/__snapshots__/PublicKeyTable.test.js.snap @@ -652,33 +652,3 @@ exports[`PublicKeyTable should render deleteKey after click trash icon 1`] = ` This deletion is permanent `; - -exports[`PublicKeyTable should render error if getService throws error 1`] = ` -
- Failed to fetch PublicKeys. -
-`; - -exports[`PublicKeyTable should render error if there is an error in submitDeleteKey(other) 1`] = ` -
- Failed to fetch PublicKeys. -
-`; - -exports[`PublicKeyTable should render error if there is an error in submitDeleteKey(refresh) 1`] = ` -
- Failed to fetch PublicKeys. -
-`; diff --git a/ui/src/__tests__/components/service/__snapshots__/ServiceList.test.js.snap b/ui/src/__tests__/components/service/__snapshots__/ServiceList.test.js.snap index dfb15a89f76..59f7bfba283 100644 --- a/ui/src/__tests__/components/service/__snapshots__/ServiceList.test.js.snap +++ b/ui/src/__tests__/components/service/__snapshots__/ServiceList.test.js.snap @@ -191,7 +191,7 @@ exports[`ServiceList should render add service modal after click 1`] = ` { + it('treats any non-blank SimpleName as an owner', () => { + expect(hasResourceOwner('terraform')).toBe(true); + expect(hasResourceOwner('TF')).toBe(true); + expect(hasResourceOwner('user.terraform')).toBe(true); + expect(hasResourceOwner('other')).toBe(true); + expect(hasResourceOwner(' principal ')).toBe(true); + expect(hasResourceOwner('')).toBe(false); + expect(hasResourceOwner(' ')).toBe(false); + expect(hasResourceOwner(null)).toBe(false); + expect(hasResourceOwner(undefined)).toBe(false); + }); + + it('detects role meta managed when any relevant owner is set', () => { + expect( + isRoleResourceMetaManaged({ + objectOwner: 'terraform', + metaOwner: null, + }) + ).toBe(true); + expect( + isRoleResourceMetaManaged({ + objectOwner: 'human', + metaOwner: null, + }) + ).toBe(true); + expect( + isRoleResourceMetaManaged({ + objectOwner: null, + metaOwner: null, + }) + ).toBe(false); + expect( + isRoleResourceMetaManaged({ + objectOwner: '', + metaOwner: ' ', + }) + ).toBe(false); + }); + + it('resolveResourceOwnershipCliOnError builds command when managed or error matches', () => { + const cmd = resolveResourceOwnershipCliOnError( + true, + { body: { message: 'other' } }, + () => 'zms-cli -d dom' + ); + expect(cmd).toBe('zms-cli -d dom'); + expect( + resolveResourceOwnershipCliOnError( + false, + { body: { message: 'other' } }, + () => 'x' + ) + ).toBeNull(); + expect( + resolveResourceOwnershipCliOnError( + false, + { body: { message: 'resource owner mismatch' } }, + () => 'y' + ) + ).toBe('y'); + expect( + resolveResourceOwnershipCliOnError(true, {}, () => 'z', { + when: false, + }) + ).toBeNull(); + }); + + it('errorMessageSuggestsResourceOwnership matches keywords', () => { + expect( + errorMessageSuggestsResourceOwnership({ + body: { message: 'Resource owner mismatch' }, + }) + ).toBe(true); + expect( + errorMessageSuggestsResourceOwnership({ + body: { message: 'Something else' }, + }) + ).toBe(false); + }); +}); diff --git a/ui/src/__tests__/components/utils/resourceOwnershipUi.test.js b/ui/src/__tests__/components/utils/resourceOwnershipUi.test.js new file mode 100644 index 00000000000..4d0b09f94b1 --- /dev/null +++ b/ui/src/__tests__/components/utils/resourceOwnershipUi.test.js @@ -0,0 +1,79 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations + * under the License. + */ +import { + DEFAULT_RESOURCE_OWNERSHIP_UI, + formatResourceOwnershipUiString, + getCliSuggestionBody, + getCliSuggestionWarningLead, + getManagedIconTooltip, + getMembersManagedIconTooltip, + resolveResourceOwnershipUi, +} from '../../../components/utils/resourceOwnershipUi'; + +const config = require('../../../config/config'); + +describe('resourceOwnershipUi', () => { + it('uses built-in defaults when config is missing', () => { + const ui = resolveResourceOwnershipUi(); + expect(ui.icon).toBe('terraform'); + expect(ui.label).toBe('Terraform'); + }); + + it('merges deployment overrides', () => { + const ui = resolveResourceOwnershipUi({ + label: 'OpenTofu', + icon: 'terraform', + }); + expect(ui.label).toBe('OpenTofu'); + expect(getManagedIconTooltip(ui)).toBe( + 'This resource is managed by OpenTofu (ownership in ZMS).' + ); + expect(getCliSuggestionBody(ui)).toContain('OpenTofu-managed'); + expect(getCliSuggestionBody(ui)).toContain( + 'through OpenTofu configuration' + ); + }); + + it('substitutes {{label}} in templates', () => { + expect( + formatResourceOwnershipUiString('Managed by {{label}}.', 'Custom') + ).toBe('Managed by Custom.'); + expect( + formatResourceOwnershipUiString( + 'Managed by {{label}}.', + DEFAULT_RESOURCE_OWNERSHIP_UI.label + ) + ).toBe('Managed by Terraform.'); + }); + + it('formats members managed icon tooltip', () => { + expect(getMembersManagedIconTooltip({ label: 'OpenTofu' })).toBe( + 'Role membership is managed by OpenTofu (members owner).' + ); + }); + + it('default-config resourceOwnershipUi matches DEFAULT_RESOURCE_OWNERSHIP_UI', () => { + expect(config().resourceOwnershipUi).toEqual( + DEFAULT_RESOURCE_OWNERSHIP_UI + ); + }); + + it('exposes warning lead for functional tests', () => { + expect(getCliSuggestionWarningLead()).toBe( + 'This resource is Terraform-managed and cannot be edited via the Athenz UI' + ); + }); +}); diff --git a/ui/src/__tests__/components/utils/zmsCliCommands.test.js b/ui/src/__tests__/components/utils/zmsCliCommands.test.js new file mode 100644 index 00000000000..b75b44e3ab3 --- /dev/null +++ b/ui/src/__tests__/components/utils/zmsCliCommands.test.js @@ -0,0 +1,111 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { setZmsCliUrl } from '../../../utils/zmsCliUrl'; +import { + cliAddAssertion, + cliDeleteRole, + cliRoleMetaDiff, + formatAssertionWords, + shellQuote, + ZMS_CLI_RESOURCE_OWNER_FLAG, +} from '../../../components/utils/zmsCliCommands'; + +const DOMAIN = 'my.domain'; +const ROLE = 'my-role'; + +describe('shellQuote', () => { + it('returns empty string for null and undefined', () => { + expect(shellQuote(null)).toBe(''); + expect(shellQuote(undefined)).toBe(''); + }); + + it('leaves simple tokens unquoted', () => { + expect(shellQuote('my.domain')).toBe('my.domain'); + expect(shellQuote('my-role')).toBe('my-role'); + expect(shellQuote('user.john')).toBe('user.john'); + }); + + it('double-quotes values with spaces or special characters', () => { + expect(shellQuote('audit ref')).toBe('"audit ref"'); + expect(shellQuote('https://zms.example.com:4443/zms/v1')).toBe( + '"https://zms.example.com:4443/zms/v1"' + ); + expect(shellQuote('contains "quotes"')).toBe('"contains \\"quotes\\""'); + }); +}); + +describe('zmsCliCommands', () => { + afterEach(() => { + setZmsCliUrl(null); + }); + + it('includes -z when ZMS URL is configured', () => { + setZmsCliUrl('https://zms.example.com:4443/zms/v1'); + const cmd = cliDeleteRole('my.domain', 'my-role', 'audit-ref'); + expect(cmd).toContain('-z "https://zms.example.com:4443/zms/v1"'); + expect(cmd).toContain('-d my.domain'); + expect(cmd).toContain(`-r ${ZMS_CLI_RESOURCE_OWNER_FLAG}`); + expect(cmd).toContain('delete-role my-role'); + expect(cmd).toContain('-a audit-ref'); + }); + + it('omits -z when ZMS URL is not configured', () => { + const cmd = cliDeleteRole('my.domain', 'my-role', null); + expect(cmd).not.toContain(' -z '); + expect(cmd).toMatch(/^zms-cli -d my\.domain/); + }); + + it('quotes assertion fields with shell metacharacters', () => { + const words = formatAssertionWords(DOMAIN, { + effect: 'ALLOW', + action: 'read', + role: `${DOMAIN}:role.writers`, + resource: `${DOMAIN}:articles.*`, + }); + expect(words).toBe('grant read to writers on "articles.*"'); + }); + + it('includes quoted assertion words in add-assertion command', () => { + const words = formatAssertionWords(DOMAIN, { + effect: 'ALLOW', + action: 'read', + role: `${DOMAIN}:role.writers`, + resource: `${DOMAIN}:articles.*`, + }); + const cmd = cliAddAssertion( + DOMAIN, + 'writers_policy', + words, + null, + false + ); + expect(cmd).toContain( + 'add-assertion writers_policy grant read to writers on "articles.*"' + ); + }); + + it('quotes role descriptions in set-role-description commands', () => { + const cmd = cliRoleMetaDiff( + DOMAIN, + ROLE, + { description: 'old' }, + { description: 'contains spaces' }, + 'audit-ref' + ); + expect(cmd).toContain('set-role-description my-role "contains spaces"'); + }); +}); diff --git a/ui/src/__tests__/pages/domain/[domain]/__snapshots__/service.test.js.snap b/ui/src/__tests__/pages/domain/[domain]/__snapshots__/service.test.js.snap index 0eb3096ca62..baee49ba13a 100644 --- a/ui/src/__tests__/pages/domain/[domain]/__snapshots__/service.test.js.snap +++ b/ui/src/__tests__/pages/domain/[domain]/__snapshots__/service.test.js.snap @@ -1627,7 +1627,7 @@ exports[`ServicePage should render 1`] = ` {}, userDomain: 'test-user-domain', serviceHeaderLinks: [], @@ -1172,6 +1189,26 @@ describe('Fetchr Server API Test', () => { userId: 'testuser', createDomainMessage: '', productMasterLink: '', + resourceOwnershipGuideLink: { + title: 'resource-ownership-guide', + url: 'https://example.com/resource-ownership', + target: '_blank', + }, + resourceOwnershipUi: { + icon: 'terraform', + label: 'Terraform', + managedIconTooltip: + 'This resource is managed by {{label}} (ownership in ZMS).', + membersManagedIconTooltip: + 'Role membership is managed by {{label}} (members owner).', + cliSuggestionBody: + 'This resource is {{label}}-managed and cannot be edited via the Athenz UI. To maintain environment stability, always apply changes through {{label}} configuration. While the zms-cli can force immediate updates, doing so creates configuration drift, leaving the resource out-of-sync and requiring manual intervention to reconcile.', + cliSuggestionEmergencyHeading: + 'Use only in emergencies:', + cliSuggestionGuideFooter: + 'For detailed guidance, refer to', + }, + zmsUrl: 'https://zms.athenz.io', }); }); }); diff --git a/ui/src/__tests__/spec/libs/resourceOwnershipHelpers.js b/ui/src/__tests__/spec/libs/resourceOwnershipHelpers.js new file mode 100644 index 00000000000..4190246eeec --- /dev/null +++ b/ui/src/__tests__/spec/libs/resourceOwnershipHelpers.js @@ -0,0 +1,138 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { waitForElementExist, waitAndClick } = require('./helpers'); + +let defaultZmsUrl; +try { + defaultZmsUrl = require('../../../config/config')().zms; +} catch (_e) { + defaultZmsUrl = null; +} + +const { + getCliSuggestionWarningLead, + resolveResourceOwnershipUi, +} = require('../../../components/utils/resourceOwnershipUi'); + +let appConfig = {}; +try { + appConfig = require('../../../config/config')(); +} catch (_e) { + appConfig = {}; +} + +const RESOURCE_OWNERSHIP_MANAGED_WARNING = getCliSuggestionWarningLead( + resolveResourceOwnershipUi(appConfig.resourceOwnershipUi) +); + +module.exports.RESOURCE_OWNERSHIP_MANAGED_WARNING = + RESOURCE_OWNERSHIP_MANAGED_WARNING; + +/** + * Assert the managed-resource icon appears within a row/container matched by selector. + * @param {String} containerSelector - CSS or XPath selector for the row + */ + +module.exports.expectManagedResourceIconInContainer = async ( + containerSelector +) => { + const container = await $(containerSelector); + await container.waitForExist({ + timeout: 10000, + timeoutMsg: `Container not found: ${containerSelector}`, + }); + + // XPath relative to container works regardless of container selector strategy. + const icon = await container.$( + `.//*[local-name()="svg" and @data-wdio="resource-ownership-managed"]` + ); + await icon.waitForExist({ + timeout: 10000, + timeoutMsg: `Managed-resource icon not found in ${containerSelector}`, + }); +}; + +/** + * Assert the managed-resource icon does not appear within a container. + * @param {String} containerSelector + */ +module.exports.expectNoManagedResourceIconInContainer = async ( + containerSelector +) => { + const container = await $(containerSelector); + const icon = await container.$( + `.//*[local-name()="svg" and @data-wdio="resource-ownership-managed"]` + ); + await expect(icon).not.toExist(); +}; + +/** + * Wait for resource-ownership CLI suggestion and verify expected substrings in the command/warning text. + * @param {Object} options + * @param {String} options.domain - Athenz domain for zms-cli -d flag + * @param {String[]} options.commandParts - substrings expected in the zms-cli command (e.g. 'delete-role') + * @param {Boolean} [options.expectIgnoreFlag=true] - expect `-r ignore` resource-owner bypass flag + * @param {String} [options.zmsUrl] - when set, expect `-z` with this ZMS base URL + */ +module.exports.expectResourceOwnershipCliSuggestion = async (options) => { + const { + domain, + commandParts, + expectIgnoreFlag = true, + zmsUrl = defaultZmsUrl, + } = options; + const suggestion = await waitForElementExist( + '[data-testid="resource-ownership-cli-suggestion"]' + ); + const text = await suggestion.getText(); + expect(text).toContain(RESOURCE_OWNERSHIP_MANAGED_WARNING); + if (expectIgnoreFlag) { + expect(text).toContain('zms-cli'); + if (zmsUrl) { + expect(text).toContain(`-z ${zmsUrl}`); + } + expect(text).toContain(`-d ${domain}`); + expect(text).toContain('-r ignore'); + } + for (const part of commandParts) { + expect(text).toContain(part); + } + const copyButton = await suggestion.$( + '[data-testid="resource-ownership-cli-copy"]' + ); + await expect(copyButton).toExist(); +}; + +/** + * Close an open delete modal without submitting. + */ +module.exports.cancelDeleteModal = async () => { + const cancel = await $('button[data-testid="delete-modal-cancel"]'); + if (await cancel.isExisting()) { + await waitAndClick(cancel); + } +}; + +/** + * Close an open update modal without submitting. + */ +module.exports.cancelUpdateModal = async () => { + const cancel = await $('button[data-testid="update-modal-cancel"]'); + if (await cancel.isExisting()) { + await waitAndClick(cancel); + } +}; diff --git a/ui/src/__tests__/spec/tests/resource-ownership.spec.js b/ui/src/__tests__/spec/tests/resource-ownership.spec.js new file mode 100644 index 00000000000..27ed89fa8b2 --- /dev/null +++ b/ui/src/__tests__/spec/tests/resource-ownership.spec.js @@ -0,0 +1,357 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * WebdriverIO tests for externally managed resource ownership UI (icons, warnings, zms-cli hints). + * + * Requires manually provisioned resources in the functional-test ownership domain: + * - tf-role: role with objectOwner and/or metaOwner (externally managed metadata) + * - tf-role-members: role with membersOwner (for member add/delete CLI hints) + * - tf-policy: policy with objectOwner and/or assertionsOwner + * - tf-service: service with objectOwner + * - tf-service + public key id OWNERSHIP_TEST_PUBLIC_KEY_ID with publicKeysOwner + * - tf-role-members should include at least one member (for delete-member CLI test) + * - tf-policy should include at least one assertion referencing tf-role (for role-policy tab) + */ + +const config = require('../../../config/config'); +const { + authenticateAndWait, + navigateAndWait, + waitAndClick, + waitAndSetValue, + waitForElementExist, + beforeEachTest, +} = require('../libs/helpers'); +const { + expectManagedResourceIconInContainer, + expectNoManagedResourceIconInContainer, + expectResourceOwnershipCliSuggestion, + cancelDeleteModal, + cancelUpdateModal, +} = require('../libs/resourceOwnershipHelpers'); + +const testdata = config().testdata; + +const OWNERSHIP_TEST_DOMAIN = testdata.functionalTestResourceOwnership; +const OWNERSHIP_TEST_ROLE = 'tf-role'; +const OWNERSHIP_TEST_POLICY = 'tf-policy'; +const OWNERSHIP_TEST_SERVICE = 'tf-service'; +const OWNERSHIP_TEST_PUBLIC_KEY_ID = '0'; + +const OWNERSHIP_TEST_DOMAIN_ROLE_URI = `/domain/${OWNERSHIP_TEST_DOMAIN}/role`; +const OWNERSHIP_TEST_DOMAIN_POLICY_URI = `/domain/${OWNERSHIP_TEST_DOMAIN}/policy`; +const OWNERSHIP_TEST_DOMAIN_SERVICE_URI = `/domain/${OWNERSHIP_TEST_DOMAIN}/service`; + +const ADD_MEMBER_PLACEHOLDER = testdata.userHeadless1.id; + +describe('Externally managed resource ownership UI', () => { + beforeEach(async () => { + await beforeEachTest(); + }); + + describe('managed-resource icons', () => { + it('shows managed-resource icon on externally owned role row', async () => { + await authenticateAndWait(); + await navigateAndWait(OWNERSHIP_TEST_DOMAIN_ROLE_URI); + await waitForElementExist('button*=Add Role'); + await expectManagedResourceIconInContainer( + `//div[@data-wdio="${OWNERSHIP_TEST_ROLE}-role-row"]` + ); + }); + + it('shows managed-resource icon on externally owned policy row', async () => { + await authenticateAndWait(); + await navigateAndWait(OWNERSHIP_TEST_DOMAIN_POLICY_URI); + await waitForElementExist('button*=Add Policy'); + + const policyRowSelector = `//tr[@data-testid="policy-row"][.//*[contains(text(), "${OWNERSHIP_TEST_POLICY}")]]`; + await waitForElementExist(policyRowSelector); + await expectManagedResourceIconInContainer(policyRowSelector); + }); + + it('shows managed-resource icon on externally owned service row', async () => { + await authenticateAndWait(); + await navigateAndWait(OWNERSHIP_TEST_DOMAIN_SERVICE_URI); + await waitForElementExist('button*=Add Service'); + + const serviceRowSelector = `//tr[@data-testid="service-row"][.//td[contains(., "${OWNERSHIP_TEST_SERVICE}")]]`; + await waitForElementExist(serviceRowSelector); + await expectManagedResourceIconInContainer(serviceRowSelector); + }); + + it('shows managed-resource icon on externally owned policy under role policy tab', async () => { + await authenticateAndWait(); + await navigateAndWait(OWNERSHIP_TEST_DOMAIN_ROLE_URI); + + await waitAndClick( + `.//*[local-name()="svg" and @data-wdio="${OWNERSHIP_TEST_ROLE}-policy-rules"]` + ); + + const policyRowSelector = `//tr[@data-testid="policy-row"][.//*[contains(., "${OWNERSHIP_TEST_POLICY}")]]`; + await waitForElementExist(policyRowSelector); + await expectManagedResourceIconInContainer(policyRowSelector); + }); + + it('does not show managed-resource icon on role members table rows', async () => { + await authenticateAndWait(); + await navigateAndWait( + `/domain/${OWNERSHIP_TEST_DOMAIN}/role/${OWNERSHIP_TEST_ROLE}/members` + ); + await waitForElementExist('button*=Add Member'); + + const memberRows = await $$('tr[data-wdio$="-member-row"]'); + expect(memberRows.length).toBeGreaterThan(0); + for (const row of memberRows) { + const memberWdio = await row.getAttribute('data-wdio'); + await expectNoManagedResourceIconInContainer( + `tr[data-wdio="${memberWdio}"]` + ); + } + }); + }); + + describe('resource ownership CLI suggestions on blocked mutations', () => { + it('shows delete-role zms-cli when deleting a TF-managed role', async () => { + await authenticateAndWait(); + await navigateAndWait(OWNERSHIP_TEST_DOMAIN_ROLE_URI); + + const deleteButton = await $( + `.//*[local-name()="svg" and @id="${OWNERSHIP_TEST_ROLE}-delete-role-button"]` + ); + await waitAndClick(deleteButton); + await waitForElementExist('div[data-testid="modal-title"]'); + await waitAndClick('button[data-testid="delete-modal-delete"]'); + + await expectResourceOwnershipCliSuggestion({ + domain: OWNERSHIP_TEST_DOMAIN, + commandParts: ['delete-roles', OWNERSHIP_TEST_ROLE], + }); + await cancelDeleteModal(); + }); + + it('shows delete-policy zms-cli when deleting a TF-managed policy', async () => { + await authenticateAndWait(); + await navigateAndWait(OWNERSHIP_TEST_DOMAIN_POLICY_URI); + + await waitAndClick( + `.//*[local-name()="svg" and @data-wdio="${OWNERSHIP_TEST_POLICY}-delete"]` + ); + await waitForElementExist('div[data-testid="modal-title"]'); + await waitAndClick('button[data-testid="delete-modal-delete"]'); + + await expectResourceOwnershipCliSuggestion({ + domain: OWNERSHIP_TEST_DOMAIN, + commandParts: ['delete-policy', OWNERSHIP_TEST_POLICY], + }); + await cancelDeleteModal(); + }); + + it('shows delete-service zms-cli when deleting a TF-managed service', async () => { + await authenticateAndWait(); + await navigateAndWait(OWNERSHIP_TEST_DOMAIN_SERVICE_URI); + + await waitAndClick( + `.//*[local-name()="svg" and @id="delete-service-${OWNERSHIP_TEST_SERVICE}"]` + ); + await waitForElementExist('div[data-testid="modal-title"]'); + await waitAndClick('button[data-testid="delete-modal-delete"]'); + + await expectResourceOwnershipCliSuggestion({ + domain: OWNERSHIP_TEST_DOMAIN, + commandParts: ['delete-service', OWNERSHIP_TEST_SERVICE], + }); + await cancelDeleteModal(); + }); + + it('shows add-member zms-cli when adding a member to a TF-managed role', async () => { + await authenticateAndWait(); + await navigateAndWait( + `/domain/${OWNERSHIP_TEST_DOMAIN}/role/${OWNERSHIP_TEST_ROLE}/members` + ); + + await waitAndClick('button*=Add Member'); + await waitAndSetValue( + 'input[name="member-name"]', + ADD_MEMBER_PLACEHOLDER + ); + await waitAndClick(`div*=${ADD_MEMBER_PLACEHOLDER}`); + await waitAndClick('button*=Submit'); + + await expectResourceOwnershipCliSuggestion({ + domain: OWNERSHIP_TEST_DOMAIN, + commandParts: [ + 'add-member', + OWNERSHIP_TEST_ROLE, + ADD_MEMBER_PLACEHOLDER, + ], + }); + await waitAndClick('button*=Cancel'); + }); + + it('shows delete-member zms-cli when deleting a member from a TF-managed role', async () => { + await authenticateAndWait(); + await navigateAndWait( + `/domain/${OWNERSHIP_TEST_DOMAIN}/role/${OWNERSHIP_TEST_ROLE}/members` + ); + + const memberRow = await waitForElementExist( + `tr[data-wdio$="-member-row"]` + ); + const memberWdio = await memberRow.getAttribute('data-wdio'); + const memberName = memberWdio.replace(/-member-row$/, ''); + + await waitAndClick( + `.//*[local-name()="svg" and @data-wdio="${memberName}-delete-member"]` + ); + await waitForElementExist('div[data-testid="modal-title"]'); + await waitAndClick('button[data-testid="delete-modal-delete"]'); + + await expectResourceOwnershipCliSuggestion({ + domain: OWNERSHIP_TEST_DOMAIN, + commandParts: [ + 'delete-member', + OWNERSHIP_TEST_ROLE, + memberName, + ], + }); + await cancelDeleteModal(); + }); + + it('shows set-role zms-cli when updating TF-managed role settings', async () => { + await authenticateAndWait(); + await navigateAndWait(OWNERSHIP_TEST_DOMAIN_ROLE_URI); + + await waitAndClick( + `.//*[local-name()="svg" and @id="${OWNERSHIP_TEST_ROLE}-setting-role-button"]` + ); + await waitForElementExist('#setting-description'); + + const description = await $('#setting-description'); + const original = await description.getValue(); + const updated = + original === 'wdio-tf-test' + ? 'wdio-tf-test-updated' + : 'wdio-tf-test'; + await waitAndSetValue(description, updated, { clearFirst: true }); + await waitAndClick('button*=Submit'); + await waitAndClick('button[data-testid="update-modal-update"]'); + + await expectResourceOwnershipCliSuggestion({ + domain: OWNERSHIP_TEST_DOMAIN, + commandParts: [ + 'set-role-description', + OWNERSHIP_TEST_ROLE, + updated, + ], + }); + await cancelUpdateModal(); + }); + + it('shows add-assertion zms-cli when adding a rule to a TF-managed policy from role policy tab', async () => { + await authenticateAndWait(); + await navigateAndWait(OWNERSHIP_TEST_DOMAIN_ROLE_URI); + + await waitAndClick( + `.//*[local-name()="svg" and @data-wdio="${OWNERSHIP_TEST_ROLE}-policy-rules"]` + ); + await waitAndClick('a*=Add rule'); + + await waitAndSetValue( + `input[id="rule-action-${OWNERSHIP_TEST_POLICY}"]`, + 'tf-wdio-action' + ); + await waitAndSetValue( + `input[id="rule-resource-${OWNERSHIP_TEST_POLICY}"]`, + 'tf-wdio-resource' + ); + await waitAndClick('button*=Submit'); + + await expectResourceOwnershipCliSuggestion({ + domain: OWNERSHIP_TEST_DOMAIN, + commandParts: ['add-assertion', OWNERSHIP_TEST_POLICY], + }); + await waitAndClick('button*=Cancel'); + }); + + it('shows delete-assertion zms-cli when deleting a rule from a TF-managed role policy', async () => { + await authenticateAndWait(); + await navigateAndWait(OWNERSHIP_TEST_DOMAIN_ROLE_URI); + + await waitAndClick( + `.//*[local-name()="svg" and @data-wdio="${OWNERSHIP_TEST_ROLE}-policy-rules"]` + ); + + const ruleRow = await waitForElementExist( + `tr[data-wdio="${OWNERSHIP_TEST_ROLE}-policy-rule-row"]` + ); + const trashIcons = await ruleRow.$$( + `.//*[local-name()="svg" and @data-testid="icon"]` + ); + await waitAndClick(trashIcons[trashIcons.length - 1]); + + await waitForElementExist('div[data-testid="modal-title"]'); + await waitAndClick('button[data-testid="delete-modal-delete"]'); + + await expectResourceOwnershipCliSuggestion({ + domain: OWNERSHIP_TEST_DOMAIN, + commandParts: ['delete-assertion', OWNERSHIP_TEST_POLICY], + }); + await cancelDeleteModal(); + }); + + it('shows delete-public-key zms-cli when deleting a TF-managed service public key', async () => { + await authenticateAndWait(); + await navigateAndWait(OWNERSHIP_TEST_DOMAIN_SERVICE_URI); + + const serviceRow = await waitForElementExist( + `//tr[@data-testid="service-row"][.//td[contains(., "${OWNERSHIP_TEST_SERVICE}")]]` + ); + const rowIcons = await serviceRow.$$( + `.//*[local-name()="svg" and @data-testid="icon"]` + ); + // key icon is the second action icon when instance navigation is enabled + await waitAndClick( + `.//*[local-name()="svg" and @data-wdio="service-pubkeys-${OWNERSHIP_TEST_SERVICE}"]` + ); + + const publicKeyTable = await waitForElementExist( + '[data-testid="public-key-table"]' + ); + const keyLabel = await publicKeyTable.$( + `div*=Public Key Version: ${OWNERSHIP_TEST_PUBLIC_KEY_ID}` + ); + await expect(keyLabel).toExist(); + + await waitAndClick( + `.//*[local-name()="svg" and @data-wdio="delete-key-${OWNERSHIP_TEST_PUBLIC_KEY_ID}"]` + ); + + await waitForElementExist('div[data-testid="modal-title"]'); + await waitAndClick('button[data-testid="delete-modal-delete"]'); + + await expectResourceOwnershipCliSuggestion({ + domain: OWNERSHIP_TEST_DOMAIN, + commandParts: [ + 'delete-public-key', + OWNERSHIP_TEST_SERVICE, + OWNERSHIP_TEST_PUBLIC_KEY_ID, + ], + }); + await cancelDeleteModal(); + }); + }); +}); diff --git a/ui/src/__tests__/spec/wdio.conf.js b/ui/src/__tests__/spec/wdio.conf.js index f782e0b4cf1..0ff600f9446 100644 --- a/ui/src/__tests__/spec/wdio.conf.js +++ b/ui/src/__tests__/spec/wdio.conf.js @@ -228,7 +228,7 @@ let config = { baseUrl: functionalConfig.instance, // // Default timeout for all waitFor* commands. - waitforTimeout: 30000, + waitforTimeout: 10000, // // Default timeout in milliseconds for request // if browser driver or grid doesn't send response @@ -251,7 +251,7 @@ let config = { framework: 'mocha', // // The number of times to retry the entire specfile when it fails as a whole - specFileRetries: 1, + specFileRetries: 0, // // Delay in seconds between the spec file retry attempts specFileRetriesDelay: 5, @@ -269,8 +269,8 @@ let config = { // See the full list at http://mochajs.org/ mochaOpts: { ui: 'bdd', - timeout: 120000, - retries: 2, + timeout: 10000, + retries: 1, }, // // ===== diff --git a/ui/src/components/denali/icons/Icons.js b/ui/src/components/denali/icons/Icons.js index a3b1b4ea257..ef39bc691fe 100644 --- a/ui/src/components/denali/icons/Icons.js +++ b/ui/src/components/denali/icons/Icons.js @@ -223,6 +223,10 @@ export const ICONS = { 'M492.8 145.067l360.533 360.533v347.733h-347.733l-360.533-360.533 347.733-347.733zM492.8 42.667c-11.712 0.172-22.255 5.037-29.86 12.793l-0.007 0.007-407.467 407.467c-8.083 7.434-13.13 18.061-13.13 29.867s5.047 22.432 13.101 29.84l0.029 0.026 416 416h424.533c23.564 0 42.667-19.103 42.667-42.667v0-424.533l-416-416c-7.612-7.763-18.155-12.628-29.835-12.8l-0.032-0z', 'M746.667 661.333c0 47.128-38.205 85.333-85.333 85.333s-85.333-38.205-85.333-85.333c0-47.128 38.205-85.333 85.333-85.333s85.333 38.205 85.333 85.333z', ], + terraform: [ + 'M8.8045 4.4847l6.3381 3.6632 0 7.3155-6.3381-3.66zM1.7714 0.4l0 7.3194 6.3381 3.66 0-7.32zM8.8045 19.9269l6.3381 3.66 0-7.32-6.3381-3.6594z', + 'M22.1757 11.8002l0-7.3155-6.3381 3.6555 0 7.3232z', + ], trash: [ 'M917.333 187.733c0.007 0.319 0.012 0.694 0.012 1.071 0 12.755-4.867 24.374-12.846 33.1l0.034-0.038c-6.696 7.89-16.62 12.864-27.706 12.864-0.76 0-1.515-0.023-2.264-0.069l0.103 0.005h-256c-23.564 0-42.667-19.103-42.667-42.667v0-42.667h-128v42.667c0 23.564-19.103 42.667-42.667 42.667v0h-253.867c-0.136 0.001-0.298 0.002-0.459 0.002-22.485 0-41.102-16.565-44.311-38.157l-0.030-0.245c-0.007-0.319-0.012-0.694-0.012-1.071 0-12.755 4.867-24.374 12.846-33.1l-0.034 0.038c6.696-7.89 16.62-12.864 27.706-12.864 0.76 0 1.515 0.023 2.264 0.069l-0.103-0.005h213.333v-42.667c0-23.564 19.103-42.667 42.667-42.667v0h213.333c23.564 0 42.667 19.103 42.667 42.667v0 42.667h211.2c0.136-0.001 0.298-0.002 0.459-0.002 22.485 0 41.102 16.565 44.311 38.157l0.030 0.245z', 'M742.4 358.4l-51.2 516.267h-358.4l-51.2-516.267c-2.24-21.656-20.391-38.401-42.453-38.401-0.075 0-0.15 0-0.225 0.001l0.012-0c-0.064-0-0.139-0.001-0.214-0.001-23.564 0-42.667 19.103-42.667 42.667 0 1.503 0.078 2.988 0.229 4.45l-0.015-0.183 55.467 554.667c2.24 21.656 20.391 38.401 42.453 38.401 0.075 0 0.15-0 0.225-0.001l-0.012 0h435.2c0.064 0 0.139 0.001 0.214 0.001 22.062 0 40.213-16.744 42.437-38.218l0.015-0.183 55.467-554.667c0.136-1.28 0.214-2.764 0.214-4.267 0-23.564-19.103-42.667-42.667-42.667-0.075 0-0.15 0-0.226 0.001l0.012-0c-0.064-0-0.139-0.001-0.214-0.001-22.062 0-40.213 16.744-42.437 38.218l-0.015 0.183z', diff --git a/ui/src/components/group/GroupRoleTable.js b/ui/src/components/group/GroupRoleTable.js index 192f69951b4..6146adb847e 100644 --- a/ui/src/components/group/GroupRoleTable.js +++ b/ui/src/components/group/GroupRoleTable.js @@ -17,6 +17,7 @@ import React from 'react'; import styled from '@emotion/styled'; import RoleGroup from '../role/RoleGroup'; import { GROUP_ROLES_CATEGORY } from '../constants/constants'; +import { maxRoleVisibleIconCount } from '../utils/roleIconStrip'; import { selectIsLoading } from '../../redux/selectors/loading'; import { connect } from 'react-redux'; import { ReduxPageLoader } from '../denali/ReduxPageLoader'; @@ -102,16 +103,20 @@ class GroupRoleTable extends React.Component { if (this.state.rows) { for (let name in this.state.rows) { // group rows + const groupRoles = this.state.rows[name]; + const groupIconStripMax = + maxRoleVisibleIconCount(groupRoles); let roleGroup = ( ); rows.push(roleGroup); diff --git a/ui/src/components/header/NameHeader.js b/ui/src/components/header/NameHeader.js index a4968f4de0c..5cc6add2aa1 100644 --- a/ui/src/components/header/NameHeader.js +++ b/ui/src/components/header/NameHeader.js @@ -21,6 +21,12 @@ import { colors } from '../denali/styles'; import Link from 'next/link'; import { withRouter } from 'next/router'; import PageUtils from '../utils/PageUtils'; +import ManagedResourceIcon from '../resource-ownership/ManagedResourceIcon'; +import { + isPolicyResourceManaged, + isRoleResourceMetaManaged, + isServiceResourceObjectManaged, +} from '../utils/resourceOwnership'; const StyledAnchor = styled.a` color: #3570f4; @@ -89,6 +95,23 @@ class NameHeader extends React.Component { let roleTypeIcon = collectionDetails.trust ? iconDelegated : ''; let roleAuditIcon = collectionDetails.auditEnabled ? iconAudit : ''; + let collectionManagedResourceIcon = ''; + if ( + this.props.category === 'role' && + isRoleResourceMetaManaged(collectionDetails.resourceOwnership) + ) { + collectionManagedResourceIcon = ; + } else if ( + this.props.category === 'policy' && + isPolicyResourceManaged(collectionDetails.resourceOwnership) + ) { + collectionManagedResourceIcon = ; + } else if ( + this.props.category === 'service' && + isServiceResourceObjectManaged(collectionDetails.resourceOwnership) + ) { + collectionManagedResourceIcon = ; + } if (collectionDetails.trust) { let deDomain = collectionDetails.trust; @@ -96,6 +119,7 @@ class NameHeader extends React.Component { {roleTypeIcon} {roleAuditIcon} + {collectionManagedResourceIcon} {roleTypeIcon} {roleAuditIcon} + {collectionManagedResourceIcon} {link}:{this.props.category}.{collection} ); diff --git a/ui/src/components/header/ServiceNameHeader.js b/ui/src/components/header/ServiceNameHeader.js index 02b0f9fc7c4..a3dbd3c973f 100644 --- a/ui/src/components/header/ServiceNameHeader.js +++ b/ui/src/components/header/ServiceNameHeader.js @@ -20,8 +20,10 @@ import PageUtils from '../utils/PageUtils'; import Menu from '../denali/Menu/Menu'; import Icon from '../denali/icons/Icon'; import { colors } from '../denali/styles'; -import { selectDynamicServiceHeaderDetails } from '../../redux/selectors/services'; import { connect } from 'react-redux'; +import { selectService } from '../../redux/selectors/services'; +import ManagedResourceIcon from '../resource-ownership/ManagedResourceIcon'; +import { isServiceResourceObjectManaged } from '../utils/resourceOwnership'; const StyledAnchor = styled.a` color: #3570f4; @@ -47,8 +49,11 @@ const SectionHeader = styled.span` vertical-align: 3px; `; -export default function ServiceNameHeader(props) { +function ServiceNameHeader(props) { const { domain, service, serviceHeaderDetails } = props; + const showManagedResourceIcon = isServiceResourceObjectManaged( + props.resourceOwnership + ); let link = ( @@ -80,6 +85,9 @@ export default function ServiceNameHeader(props) { return ( {link}:service.{service} + {showManagedResourceIcon ? ( + + ) : null} ); } + +const mapStateToProps = (state, ownProps) => ({ + resourceOwnership: selectService(state, ownProps.domain, ownProps.service) + .resourceOwnership, +}); + +export default connect(mapStateToProps)(ServiceNameHeader); diff --git a/ui/src/components/member/AddMember.js b/ui/src/components/member/AddMember.js index a5fa0735a12..e8af199dfc0 100644 --- a/ui/src/components/member/AddMember.js +++ b/ui/src/components/member/AddMember.js @@ -30,6 +30,17 @@ import InputDropdown from '../denali/InputDropdown'; import MemberUtils from '../utils/MemberUtils'; import { selectAllUsers } from '../../redux/selectors/user'; import Downshift from 'downshift'; +import ManagedResourceIcon from '../resource-ownership/ManagedResourceIcon'; +import { + isRoleResourceMembersManaged, + shouldOfferResourceOwnershipCli, +} from '../utils/resourceOwnership'; +import { getMembersManagedIconTooltip } from '../utils/resourceOwnershipUi'; +import { selectResourceOwnershipUi } from '../../redux/selectors/domains'; +import { + cliAddRoleMember, + cliAddTemporaryRoleMember, +} from '../utils/zmsCliCommands'; const SectionsDiv = styled.div` width: 760px; @@ -102,10 +113,21 @@ class AddMember extends React.Component { this.state = { showModal: !!this.props.showAddMember, + resourceOwnershipCliCommand: null, }; this.dateUtils = new DateUtils(); } + componentDidUpdate(prevProps) { + if (prevProps.showAddMember !== this.props.showAddMember) { + this.setState({ + showModal: !!this.props.showAddMember, + errorMessage: null, + resourceOwnershipCliCommand: null, + }); + } + } + clearStateIfInputDoesntMatchIt(inputVal) { if (this.state.memberName && this.state.memberName !== inputVal) { this.setState({ ['memberName']: '' }); @@ -177,6 +199,7 @@ class AddMember extends React.Component { this.setState({ showModal: false, justification: '', + resourceOwnershipCliCommand: null, }); this.props.onSubmit( `${this.state.memberName}-${this.props.category}-${this.props.domainName}-${this.props.collection}`, @@ -184,8 +207,38 @@ class AddMember extends React.Component { ); }) .catch((err) => { + const errMsg = RequestUtils.xhrErrorCheckHelper(err); + const isMembersManaged = isRoleResourceMembersManaged( + this.props.resourceOwnership + ); + let resourceOwnershipCliCommand = null; + if (shouldOfferResourceOwnershipCli(isMembersManaged, err)) { + const aud = + this.props.justificationRequired && + this.state.justification + ? this.state.justification + : null; + if (member.expiration || member.reviewReminder) { + resourceOwnershipCliCommand = cliAddTemporaryRoleMember( + this.props.domainName, + this.props.collection, + member.memberName, + member.expiration || '', + member.reviewReminder || '', + aud + ); + } else { + resourceOwnershipCliCommand = cliAddRoleMember( + this.props.domainName, + this.props.collection, + member.memberName, + aud + ); + } + } this.setState({ - errorMessage: RequestUtils.xhrErrorCheckHelper(err), + errorMessage: errMsg, + resourceOwnershipCliCommand, }); }); } @@ -295,19 +348,45 @@ class AddMember extends React.Component { ); + const isMembersManaged = isRoleResourceMembersManaged( + this.props.resourceOwnership + ); + const title = ( + + {'Add Member to ' + + this.props.category + + ': ' + + this.props.collection} + {isMembersManaged ? ( + + ) : null} + + ); + return (
@@ -319,6 +398,7 @@ const mapStateToProps = (state, props) => { return { ...props, userList: selectAllUsers(state), + resourceOwnershipUi: selectResourceOwnershipUi(state), }; }; diff --git a/ui/src/components/member/MemberList.js b/ui/src/components/member/MemberList.js index 23d0eea491e..067dbecb727 100644 --- a/ui/src/components/member/MemberList.js +++ b/ui/src/components/member/MemberList.js @@ -112,6 +112,7 @@ class MemberList extends React.Component { _csrf={this.props._csrf} showAddMember={this.state.showAddMember} justificationRequired={justificationReq} + resourceOwnership={collectionDetails.resourceOwnership} /> ) : ( '' @@ -155,6 +156,7 @@ class MemberList extends React.Component { onSubmit={this.reloadMembers} justificationRequired={justificationReq} newMember={newMember} + resourceOwnership={collectionDetails.resourceOwnership} />
{showPending ? ( @@ -170,6 +172,7 @@ class MemberList extends React.Component { onSubmit={this.reloadMembers} justificationRequired={justificationReq} newMember={newMember} + resourceOwnership={collectionDetails.resourceOwnership} /> ) : null} {this.state.showSuccess ? ( diff --git a/ui/src/components/member/MemberRow.js b/ui/src/components/member/MemberRow.js index c14c46e806e..ec3d82b8705 100644 --- a/ui/src/components/member/MemberRow.js +++ b/ui/src/components/member/MemberRow.js @@ -36,6 +36,14 @@ import { MEMBER_AUTHORITY_SYSTEM_SUSPENDED, } from '../constants/constants'; import NameUtils from '../utils/NameUtils'; +import { + isRoleResourceMembersManaged, + shouldOfferResourceOwnershipCli, +} from '../utils/resourceOwnership'; +import { + cliAddTemporaryRoleMember, + cliDeleteRoleMember, +} from '../utils/zmsCliCommands'; const TDStyled = styled.td` background-color: ${(props) => props.color}; @@ -112,6 +120,7 @@ class MemberRow extends React.Component { showDelete: false, showEdit: false, date: null, + resourceOwnershipCliCommand: null, }; this.localDate = new DateUtils(); } @@ -173,10 +182,30 @@ class MemberRow extends React.Component { true ); } + this.setState({ resourceOwnershipCliCommand: null }); }) .catch((err) => { + const errMsg = RequestUtils.xhrErrorCheckHelper(err); + const isMembersManaged = isRoleResourceMembersManaged( + this.props.resourceOwnership + ); + let resourceOwnershipCliCommand = null; + if (shouldOfferResourceOwnershipCli(isMembersManaged, err)) { + const aud = + this.props.justificationRequired && + this.state.justification + ? this.state.justification + : null; + resourceOwnershipCliCommand = cliDeleteRoleMember( + this.props.domain, + this.props.collection, + this.state.deleteName, + aud + ); + } this.setState({ - errorMessage: RequestUtils.xhrErrorCheckHelper(err), + errorMessage: errMsg, + resourceOwnershipCliCommand, }); }); } @@ -186,6 +215,7 @@ class MemberRow extends React.Component { showDelete: false, deleteName: '', errorMessage: null, + resourceOwnershipCliCommand: null, }); } @@ -202,6 +232,7 @@ class MemberRow extends React.Component { showEdit: false, errorMessage: null, date: null, + resourceOwnershipCliCommand: null, }); } @@ -245,11 +276,33 @@ class MemberRow extends React.Component { ); this.setState({ showEdit: false, + resourceOwnershipCliCommand: null, }); }) .catch((err) => { + const errMsg = RequestUtils.xhrErrorCheckHelper(err); + const isMembersManaged = isRoleResourceMembersManaged( + this.props.resourceOwnership + ); + let resourceOwnershipCliCommand = null; + if (shouldOfferResourceOwnershipCli(isMembersManaged, err)) { + const aud = + this.props.justificationRequired && + this.state.justification + ? this.state.justification + : null; + resourceOwnershipCliCommand = cliAddTemporaryRoleMember( + this.props.domain, + this.props.collection, + member.memberName, + member.expiration || '', + member.reviewReminder || '', + aud + ); + } this.setState({ - errorMessage: RequestUtils.xhrErrorCheckHelper(err), + errorMessage: errMsg, + resourceOwnershipCliCommand, }); }); } @@ -502,6 +555,9 @@ class MemberRow extends React.Component { onJustification={this.saveJustification} onDateChange={this.onDateChange} errorMessage={this.state.errorMessage} + resourceOwnershipCliCommand={ + this.state.resourceOwnershipCliCommand + } /> ); } @@ -520,6 +576,9 @@ class MemberRow extends React.Component { } onJustification={this.saveJustification} errorMessage={this.state.errorMessage} + resourceOwnershipCliCommand={ + this.state.resourceOwnershipCliCommand + } /> ); } diff --git a/ui/src/components/member/MemberTable.js b/ui/src/components/member/MemberTable.js index 402fbbfa09e..1e54ba24059 100644 --- a/ui/src/components/member/MemberTable.js +++ b/ui/src/components/member/MemberTable.js @@ -163,6 +163,7 @@ export default class MemberTable extends React.Component { this.props.justificationRequired } newMember={this.props.newMember} + resourceOwnership={this.props.resourceOwnership} /> ); }); diff --git a/ui/src/components/microsegmentation/AddStaticInstances.js b/ui/src/components/microsegmentation/AddStaticInstances.js index cbc18e918dd..bd03c0e97a6 100644 --- a/ui/src/components/microsegmentation/AddStaticInstances.js +++ b/ui/src/components/microsegmentation/AddStaticInstances.js @@ -25,6 +25,11 @@ import { StaticWorkloadType } from '../constants/constants'; import RegexUtils from '../utils/RegexUtils'; import { addServiceHost } from '../../redux/thunks/services'; import { connect } from 'react-redux'; +import { + isServiceResourceHostsManaged, + shouldOfferResourceOwnershipCli, +} from '../utils/resourceOwnership'; +import { cliAddServiceHost } from '../utils/zmsCliCommands'; const SectionDiv = styled.div` align-items: center; @@ -66,6 +71,7 @@ class AddStaticInstances extends React.Component { resourceValue: '', resourceType: '', pattern: '', + resourceOwnershipCliCommand: null, }; } @@ -81,6 +87,7 @@ class AddStaticInstances extends React.Component { onSubmit() { this.setState({ errorMessage: '', + resourceOwnershipCliCommand: null, }); //Input field validation @@ -127,8 +134,22 @@ class AddStaticInstances extends React.Component { this.props._csrf ) .catch((err) => { + const errMsg = RequestUtils.xhrErrorCheckHelper(err); + const isHostsManaged = isServiceResourceHostsManaged( + this.props.resourceOwnership + ); + let resourceOwnershipCliCommand = null; + if (shouldOfferResourceOwnershipCli(isHostsManaged, err)) { + resourceOwnershipCliCommand = cliAddServiceHost( + this.props.domain, + this.props.service, + [this.state.resourceValue], + auditRef + ); + } this.setState({ - errorMessage: RequestUtils.xhrErrorCheckHelper(err), + errorMessage: errMsg, + resourceOwnershipCliCommand, }); }); } @@ -169,6 +190,9 @@ class AddStaticInstances extends React.Component { submit={this.onSubmit} title={`Add Static Instances`} errorMessage={this.state.errorMessage} + resourceOwnershipCliCommand={ + this.state.resourceOwnershipCliCommand + } sections={sections} /> diff --git a/ui/src/components/modal/AddModal.js b/ui/src/components/modal/AddModal.js index 4fa11df7c47..014b01af259 100644 --- a/ui/src/components/modal/AddModal.js +++ b/ui/src/components/modal/AddModal.js @@ -17,8 +17,8 @@ import React from 'react'; import styled from '@emotion/styled'; import Button from '../denali/Button'; import Modal from '../denali/Modal'; -import Color from '../denali/Color'; import Loader from '../denali/Loader'; +import ResourceOwnershipModalFeedback from '../resource-ownership/ResourceOwnershipModalFeedback'; const MessageDiv = styled.div` text-align: left; @@ -88,9 +88,12 @@ export default class AddModal extends React.Component { > {this.props.sections} - {this.props.errorMessage && ( - {this.props.errorMessage} - )} + )} - {this.props.errorMessage && ( - {this.props.errorMessage} - )} + )} - {this.props.errorMessage && ( - {this.props.errorMessage} - )} + { + const errMsg = RequestUtils.xhrErrorCheckHelper(err); + const isPolicyManaged = isPolicyResourceManaged( + this.props.resourceOwnership + ); + let resourceOwnershipCliCommand = null; + if (shouldOfferResourceOwnershipCli(isPolicyManaged, err)) { + const assertion = { + effect: this.state.effect, + action: this.state.action, + role: this.state.role, + resource: this.state.resource, + }; + const words = formatAssertionWords( + this.props.domain, + assertion + ); + resourceOwnershipCliCommand = cliAddAssertionPolicyVersion( + this.props.domain, + this.props.name, + this.props.version, + words, + null, + !!this.state.case + ); + } this.setState({ - errorMessage: RequestUtils.xhrErrorCheckHelper(err), + errorMessage: errMsg, + resourceOwnershipCliCommand, }); }); } @@ -108,11 +144,21 @@ class AddAssertion extends React.Component { domain={this.props.domain} id={this.props.name + '-' + this.props.version} /> - {this.state.errorMessage && ( + {this.state.errorMessage && + !this.state.resourceOwnershipCliCommand && ( + + + {this.state.errorMessage} + + + )} + {this.state.resourceOwnershipCliCommand ? ( - {this.state.errorMessage} + - )} + ) : null} Submit diff --git a/ui/src/components/policy/AddPolicy.js b/ui/src/components/policy/AddPolicy.js index 81a12560546..41357484724 100644 --- a/ui/src/components/policy/AddPolicy.js +++ b/ui/src/components/policy/AddPolicy.js @@ -17,6 +17,12 @@ import React from 'react'; import AddModal from '../modal/AddModal'; import AddRuleForm from './AddRuleForm'; import RequestUtils from '../utils/RequestUtils'; +import { + shouldOfferResourceOwnershipCli, + isPolicyResourceManaged, +} from '../utils/resourceOwnership'; +import { cliAddPolicy } from '../utils/zmsCliCommands'; +import NameUtils from '../utils/NameUtils'; export default class AddPolicy extends React.Component { constructor(props) { @@ -26,6 +32,7 @@ export default class AddPolicy extends React.Component { this.state = { showModal: !!this.props.showAddPolicy, case: false, + resourceOwnershipCliCommand: null, }; } @@ -69,10 +76,49 @@ export default class AddPolicy extends React.Component { this.state.case, this.props._csrf ) - .then(() => this.setState({ showModal: false })) + .then(() => + this.setState({ + showModal: false, + resourceOwnershipCliCommand: null, + }) + ) .catch((err) => { + const errMsg = RequestUtils.xhrErrorCheckHelper(err); + const isPolicyManaged = isPolicyResourceManaged( + this.props.resourceOwnership + ); + let resourceOwnershipCliCommand = null; + if (shouldOfferResourceOwnershipCli(isPolicyManaged, err)) { + const assertion = { + effect: this.state.effect, + action: this.state.action, + role: this.state.role, + resource: this.state.resource, + }; + const role = NameUtils.getShortName( + this.props.domain + ':role.', + assertion.role + ); + const res = NameUtils.getShortName( + this.props.domain + ':', + assertion.resource + ); + const eff = + String(assertion.effect || '').toUpperCase() === 'DENY' + ? 'deny' + : 'grant'; + const words = `${eff} ${assertion.action} to ${role} on ${res}`; + resourceOwnershipCliCommand = cliAddPolicy( + this.props.domain, + this.state.name, + words, + null, + !!this.state.case + ); + } this.setState({ - errorMessage: RequestUtils.xhrErrorCheckHelper(err), + errorMessage: errMsg, + resourceOwnershipCliCommand, }); }); } @@ -89,6 +135,9 @@ export default class AddPolicy extends React.Component { submit={this.onSubmit} title={`Add Policy to ${this.props.domain}`} errorMessage={this.state.errorMessage} + resourceOwnershipCliCommand={ + this.state.resourceOwnershipCliCommand + } sections={ { + const errMsg = RequestUtils.xhrErrorCheckHelper(err); + const versions = + this.state.policiesMap[this.state.deletePolicyName]; + const active = versions && versions.find((x) => x.active); + const isPolicyManaged = isPolicyResourceManaged( + active && active.resourceOwnership + ); + let resourceOwnershipCliCommand = null; + if (shouldOfferResourceOwnershipCli(isPolicyManaged, err)) { + resourceOwnershipCliCommand = cliDeletePolicy( + this.props.domain, + this.state.deletePolicyName, + null + ); + } this.setState({ - errorMessage: RequestUtils.xhrErrorCheckHelper(err), + errorMessage: errMsg, + resourceOwnershipCliCommand, }); }); } @@ -199,8 +221,26 @@ export class PolicyList extends React.Component { ); }) .catch((err) => { + const errMsg = RequestUtils.xhrErrorCheckHelper(err); + const versions = + this.state.policiesMap[this.state.duplicatePolicyName]; + const active = versions && versions.find((x) => x.active); + const isPolicyManaged = isPolicyResourceManaged( + active && active.resourceOwnership + ); + let resourceOwnershipCliCommand = null; + if (shouldOfferResourceOwnershipCli(isPolicyManaged, err)) { + resourceOwnershipCliCommand = cliAddPolicyVersion( + this.props.domain, + this.state.duplicatePolicyName, + this.state.duplicateVersionSourceName, + this.state.duplicateVersionName, + null + ); + } this.setState({ - errorMessage: RequestUtils.xhrErrorCheckHelper(err), + errorMessage: errMsg, + resourceOwnershipCliCommand, }); }); } @@ -209,6 +249,7 @@ export class PolicyList extends React.Component { this.setState({ showDelete: false, deletePolicyName: null, + resourceOwnershipCliCommand: null, }); } @@ -218,6 +259,7 @@ export class PolicyList extends React.Component { duplicatePolicyName: null, duplicateVersionName: null, reloadPolicyVersionsFunc: null, + resourceOwnershipCliCommand: null, }); } @@ -299,6 +341,13 @@ export class PolicyList extends React.Component { const left = 'left'; const center = 'center'; const policyNames = Object.keys(this.state.policiesMap); + const managedResourceIconStripMaxIcons = + this.props.policies && + this.props.policies.some((p) => + isPolicyResourceManaged(p.resourceOwnership) + ) + ? 1 + : 0; const rows = policyNames.map((policyName, i) => { let item = AppUtils.deepClone( this.state.policiesMap[policyName][0] @@ -324,6 +373,7 @@ export class PolicyList extends React.Component { policyVersions={this.state.policiesMap[policyName]} domain={domain} modified={item.modified} + resourceOwnership={activeVersion.resourceOwnership} duplicatePolicyVersion={this.props.duplicatePolicyVersion} key={item.name + '-' + item.version} _csrf={this.props._csrf} @@ -339,6 +389,9 @@ export class PolicyList extends React.Component { } isChild={false} timeZone={this.props.timeZone} + managedResourceIconStripMaxIcons={ + managedResourceIconStripMaxIcons + } /> ); }); @@ -430,6 +483,9 @@ export class PolicyList extends React.Component { cancel={this.onCancelDeletePolicy} submit={this.onSubmitDeletePolicy} errorMessage={this.state.errorMessage} + resourceOwnershipCliCommand={ + this.state.resourceOwnershipCliCommand + } message={ 'Are you sure you want to permanently delete the Policy ' } @@ -441,6 +497,9 @@ export class PolicyList extends React.Component { cancel={this.onCancelDuplicatePolicy} submit={this.onSubmitDuplicatePolicy} errorMessage={this.state.errorMessage} + resourceOwnershipCliCommand={ + this.state.resourceOwnershipCliCommand + } title={`Add version to ${this.state.duplicatePolicyName} based on version ${this.state.duplicateVersionSourceName}`} sections={sections} overflowY={'auto'} diff --git a/ui/src/components/policy/PolicyRow.js b/ui/src/components/policy/PolicyRow.js index 238aeba7aad..784704298e2 100644 --- a/ui/src/components/policy/PolicyRow.js +++ b/ui/src/components/policy/PolicyRow.js @@ -35,6 +35,33 @@ import { } from '../../redux/thunks/policies'; import { connect } from 'react-redux'; import { withRouter } from 'next/router'; +import ManagedResourceIcon from '../resource-ownership/ManagedResourceIcon'; +import { + isPolicyResourceManaged, + shouldOfferResourceOwnershipCli, +} from '../utils/resourceOwnership'; +import { + cliAddPolicyVersion, + cliDeletePolicyVersion, +} from '../utils/zmsCliCommands'; +import { roleIconStripMinWidthStyle } from '../utils/roleIconStrip'; + +const IconStrip = styled.span` + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + vertical-align: middle; + box-sizing: border-box; +`; + +const IconSlot = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.35em; + vertical-align: middle; +`; const TdStyled = styled.td` text-align: ${(props) => props.align}; @@ -166,6 +193,7 @@ export class PolicyRow extends React.Component { duplicatePolicyName: null, duplicateVersionSourceName: null, duplicateVersionName: null, + resourceOwnershipCliCommand: null, rowInVersionGroup: this.props.rowInVersionGroup, version: this.props.version, isActive: this.props.isActive, @@ -320,8 +348,22 @@ export class PolicyRow extends React.Component { ); }) .catch((err) => { + const errMsg = RequestUtils.xhrErrorCheckHelper(err); + const isPolicyManaged = isPolicyResourceManaged( + this.props.resourceOwnership + ); + let resourceOwnershipCliCommand = null; + if (shouldOfferResourceOwnershipCli(isPolicyManaged, err)) { + resourceOwnershipCliCommand = cliDeletePolicyVersion( + this.props.domain, + this.state.deletePolicyName, + this.state.deleteVersionName, + null + ); + } this.setState({ - errorMessage: RequestUtils.xhrErrorCheckHelper(err), + errorMessage: errMsg, + resourceOwnershipCliCommand, }); }); } @@ -345,8 +387,23 @@ export class PolicyRow extends React.Component { ); }) .catch((err) => { + const errMsg = RequestUtils.xhrErrorCheckHelper(err); + const isPolicyManaged = isPolicyResourceManaged( + this.props.resourceOwnership + ); + let resourceOwnershipCliCommand = null; + if (shouldOfferResourceOwnershipCli(isPolicyManaged, err)) { + resourceOwnershipCliCommand = cliAddPolicyVersion( + this.props.domain, + this.state.duplicatePolicyName, + this.state.duplicateVersionSourceName, + this.state.duplicateVersionName, + null + ); + } this.setState({ - errorMessage: RequestUtils.xhrErrorCheckHelper(err), + errorMessage: errMsg, + resourceOwnershipCliCommand, }); }); } @@ -356,6 +413,7 @@ export class PolicyRow extends React.Component { showDelete: false, deletePolicyName: null, deleteVersionName: null, + resourceOwnershipCliCommand: null, }); } @@ -364,6 +422,7 @@ export class PolicyRow extends React.Component { showDuplicatePolicyVersion: false, duplicatePolicyName: null, duplicateVersionName: null, + resourceOwnershipCliCommand: null, }); } @@ -373,6 +432,7 @@ export class PolicyRow extends React.Component { deletePolicyName: policyName, deleteVersionName: version, errorMessage: null, + resourceOwnershipCliCommand: null, }); } @@ -382,6 +442,7 @@ export class PolicyRow extends React.Component { duplicatePolicyName: policyName, duplicateVersionSourceName: version, errorMessage: null, + resourceOwnershipCliCommand: null, }); } @@ -444,6 +505,35 @@ export class PolicyRow extends React.Component { ); + const managedResourceIconStripMaxIcons = + this.props.managedResourceIconStripMaxIcons ?? 0; + const policyManagedResourceIcon = isPolicyResourceManaged( + this.props.resourceOwnership + ) ? ( + + ) : null; + let policyNameCell = + managedResourceIconStripMaxIcons > 0 ? ( + <> + + {policyManagedResourceIcon ? ( + {policyManagedResourceIcon} + ) : null} + + {displayName ? {' ' + displayName} : null} + + ) : ( + <> + {policyManagedResourceIcon} + {displayName} + + ); rows.push( {/*Policy*/} - {displayName} + {policyNameCell} {/*Versions*/} @@ -618,6 +708,9 @@ export class PolicyRow extends React.Component { cancel={this.onCancelDeletePolicyVersion} submit={this.onSubmitDeletePolicyVersion} errorMessage={this.state.errorMessage} + resourceOwnershipCliCommand={ + this.state.resourceOwnershipCliCommand + } message={ 'Are you sure you want to permanently delete the Policy ' } @@ -629,6 +722,9 @@ export class PolicyRow extends React.Component { cancel={this.onCancelDuplicatePolicyVersion} submit={this.onSubmitDuplicatePolicyVersion} errorMessage={this.state.errorMessage} + resourceOwnershipCliCommand={ + this.state.resourceOwnershipCliCommand + } title={`Add version to ${this.state.duplicatePolicyName} based on version ${this.state.duplicateVersionSourceName}`} sections={sections} overflowY={'auto'} @@ -647,6 +743,7 @@ export class PolicyRow extends React.Component { version={this.state.version} domain={this.props.domain} _csrf={this.props._csrf} + resourceOwnership={this.props.resourceOwnership} /> ); @@ -682,6 +779,7 @@ export class PolicyRow extends React.Component { isActive={false} domain={this.props.domain} modified={item.modified} + resourceOwnership={item.resourceOwnership} key={this.props.name + '-' + item.version} _csrf={this.props._csrf} onClickDeletePolicy={onClickDeletePolicyVersion} @@ -700,6 +798,9 @@ export class PolicyRow extends React.Component { isChild={true} router={this.props.router} timeZone={this.props.timeZone} + managedResourceIconStripMaxIcons={ + this.props.managedResourceIconStripMaxIcons + } /> ); }); diff --git a/ui/src/components/policy/PolicyRuleTable.js b/ui/src/components/policy/PolicyRuleTable.js index f0a7cb6feec..1e5b9752eb3 100644 --- a/ui/src/components/policy/PolicyRuleTable.js +++ b/ui/src/components/policy/PolicyRuleTable.js @@ -33,6 +33,14 @@ import { getPolicyVersion, } from '../../redux/thunks/policies'; import { connect } from 'react-redux'; +import { + isPolicyResourceManaged, + shouldOfferResourceOwnershipCli, +} from '../utils/resourceOwnership'; +import { + cliDeleteAssertionPolicyVersion, + formatAssertionWords, +} from '../utils/zmsCliCommands'; const StyleTable = styled.table` width: 100%; @@ -135,6 +143,8 @@ class PolicyRuleTable extends React.Component { addAssertion: false, assertions: this.props.assertions ? this.props.assertions : [], showDelete: false, + deleteAssertion: null, + resourceOwnershipCliCommand: null, }; } @@ -157,6 +167,7 @@ class PolicyRuleTable extends React.Component { showDelete: false, showSuccess, errorMessage: null, + resourceOwnershipCliCommand: null, }); // this is to close the success alert setTimeout( @@ -178,12 +189,12 @@ class PolicyRuleTable extends React.Component { this.setState({ showSuccess: false }); } - onClickDeleteAssertion(role, assertionId) { + onClickDeleteAssertion(assertion) { this.setState({ showDelete: true, - deleteAssertionRole: role, - deleteAssertionId: assertionId, + deleteAssertion: assertion, errorMessage: null, + resourceOwnershipCliCommand: null, }); } @@ -193,7 +204,7 @@ class PolicyRuleTable extends React.Component { this.props.domain, this.props.name, this.props.version, - this.state.deleteAssertionId, + this.state.deleteAssertion.id, this.props._csrf ) .then(() => { @@ -202,8 +213,31 @@ class PolicyRuleTable extends React.Component { ); }) .catch((err) => { + const errMsg = RequestUtils.xhrErrorCheckHelper(err); + const isPolicyManaged = isPolicyResourceManaged( + this.props.resourceOwnership + ); + let resourceOwnershipCliCommand = null; + if ( + shouldOfferResourceOwnershipCli(isPolicyManaged, err) && + this.state.deleteAssertion + ) { + const words = formatAssertionWords( + this.props.domain, + this.state.deleteAssertion + ); + resourceOwnershipCliCommand = + cliDeleteAssertionPolicyVersion( + this.props.domain, + this.props.name, + this.props.version, + words, + null + ); + } this.setState({ - errorMessage: RequestUtils.xhrErrorCheckHelper(err), + errorMessage: errMsg, + resourceOwnershipCliCommand, }); }); } @@ -211,9 +245,9 @@ class PolicyRuleTable extends React.Component { onCancelDeleteAssertion() { this.setState({ showDelete: false, - deleteAssertionRole: null, - deleteAssertionId: null, + deleteAssertion: null, errorMessage: null, + resourceOwnershipCliCommand: null, }); } @@ -225,8 +259,7 @@ class PolicyRuleTable extends React.Component { this.state.assertions.forEach((assertion, i) => { let onClickDeleteAssertion = this.onClickDeleteAssertion.bind( this, - assertion.role, - assertion.id + assertion ); let tempRole = NameUtils.getShortName( this.props.domain + ':role.', @@ -287,6 +320,7 @@ class PolicyRuleTable extends React.Component { _csrf={this.props._csrf} name={this.props.name} version={this.props.version} + resourceOwnership={this.props.resourceOwnership} /> ); } @@ -358,11 +392,18 @@ class PolicyRuleTable extends React.Component { ) : null} {this.state.showDelete ? ( + + + } + > + {label} +
+ ); +} + +const mapStateToProps = (state, ownProps) => ({ + resourceOwnershipUi: + ownProps.resourceOwnershipUi !== undefined + ? ownProps.resourceOwnershipUi + : selectResourceOwnershipUi(state), +}); + +export default connect(mapStateToProps)(ManagedResourceIcon); diff --git a/ui/src/components/resource-ownership/ResourceOwnershipCliSuggestion.js b/ui/src/components/resource-ownership/ResourceOwnershipCliSuggestion.js new file mode 100644 index 00000000000..21b96ef5aa9 --- /dev/null +++ b/ui/src/components/resource-ownership/ResourceOwnershipCliSuggestion.js @@ -0,0 +1,212 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import styled from '@emotion/styled'; +import { connect } from 'react-redux'; +import Icon from '../denali/icons/Icon'; +import { colors } from '../denali/styles'; +import { + selectResourceOwnershipGuideLink, + selectResourceOwnershipUi, +} from '../../redux/selectors/domains'; +import { + getCliSuggestionBody, + getCliSuggestionEmergencyHeading, + getCliSuggestionGuideFooter, +} from '../utils/resourceOwnershipUi'; + +const Wrap = styled.div` + text-align: left; + margin: 12px 0; + padding: 0 16px; + box-sizing: border-box; + font: 300 13px HelveticaNeue-Reg, Helvetica, Arial, sans-serif; +`; + +const BodyText = styled.div` + line-height: 1.45; + margin-bottom: 10px; +`; + +const CommandBox = styled.div` + display: flex; + align-items: stretch; + background: ${colors.grey200}; + border-radius: 2px; + margin: 8px 0 12px; + overflow: hidden; + border: 1px solid ${colors.grey500}; +`; + +const CommandPre = styled.pre` + flex: 1; + min-width: 0; + margin: 0; + padding: 10px 8px 10px 10px; + white-space: pre-wrap; + word-break: break-all; + font-family: Menlo, Monaco, Consolas, monospace; + font-size: 12px; + background: transparent; +`; + +const CopyHitArea = styled.button` + flex-shrink: 0; + border: none; + border-left: 1px solid ${colors.grey500}; + background: ${colors.grey200}; + padding: 6px 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: ${colors.grey700}; + &:hover { + background: ${colors.grey400}; + } + &:focus { + outline: 2px solid ${colors.blue400}; + outline-offset: -2px; + } +`; + +const GuideLink = styled.a` + color: ${colors.linkActive}; + text-decoration: none; + &:hover { + text-decoration: underline; + } +`; + +export class ResourceOwnershipCliSuggestion extends React.Component { + constructor(props) { + super(props); + this.copy = this.copy.bind(this); + this.state = { copied: false }; + this.copiedTimer = null; + } + + clearCopiedTimer() { + if (this.copiedTimer) { + clearTimeout(this.copiedTimer); + this.copiedTimer = null; + } + } + + copy() { + const cmd = this.props.command; + if (!cmd) { + return; + } + this.clearCopiedTimer(); + navigator.clipboard + .writeText(cmd) + .then(() => { + this.setState({ copied: true }); + this.copiedTimer = setTimeout(() => { + this.copiedTimer = null; + this.setState({ copied: false }); + }, 2000); + }) + .catch(() => { + // Clipboard unavailable or denied; command remains selectable in
+            });
+    }
+
+    componentWillUnmount() {
+        this.clearCopiedTimer();
+    }
+
+    render() {
+        if (!this.props.command) {
+            return null;
+        }
+        const guide = this.props.resourceOwnershipGuideLink || {};
+        const guideLabel = guide.title || 'resource-ownership-guide';
+        const guideTarget = guide.target || '_blank';
+        const ui = this.props.resourceOwnershipUi;
+        const bodyText = getCliSuggestionBody(ui);
+        const emergencyHeading = getCliSuggestionEmergencyHeading(ui);
+        const guideFooter = getCliSuggestionGuideFooter(ui);
+
+        return (
+            
+                {bodyText}
+                {emergencyHeading}
+                
+                    {this.props.command}
+                    
+                        {this.state.copied ? (
+                            
+                        ) : (
+                            
+                        )}
+                    
+                
+                
+                    {guideFooter}{' '}
+                    {guide.url ? (
+                        
+                            {guideLabel}
+                        
+                    ) : (
+                        guideLabel
+                    )}
+                    .
+                
+            
+        );
+    }
+}
+
+const mapStateToProps = (state, ownProps) => ({
+    resourceOwnershipGuideLink:
+        ownProps.resourceOwnershipGuideLink !== undefined
+            ? ownProps.resourceOwnershipGuideLink
+            : selectResourceOwnershipGuideLink(state),
+    resourceOwnershipUi:
+        ownProps.resourceOwnershipUi !== undefined
+            ? ownProps.resourceOwnershipUi
+            : selectResourceOwnershipUi(state),
+});
+
+export default connect(mapStateToProps)(ResourceOwnershipCliSuggestion);
diff --git a/ui/src/components/resource-ownership/ResourceOwnershipModalFeedback.js b/ui/src/components/resource-ownership/ResourceOwnershipModalFeedback.js
new file mode 100644
index 00000000000..4119efe9f40
--- /dev/null
+++ b/ui/src/components/resource-ownership/ResourceOwnershipModalFeedback.js
@@ -0,0 +1,38 @@
+/*
+ * Copyright The Athenz Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and limitations
+ * under the License.
+ */
+import React from 'react';
+import Color from '../denali/Color';
+import ResourceOwnershipCliSuggestion from './ResourceOwnershipCliSuggestion';
+
+/**
+ * Error area for modals: raw API error, or zms-cli suggestion when ownership blocks the change.
+ */
+export default function ResourceOwnershipModalFeedback({
+    errorMessage,
+    resourceOwnershipCliCommand,
+}) {
+    if (resourceOwnershipCliCommand) {
+        return (
+            
+        );
+    }
+    if (errorMessage) {
+        return {errorMessage};
+    }
+    return null;
+}
diff --git a/ui/src/components/role-policy/AddAssertionForRole.js b/ui/src/components/role-policy/AddAssertionForRole.js
index 98044ce5092..c4cbb6a89d0 100644
--- a/ui/src/components/role-policy/AddAssertionForRole.js
+++ b/ui/src/components/role-policy/AddAssertionForRole.js
@@ -20,8 +20,16 @@ import Button from '../denali/Button';
 import { colors } from '../denali/styles';
 import Color from '../denali/Color';
 import RequestUtils from '../utils/RequestUtils';
+import NameUtils from '../utils/NameUtils';
 import { addAssertion } from '../../redux/thunks/policies';
 import { connect } from 'react-redux';
+import { selectPolicy } from '../../redux/selectors/policies';
+import ResourceOwnershipCliSuggestion from '../resource-ownership/ResourceOwnershipCliSuggestion';
+import {
+    isPolicyResourceManaged,
+    shouldOfferResourceOwnershipCli,
+} from '../utils/resourceOwnership';
+import { cliAddAssertion, formatAssertionWords } from '../utils/zmsCliCommands';
 
 const StyledDiv = styled.div`
     background-color: ${colors.white};
@@ -47,6 +55,7 @@ class AddAssertionForRole extends React.Component {
         this.onSubmit = this.onSubmit.bind(this);
         this.state = {
             case: false,
+            resourceOwnershipCliCommand: null,
         };
     }
 
@@ -58,6 +67,7 @@ class AddAssertionForRole extends React.Component {
         if (!this.state.action || this.state.action === '') {
             this.setState({
                 errorMessage: 'Rule action is required.',
+                resourceOwnershipCliCommand: null,
             });
             return;
         }
@@ -65,6 +75,7 @@ class AddAssertionForRole extends React.Component {
         if (!this.state.resource || this.state.resource === '') {
             this.setState({
                 errorMessage: 'Rule resource is required.',
+                resourceOwnershipCliCommand: null,
             });
             return;
         }
@@ -81,14 +92,46 @@ class AddAssertionForRole extends React.Component {
                 this.props._csrf
             )
             .then(() => {
+                this.setState({ resourceOwnershipCliCommand: null });
                 this.props.submit(
                     `${this.props.name}-${this.props.role}-${this.state.resource}-${this.state.action}`,
                     false
                 );
             })
             .catch((err) => {
+                const errMsg = RequestUtils.xhrErrorCheckHelper(err);
+                const isPolicyManaged = isPolicyResourceManaged(
+                    this.props.resourceOwnership
+                );
+                let resourceOwnershipCliCommand = null;
+                if (shouldOfferResourceOwnershipCli(isPolicyManaged, err)) {
+                    const assertion = {
+                        effect: this.state.effect,
+                        action: this.state.action.trim(),
+                        role: NameUtils.getRoleAssertionName(
+                            this.props.role,
+                            this.props.domain
+                        ),
+                        resource: NameUtils.getResourceName(
+                            this.state.resource,
+                            this.props.domain
+                        ),
+                    };
+                    const words = formatAssertionWords(
+                        this.props.domain,
+                        assertion
+                    );
+                    resourceOwnershipCliCommand = cliAddAssertion(
+                        this.props.domain,
+                        this.props.name,
+                        words,
+                        null,
+                        !!this.state.case
+                    );
+                }
                 this.setState({
-                    errorMessage: RequestUtils.xhrErrorCheckHelper(err),
+                    errorMessage: errMsg,
+                    resourceOwnershipCliCommand,
                 });
             });
     }
@@ -101,11 +144,21 @@ class AddAssertionForRole extends React.Component {
                     onChange={this.onChange}
                     domain={this.props.domain}
                 />
-                {this.state.errorMessage && (
+                {this.state.errorMessage &&
+                    !this.state.resourceOwnershipCliCommand && (
+                        
+                            
+                                {this.state.errorMessage}
+                            
+                        
+                    )}
+                {this.state.resourceOwnershipCliCommand ? (
                     
-                        {this.state.errorMessage}
+                        
                     
-                )}
+                ) : null}
                 
                     
                         Submit
@@ -144,4 +197,14 @@ const mapDispatchToProps = (dispatch) => ({
         ),
 });
 
-export default connect(null, mapDispatchToProps)(AddAssertionForRole);
+const mapStateToProps = (state, props) => {
+    const policy = selectPolicy(state, props.domain, props.name);
+    return {
+        resourceOwnership: policy && policy.resourceOwnership,
+    };
+};
+
+export default connect(
+    mapStateToProps,
+    mapDispatchToProps
+)(AddAssertionForRole);
diff --git a/ui/src/components/role-policy/AddRuleFormForRole.js b/ui/src/components/role-policy/AddRuleFormForRole.js
index 289028c44cc..e6de9f9d034 100644
--- a/ui/src/components/role-policy/AddRuleFormForRole.js
+++ b/ui/src/components/role-policy/AddRuleFormForRole.js
@@ -138,7 +138,7 @@ export default class AddRuleFormForRole extends React.Component {
                     
                     
                         
                     
                         
                     
                         
                         
diff --git a/ui/src/components/role-policy/RolePolicyList.js b/ui/src/components/role-policy/RolePolicyList.js
index 640953b243b..41ae4e89777 100644
--- a/ui/src/components/role-policy/RolePolicyList.js
+++ b/ui/src/components/role-policy/RolePolicyList.js
@@ -22,6 +22,11 @@ import DeleteModal from '../modal/DeleteModal';
 import NameUtils from '../utils/NameUtils';
 import { MODAL_TIME_OUT } from '../constants/constants';
 import RequestUtils from '../utils/RequestUtils';
+import {
+    isPolicyResourceManaged,
+    shouldOfferResourceOwnershipCli,
+} from '../utils/resourceOwnership';
+import { cliDeletePolicy } from '../utils/zmsCliCommands';
 import { selectActivePoliciesOnly } from '../../redux/selectors/policies';
 import { deletePolicy } from '../../redux/thunks/policies';
 import { connect } from 'react-redux';
@@ -61,6 +66,7 @@ class RolePolicyList extends React.Component {
         this.state = {
             list: props.policies ? this.filterPolicies() : [],
             showAddPolicy: false,
+            resourceOwnershipCliCommand: null,
         };
     }
 
@@ -78,8 +84,26 @@ class RolePolicyList extends React.Component {
                 );
             })
             .catch((err) => {
+                const errMsg = RequestUtils.xhrErrorCheckHelper(err);
+                const policyEntry = this.state.list.find(
+                    (p) =>
+                        NameUtils.getShortName(':policy.', p.name) ===
+                        this.state.deletePolicyName
+                );
+                const isPolicyManaged = isPolicyResourceManaged(
+                    policyEntry && policyEntry.resourceOwnership
+                );
+                let resourceOwnershipCliCommand = null;
+                if (shouldOfferResourceOwnershipCli(isPolicyManaged, err)) {
+                    resourceOwnershipCliCommand = cliDeletePolicy(
+                        this.props.domain,
+                        this.state.deletePolicyName,
+                        null
+                    );
+                }
                 this.setState({
-                    errorMessage: RequestUtils.xhrErrorCheckHelper(err),
+                    errorMessage: errMsg,
+                    resourceOwnershipCliCommand,
                 });
             });
     }
@@ -89,6 +113,7 @@ class RolePolicyList extends React.Component {
             showDelete: false,
             deletePolicyName: null,
             errorMessage: null,
+            resourceOwnershipCliCommand: null,
         });
     }
 
@@ -97,6 +122,7 @@ class RolePolicyList extends React.Component {
             showDelete: true,
             deletePolicyName: policyName,
             errorMessage: null,
+            resourceOwnershipCliCommand: null,
         });
     }
 
@@ -141,6 +167,7 @@ class RolePolicyList extends React.Component {
             showSuccess,
             successMessage,
             showDelete: false,
+            resourceOwnershipCliCommand: null,
         });
         // this is to close the success alert
         setTimeout(
@@ -238,6 +265,9 @@ class RolePolicyList extends React.Component {
                         cancel={this.onCancelDeletePolicy}
                         submit={this.onSubmitDeletePolicy}
                         errorMessage={this.state.errorMessage}
+                        resourceOwnershipCliCommand={
+                            this.state.resourceOwnershipCliCommand
+                        }
                         message={
                             'Are you sure you want to permanently delete the Policy '
                         }
@@ -257,8 +287,8 @@ const mapStateToProps = (state, props) => {
 };
 
 const mapDispatchToProps = (dispatch) => ({
-    deletePolicy: (domainName, roleName) =>
-        dispatch(deletePolicy(domainName, roleName)),
+    deletePolicy: (domainName, policyName, _csrf) =>
+        dispatch(deletePolicy(domainName, policyName, _csrf)),
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(RolePolicyList);
diff --git a/ui/src/components/role-policy/RolePolicyRow.js b/ui/src/components/role-policy/RolePolicyRow.js
index bb409bb403f..d278db3661a 100644
--- a/ui/src/components/role-policy/RolePolicyRow.js
+++ b/ui/src/components/role-policy/RolePolicyRow.js
@@ -19,10 +19,31 @@ import { colors } from '../denali/styles';
 import styled from '@emotion/styled';
 import DateUtils from '../utils/DateUtils';
 import { css, keyframes } from '@emotion/react';
-import { selectPolicyAssertions } from '../../redux/selectors/policies';
-import { deletePolicy } from '../../redux/thunks/policies';
+import {
+    selectPolicy,
+    selectPolicyAssertions,
+} from '../../redux/selectors/policies';
 import { connect } from 'react-redux';
 import RolePolicyRuleTable from './RolePolicyRuleTable';
+import ManagedResourceIcon from '../resource-ownership/ManagedResourceIcon';
+import { isPolicyResourceManaged } from '../utils/resourceOwnership';
+
+const IconStrip = styled.span`
+    display: inline-flex;
+    align-items: center;
+    gap: 2px;
+    flex-shrink: 0;
+    vertical-align: middle;
+    box-sizing: border-box;
+`;
+
+const IconSlot = styled.span`
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 1.35em;
+    vertical-align: middle;
+`;
 
 const TdStyled = styled.td`
     background-color: ${(props) => props.color};
@@ -106,6 +127,11 @@ class RolePolicyRow extends React.Component {
         const arrowup = 'arrowhead-up-circle-solid';
         const arrowdown = 'arrowhead-down-circle';
         let id = this.props.id;
+        const policyManagedResourceIcon = isPolicyResourceManaged(
+            this.props.resourceOwnership
+        ) ? (
+            
+        ) : null;
         if (this.state.assertions) {
             rows.push(
                 
                             
+                            {policyManagedResourceIcon ? (
+                                
+                                    
+                                        {policyManagedResourceIcon}
+                                    
+                                
+                            ) : null}
+                            {policyManagedResourceIcon ? ' ' : ''}
                             {this.state.name}
                         
                         
@@ -162,6 +196,12 @@ class RolePolicyRow extends React.Component {
                                 verticalAlign={'text-bottom'}
                             />
                         
+                        {policyManagedResourceIcon ? (
+                            
+                                {policyManagedResourceIcon}
+                            
+                        ) : null}
+                        {policyManagedResourceIcon ? ' ' : ''}
                         {this.state.name}
                     
                 
@@ -172,15 +212,12 @@ class RolePolicyRow extends React.Component {
 }
 
 const mapStateToProps = (state, props) => {
+    const policy = selectPolicy(state, props.domain, props.name);
     return {
         ...props,
         assertions: selectPolicyAssertions(state, props.domain, props.name),
+        resourceOwnership: policy && policy.resourceOwnership,
     };
 };
 
-const mapDispatchToProps = (dispatch) => ({
-    deletePolicy: (domainName, roleName) =>
-        dispatch(deletePolicy(domainName, roleName)),
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(RolePolicyRow);
+export default connect(mapStateToProps)(RolePolicyRow);
diff --git a/ui/src/components/role-policy/RolePolicyRuleTable.js b/ui/src/components/role-policy/RolePolicyRuleTable.js
index a35f56237e4..057a7729f71 100644
--- a/ui/src/components/role-policy/RolePolicyRuleTable.js
+++ b/ui/src/components/role-policy/RolePolicyRuleTable.js
@@ -27,9 +27,20 @@ import {
 import RequestUtils from '../utils/RequestUtils';
 import NameUtils from '../utils/NameUtils';
 import { css, keyframes } from '@emotion/react';
-import { selectPolicyAssertions } from '../../redux/selectors/policies';
+import {
+    selectPolicy,
+    selectPolicyAssertions,
+} from '../../redux/selectors/policies';
 import { deleteAssertion } from '../../redux/thunks/policies';
 import { connect } from 'react-redux';
+import {
+    isPolicyResourceManaged,
+    shouldOfferResourceOwnershipCli,
+} from '../utils/resourceOwnership';
+import {
+    cliDeleteAssertion,
+    formatAssertionWords,
+} from '../utils/zmsCliCommands';
 import AddAssertionForRole from './AddAssertionForRole';
 
 const StyleTable = styled.table`
@@ -128,6 +139,7 @@ class RolePolicyRuleTable extends React.Component {
             addAssertion: false,
             assertions: this.props.assertions ? this.props.assertions : [],
             showDelete: false,
+            resourceOwnershipCliCommand: null,
         };
     }
 
@@ -143,6 +155,7 @@ class RolePolicyRuleTable extends React.Component {
             showDelete: false,
             showSuccess,
             errorMessage: null,
+            resourceOwnershipCliCommand: null,
         });
         // this is to close the success alert
         setTimeout(
@@ -164,6 +177,7 @@ class RolePolicyRuleTable extends React.Component {
             deleteAssertionRole: role,
             deleteAssertionId: assertionId,
             errorMessage: null,
+            resourceOwnershipCliCommand: null,
         });
     }
 
@@ -183,8 +197,32 @@ class RolePolicyRuleTable extends React.Component {
                 );
             })
             .catch((err) => {
+                const errMsg = RequestUtils.xhrErrorCheckHelper(err);
+                const isPolicyManaged = isPolicyResourceManaged(
+                    this.props.resourceOwnership
+                );
+                let resourceOwnershipCliCommand = null;
+                const assertion = this.state.assertions.find(
+                    (a) => a.id === this.state.deleteAssertionId
+                );
+                if (
+                    shouldOfferResourceOwnershipCli(isPolicyManaged, err) &&
+                    assertion
+                ) {
+                    const words = formatAssertionWords(
+                        this.props.domain,
+                        assertion
+                    );
+                    resourceOwnershipCliCommand = cliDeleteAssertion(
+                        this.props.domain,
+                        this.props.name,
+                        words,
+                        null
+                    );
+                }
                 this.setState({
-                    errorMessage: RequestUtils.xhrErrorCheckHelper(err),
+                    errorMessage: errMsg,
+                    resourceOwnershipCliCommand,
                 });
             });
     }
@@ -195,6 +233,7 @@ class RolePolicyRuleTable extends React.Component {
             deleteAssertionRole: null,
             deleteAssertionId: null,
             errorMessage: null,
+            resourceOwnershipCliCommand: null,
         });
     }
 
@@ -352,6 +391,9 @@ class RolePolicyRuleTable extends React.Component {
                         cancel={this.onCancelDeleteAssertion}
                         submit={this.onSubmitDeleteAssertion}
                         errorMessage={this.state.errorMessage}
+                        resourceOwnershipCliCommand={
+                            this.state.resourceOwnershipCliCommand
+                        }
                         message={
                             'Are you sure you want to permanently delete the assertion with Role '
                         }
@@ -363,9 +405,11 @@ class RolePolicyRuleTable extends React.Component {
 }
 
 const mapStateToProps = (state, props) => {
+    const policy = selectPolicy(state, props.domain, props.name);
     return {
         ...props,
         assertions: selectPolicyAssertions(state, props.domain, props.name),
+        resourceOwnership: policy && policy.resourceOwnership,
     };
 };
 
diff --git a/ui/src/components/role/RoleGroup.js b/ui/src/components/role/RoleGroup.js
index 8c7ae03051d..fd18ccf0793 100644
--- a/ui/src/components/role/RoleGroup.js
+++ b/ui/src/components/role/RoleGroup.js
@@ -133,6 +133,7 @@ export default class RoleGroup extends React.Component {
                                 timeZone={this.props.timeZone}
                                 _csrf={this.props._csrf}
                                 newRole={this.props.newRole}
+                                iconStripMaxIcons={this.props.iconStripMaxIcons}
                             />
                         );
                     });
diff --git a/ui/src/components/role/RoleRow.js b/ui/src/components/role/RoleRow.js
index b762b95fd89..c2f75a043f2 100644
--- a/ui/src/components/role/RoleRow.js
+++ b/ui/src/components/role/RoleRow.js
@@ -29,11 +29,19 @@ import { connect } from 'react-redux';
 import { selectDomainAuditEnabled } from '../../redux/selectors/domainData';
 import { isReviewRequired } from '../utils/ReviewUtils';
 import { onClickNewTabFunction } from '../utils/PageUtils';
+import ManagedResourceIcon from '../resource-ownership/ManagedResourceIcon';
+import {
+    isRoleResourceMembersManaged,
+    isRoleResourceMetaManaged,
+    resolveResourceOwnershipCliOnError,
+} from '../utils/resourceOwnership';
+import { cliDeleteRole } from '../utils/zmsCliCommands';
+import { roleIconStripMinWidthStyle } from '../utils/roleIconStrip';
 
 const TDStyledName = styled.div`
     background-color: ${(props) => props.color};
     text-align: ${(props) => props.align};
-    padding: 5px 0 5px 15px;
+    padding: 5px 0 5px 0px;
     vertical-align: middle;
     word-break: break-all;
     width: 28%;
@@ -42,16 +50,16 @@ const TDStyledName = styled.div`
 const TDStyledTime = styled.div`
     background-color: ${(props) => props.color};
     text-align: ${(props) => props.align};
-    padding: 5px 0 5px 15px;
+    padding: 5px 0 5px 0px;
     vertical-align: middle;
     word-break: break-all;
-    width: 16%;
+    width: 15%;
 `;
 
 const TDStyledIcon = styled.div`
     background-color: ${(props) => props.color};
     text-align: ${(props) => props.align};
-    padding: 5px 0 5px 15px;
+    padding: 5px 0 5px 0px;
     vertical-align: middle;
     word-break: break-all;
     width: 7%;
@@ -91,8 +99,21 @@ const MenuDiv = styled.div`
     font-size: 12px;
 `;
 
-const LeftSpan = styled.span`
-    padding-left: 20px;
+const IconStrip = styled.span`
+    display: inline-flex;
+    align-items: center;
+    gap: 2px;
+    flex-shrink: 0;
+    vertical-align: middle;
+    box-sizing: border-box;
+`;
+
+const IconSlot = styled.span`
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 1.35em;
+    vertical-align: middle;
 `;
 
 class RoleRow extends React.Component {
@@ -104,6 +125,7 @@ class RoleRow extends React.Component {
         this.state = {
             name: NameUtils.getShortName(':role.', this.props.details.name),
             showDelete: false,
+            resourceOwnershipCliCommand: null,
         };
         this.localDate = new DateUtils();
     }
@@ -146,14 +168,31 @@ class RoleRow extends React.Component {
                     deleteName: null,
                     deleteJustification: null,
                     errorMessage: null,
+                    resourceOwnershipCliCommand: null,
                 });
                 this.props.onUpdateSuccess(
                     `Successfully deleted role ${roleName}`
                 );
             })
             .catch((err) => {
+                const role = this.props.details;
+                const isRoleManaged =
+                    isRoleResourceMetaManaged(role.resourceOwnership) ||
+                    isRoleResourceMembersManaged(role.resourceOwnership);
+                const aud =
+                    this.props.justificationRequired &&
+                    this.state.deleteJustification
+                        ? this.state.deleteJustification
+                        : null;
                 this.setState({
                     errorMessage: RequestUtils.xhrErrorCheckHelper(err),
+                    resourceOwnershipCliCommand:
+                        resolveResourceOwnershipCliOnError(
+                            isRoleManaged,
+                            err,
+                            () =>
+                                cliDeleteRole(this.props.domain, roleName, aud)
+                        ),
                 });
             });
     }
@@ -163,6 +202,7 @@ class RoleRow extends React.Component {
             showDelete: false,
             deleteName: '',
             errorMessage: null,
+            resourceOwnershipCliCommand: null,
         });
     }
 
@@ -287,18 +327,18 @@ class RoleRow extends React.Component {
 
         let reviewRequired = isReviewRequired(role);
 
-        let roleTypeIcon = role.trust ? iconDelegated : '';
         let roleDescriptionIcon = role.description ? iconDescription : '';
-        let roleAuditIcon = auditEnabled ? iconAudit : '';
 
-        let roleNameSpan =
-            roleTypeIcon === '' && roleAuditIcon === '' ? (
-                {' ' + this.state.name}
-            ) : (
-                {' ' + this.state.name}
-            );
+        let roleTypeIcon = role.trust ? iconDelegated : null;
+        let roleAuditIcon = auditEnabled ? iconAudit : null;
+        let roleManagedResourceIcon = isRoleResourceMetaManaged(
+            role.resourceOwnership
+        ) ? (
+            
+        ) : null;
         let newRole =
             this.props.newRole === this.props.domain + '-' + this.state.name;
+        const iconStripMaxIcons = this.props.iconStripMaxIcons ?? 3;
         rows.push(
             
                 
-                    {roleTypeIcon}
-                    {roleAuditIcon}
-                    {roleNameSpan} {roleDescriptionIcon}
+                    
+                        {roleTypeIcon ? (
+                            {roleTypeIcon}
+                        ) : null}
+                        {roleAuditIcon ? (
+                            {roleAuditIcon}
+                        ) : null}
+                        {roleManagedResourceIcon ? (
+                            {roleManagedResourceIcon}
+                        ) : null}
+                    
+                    {' ' + this.state.name} {roleDescriptionIcon}
                 
                 
                     {this.localDate.getLocalDate(
@@ -492,6 +546,9 @@ class RoleRow extends React.Component {
                     }
                     onJustification={this.saveJustification}
                     errorMessage={this.state.errorMessage}
+                    resourceOwnershipCliCommand={
+                        this.state.resourceOwnershipCliCommand
+                    }
                 />
             );
         }
diff --git a/ui/src/components/role/RoleSectionRow.js b/ui/src/components/role/RoleSectionRow.js
index 7b9da212ec4..94bc0bc8930 100644
--- a/ui/src/components/role/RoleSectionRow.js
+++ b/ui/src/components/role/RoleSectionRow.js
@@ -26,7 +26,16 @@ import { withRouter } from 'next/router';
 import { css, keyframes } from '@emotion/react';
 import { deleteRole } from '../../redux/thunks/roles';
 import { connect } from 'react-redux';
+import { selectDomainAuditEnabled } from '../../redux/selectors/domainData';
 import { onClickNewTabFunction } from '../utils/PageUtils';
+import ManagedResourceIcon from '../resource-ownership/ManagedResourceIcon';
+import {
+    isRoleResourceMembersManaged,
+    isRoleResourceMetaManaged,
+    shouldOfferResourceOwnershipCli,
+} from '../utils/resourceOwnership';
+import { cliDeleteRole } from '../utils/zmsCliCommands';
+import { roleIconStripMinWidthStyle } from '../utils/roleIconStrip';
 
 const TDName = styled.div`
     background-color: ${(props) => props.color};
@@ -71,8 +80,10 @@ const TDIcon = styled.div`
 `;
 
 const TrStyled = styled.div`
+    box-sizing: border-box;
     background-color: ${(props) => props.color};
     display: flex;
+    padding-left: 15px;
     ${(props) =>
         props.isSuccess === true &&
         css`
@@ -96,8 +107,21 @@ const MenuDiv = styled.div`
     font-size: 12px;
 `;
 
-const LeftSpan = styled.span`
-    padding-left: 20px;
+const IconStrip = styled.span`
+    display: inline-flex;
+    align-items: center;
+    gap: 2px;
+    flex-shrink: 0;
+    vertical-align: middle;
+    box-sizing: border-box;
+`;
+
+const IconSlot = styled.span`
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 1.35em;
+    vertical-align: middle;
 `;
 
 class RoleSectionRow extends React.Component {
@@ -111,6 +135,7 @@ class RoleSectionRow extends React.Component {
                 this.props.details.roleName ||
                 NameUtils.getShortName(':role.', this.props.details.name),
             showDelete: false,
+            resourceOwnershipCliCommand: null,
         };
         this.localDate = new DateUtils();
     }
@@ -157,14 +182,33 @@ class RoleSectionRow extends React.Component {
                     deleteName: null,
                     deleteJustification: null,
                     errorMessage: null,
+                    resourceOwnershipCliCommand: null,
                 });
                 this.props.onUpdateSuccess(
                     `Successfully deleted role ${roleName}`
                 );
             })
             .catch((err) => {
+                const role = this.props.details;
+                const isRoleManaged =
+                    isRoleResourceMetaManaged(role.resourceOwnership) ||
+                    isRoleResourceMembersManaged(role.resourceOwnership);
+                let resourceOwnershipCliCommand = null;
+                if (shouldOfferResourceOwnershipCli(isRoleManaged, err)) {
+                    const aud =
+                        this.props.justificationRequired &&
+                        this.state.deleteJustification
+                            ? this.state.deleteJustification
+                            : null;
+                    resourceOwnershipCliCommand = cliDeleteRole(
+                        this.props.domain,
+                        roleName,
+                        aud
+                    );
+                }
                 this.setState({
                     errorMessage: RequestUtils.xhrErrorCheckHelper(err),
+                    resourceOwnershipCliCommand,
                 });
             });
     }
@@ -174,6 +218,7 @@ class RoleSectionRow extends React.Component {
             showDelete: false,
             deleteName: '',
             errorMessage: null,
+            resourceOwnershipCliCommand: null,
         });
     }
 
@@ -295,24 +340,38 @@ class RoleSectionRow extends React.Component {
             
         );
 
-        let roleTypeIcon = role.trust ? iconDelegated : '';
         let roleDescriptionIcon = role.description ? iconDescription : '';
-        let roleAuditIcon = auditEnabled ? iconAudit : '';
 
-        let roleNameSpan =
-            roleTypeIcon === '' && roleAuditIcon === '' ? (
-                {' ' + this.state.name}
-            ) : (
-                {' ' + this.state.name}
-            );
+        let roleTypeIcon = role.trust ? iconDelegated : null;
+        let roleAuditIcon = auditEnabled ? iconAudit : null;
+        let roleManagedResourceIcon = isRoleResourceMetaManaged(
+            role.resourceOwnership
+        ) ? (
+            
+        ) : null;
+        const iconStripStyle = {
+            minWidth: roleIconStripMinWidthStyle(
+                this.props.iconStripMaxIcons ?? 3
+            ),
+        };
         if (this.props.category === 'group-roles') {
             let category = 'group';
             rows.push(
                 
                     
-                        {roleTypeIcon}
-                        {roleAuditIcon}
-                        {roleNameSpan} {roleDescriptionIcon}
+                        
+                            {roleTypeIcon ? (
+                                {roleTypeIcon}
+                            ) : null}
+                            {roleAuditIcon ? (
+                                {roleAuditIcon}
+                            ) : null}
+                            {roleManagedResourceIcon ? (
+                                {roleManagedResourceIcon}
+                            ) : null}
+                        
+                        {' ' + this.state.name}{' '}
+                        {roleDescriptionIcon}
                     
                     
                         {role.expiration
@@ -356,9 +415,19 @@ class RoleSectionRow extends React.Component {
                     isSuccess={newRole}
                 >
                     
-                        {roleTypeIcon}
-                        {roleAuditIcon}
-                        {roleNameSpan} {roleDescriptionIcon}
+                        
+                            {roleTypeIcon ? (
+                                {roleTypeIcon}
+                            ) : null}
+                            {roleAuditIcon ? (
+                                {roleAuditIcon}
+                            ) : null}
+                            {roleManagedResourceIcon ? (
+                                {roleManagedResourceIcon}
+                            ) : null}
+                        
+                        {' ' + this.state.name}{' '}
+                        {roleDescriptionIcon}
                     
                     
                         {this.localDate.getLocalDate(
@@ -528,6 +597,9 @@ class RoleSectionRow extends React.Component {
                     }
                     onJustification={this.saveJustification}
                     errorMessage={this.state.errorMessage}
+                    resourceOwnershipCliCommand={
+                        this.state.resourceOwnershipCliCommand
+                    }
                 />
             );
         }
@@ -535,9 +607,17 @@ class RoleSectionRow extends React.Component {
     }
 }
 
+const mapStateToProps = (state, props) => ({
+    ...props,
+    justificationRequired: selectDomainAuditEnabled(state),
+});
+
 const mapDispatchToProps = (dispatch) => ({
     deleteRole: (roleName, auditRef, _csrf) =>
         dispatch(deleteRole(roleName, auditRef, _csrf)),
 });
 
-export default connect(null, mapDispatchToProps)(withRouter(RoleSectionRow));
+export default connect(
+    mapStateToProps,
+    mapDispatchToProps
+)(withRouter(RoleSectionRow));
diff --git a/ui/src/components/role/RoleTable.js b/ui/src/components/role/RoleTable.js
index 0b5ac7d0393..eefb9ea70bf 100644
--- a/ui/src/components/role/RoleTable.js
+++ b/ui/src/components/role/RoleTable.js
@@ -18,6 +18,7 @@ import styled from '@emotion/styled';
 import RoleRow from './RoleRow';
 //import 'denali-css/css/denali.css';
 import RoleGroup from './RoleGroup';
+import { maxRoleVisibleIconCount } from '../utils/roleIconStrip';
 
 const StyleTable = styled.div`
     width: 100%;
@@ -104,6 +105,9 @@ export default class RoleTable extends React.Component {
         const { domain } = this.props;
         const adminRole = domain + ':role.admin';
         let rows = [];
+        const tableIconStripMax = maxRoleVisibleIconCount(
+            this.props.roles || []
+        );
         if (this.props.roles && this.props.roles.length > 0) {
             let remainingRows = this.props.roles.filter((item) => {
                 if (item.name === adminRole) {
@@ -140,6 +144,7 @@ export default class RoleTable extends React.Component {
                             onUpdateSuccess={this.props.onSubmit}
                             timeZone={this.props.timeZone}
                             _csrf={this.props._csrf}
+                            iconStripMaxIcons={tableIconStripMax}
                         />
                     );
                 });
@@ -148,16 +153,20 @@ export default class RoleTable extends React.Component {
 
             if (this.state.rows) {
                 for (let name in this.state.rows) {
+                    const groupRoles = this.state.rows[name];
+                    const groupIconStripMax =
+                        maxRoleVisibleIconCount(groupRoles);
                     let roleGroup = (
                         
                     );
 
@@ -180,6 +189,7 @@ export default class RoleTable extends React.Component {
                             timeZone={this.props.timeZone}
                             _csrf={this.props._csrf}
                             newRole={this.props.newRole}
+                            iconStripMaxIcons={tableIconStripMax}
                         />
                     );
                 });
diff --git a/ui/src/components/service/AddKey.js b/ui/src/components/service/AddKey.js
index d61cb0b22dd..d92491ce8bb 100644
--- a/ui/src/components/service/AddKey.js
+++ b/ui/src/components/service/AddKey.js
@@ -21,9 +21,15 @@ import { colors } from '../denali/styles';
 import Button from '../denali/Button';
 import Color from '../denali/Color';
 import RequestUtils from '../utils/RequestUtils';
+import ResourceOwnershipCliSuggestion from '../resource-ownership/ResourceOwnershipCliSuggestion';
+import {
+    isServiceResourcePublicKeysManaged,
+    shouldOfferResourceOwnershipCli,
+} from '../utils/resourceOwnership';
+import { cliAddPublicKey } from '../utils/zmsCliCommands';
 import { selectIsLoading } from '../../redux/selectors/loading';
 import { selectServices } from '../../redux/selectors/services';
-import { addKey, deleteService } from '../../redux/thunks/services';
+import { addKey } from '../../redux/thunks/services';
 import { connect } from 'react-redux';
 
 const SectionsDiv = styled.div`
@@ -61,6 +67,7 @@ class AddKey extends React.Component {
         if (!this.state.keyId || this.state.keyId === '') {
             this.setState({
                 errorMessage: 'Key Id is required.',
+                resourceOwnershipCliCommand: null,
             });
             return;
         }
@@ -68,6 +75,7 @@ class AddKey extends React.Component {
         if (!this.state.keyValue || this.state.keyValue === '') {
             this.setState({
                 errorMessage: 'Key Value is required.',
+                resourceOwnershipCliCommand: null,
             });
             return;
         }
@@ -83,14 +91,30 @@ class AddKey extends React.Component {
                 this.props._csrf
             )
             .then(() => {
+                this.setState({ resourceOwnershipCliCommand: null });
                 this.props.onSubmit(
                     `${this.state.keyId}-${this.props.service}`,
                     false
                 );
             })
             .catch((err) => {
+                const errMsg = RequestUtils.xhrErrorCheckHelper(err);
+                const isPublicKeysManaged = isServiceResourcePublicKeysManaged(
+                    this.props.resourceOwnership
+                );
+                let resourceOwnershipCliCommand = null;
+                if (shouldOfferResourceOwnershipCli(isPublicKeysManaged, err)) {
+                    resourceOwnershipCliCommand = cliAddPublicKey(
+                        this.props.domain,
+                        this.props.service,
+                        this.state.keyId,
+                        '',
+                        null
+                    );
+                }
                 this.setState({
-                    errorMessage: RequestUtils.xhrErrorCheckHelper(err),
+                    errorMessage: errMsg,
+                    resourceOwnershipCliCommand,
                 });
             });
     }
@@ -106,11 +130,21 @@ class AddKey extends React.Component {
                     domain={this.props.domain}
                     onChange={this.onChange}
                 />
-                {this.state.errorMessage && (
+                {this.state.errorMessage &&
+                    !this.state.resourceOwnershipCliCommand && (
+                        
+                            
+                                {this.state.errorMessage}
+                            
+                        
+                    )}
+                {this.state.resourceOwnershipCliCommand ? (
                     
-                        {this.state.errorMessage}
+                        
                     
-                )}
+                ) : null}
                 
                     
                         
diff --git a/ui/src/components/service/InstanceList.js b/ui/src/components/service/InstanceList.js
index a2f0e6f0bb2..c64ca40017b 100644
--- a/ui/src/components/service/InstanceList.js
+++ b/ui/src/components/service/InstanceList.js
@@ -21,7 +21,10 @@ import InstanceTable from './InstanceTable';
 import AddStaticInstances from '../microsegmentation/AddStaticInstances';
 import InputDropdown from '../denali/InputDropdown';
 import { selectTimeZone } from '../../redux/selectors/domains';
-import { selectInstancesWorkLoadData } from '../../redux/selectors/services';
+import {
+    selectInstancesWorkLoadData,
+    selectService,
+} from '../../redux/selectors/services';
 import { connect } from 'react-redux';
 
 const InstanceSectionDiv = styled.div`
@@ -158,6 +161,7 @@ class InstanceList extends React.Component {
                 _csrf={this.props._csrf}
                 showAddInstance={this.state.showAddInstance}
                 service={this.props.service}
+                resourceOwnership={this.props.resourceOwnership}
             />
         ) : (
             ''
@@ -241,6 +245,8 @@ const mapStateToProps = (state, props) => {
             props.category
         ),
         timeZone: selectTimeZone(state),
+        resourceOwnership: selectService(state, props.domain, props.service)
+            .resourceOwnership,
     };
 };
 
diff --git a/ui/src/components/service/PublicKeyTable.js b/ui/src/components/service/PublicKeyTable.js
index ff36b10de78..95c0cd8af89 100644
--- a/ui/src/components/service/PublicKeyTable.js
+++ b/ui/src/components/service/PublicKeyTable.js
@@ -27,9 +27,15 @@ import { css, keyframes } from '@emotion/react';
 import { deleteKey } from '../../redux/thunks/services';
 import { connect } from 'react-redux';
 import {
+    selectService,
     selectServiceDescription,
     selectServicePublicKeys,
 } from '../../redux/selectors/services';
+import {
+    isServiceResourcePublicKeysManaged,
+    resolveResourceOwnershipCliOnError,
+} from '../utils/resourceOwnership';
+import { cliDeletePublicKey } from '../utils/zmsCliCommands';
 import AddKey from './AddKey';
 
 const HeaderDiv = styled.div`
@@ -167,6 +173,7 @@ class PublicKeyTable extends React.Component {
             showDelete: true,
             deleteKeyId: keyId,
             errorMessage: null,
+            resourceOwnershipCliCommand: null,
         });
     }
 
@@ -183,10 +190,26 @@ class PublicKeyTable extends React.Component {
                     `Successfully deleted key id ${this.state.deleteKeyId} from service ${this.props.service}`,
                     true
                 );
+                this.setState({ resourceOwnershipCliCommand: null });
             })
             .catch((err) => {
+                const errMsg = RequestUtils.xhrErrorCheckHelper(err);
                 this.setState({
-                    errorMessage: RequestUtils.xhrErrorCheckHelper(err),
+                    errorMessage: errMsg,
+                    resourceOwnershipCliCommand:
+                        resolveResourceOwnershipCliOnError(
+                            isServiceResourcePublicKeysManaged(
+                                this.props.resourceOwnership
+                            ),
+                            err,
+                            () =>
+                                cliDeletePublicKey(
+                                    this.props.domain,
+                                    this.props.service,
+                                    this.state.deleteKeyId,
+                                    null
+                                )
+                        ),
                 });
             });
     }
@@ -195,18 +218,11 @@ class PublicKeyTable extends React.Component {
         this.setState({
             showDelete: false,
             deleteKeyId: null,
+            resourceOwnershipCliCommand: null,
         });
     }
 
     render() {
-        if (this.state.errorMessage) {
-            return (
-                
-                    Failed to fetch PublicKeys.
-                
-            );
-        }
-
         let description = null;
         let publicKeys = [];
 
@@ -233,6 +249,7 @@ class PublicKeyTable extends React.Component {
                     {formattedKey}
                     
                         
         ) : (
             ''
@@ -291,6 +309,9 @@ class PublicKeyTable extends React.Component {
                         cancel={this.onCancelDeleteKey}
                         submit={this.onSubmitDeleteKey}
                         errorMessage={this.state.errorMessage}
+                        resourceOwnershipCliCommand={
+                            this.state.resourceOwnershipCliCommand
+                        }
                         message={
                             'Are you sure you want to permanently delete the key id '
                         }
@@ -306,6 +327,8 @@ const mapStateToProps = (state, props) => {
         ...props,
         pubKeys: selectServicePublicKeys(state, props.domain, props.service),
         desc: selectServiceDescription(state, props.domain, props.service),
+        resourceOwnership: selectService(state, props.domain, props.service)
+            .resourceOwnership,
     };
 };
 
diff --git a/ui/src/components/service/ServiceList.js b/ui/src/components/service/ServiceList.js
index 0de8ee185cd..50eba450274 100644
--- a/ui/src/components/service/ServiceList.js
+++ b/ui/src/components/service/ServiceList.js
@@ -36,6 +36,11 @@ import {
     selectShowMicrosegmentation,
 } from '../../redux/selectors/domains';
 import { ReduxPageLoader } from '../denali/ReduxPageLoader';
+import {
+    isServiceResourceObjectManaged,
+    shouldOfferResourceOwnershipCli,
+} from '../utils/resourceOwnership';
+import { cliDeleteService } from '../utils/zmsCliCommands';
 
 const ServicesSectionDiv = styled.div`
     margin: 20px;
@@ -88,21 +93,39 @@ class ServiceList extends React.Component {
     }
 
     onSubmitDeleteService() {
+        const deletedName = this.state.deleteServiceName;
         this.props
-            .deleteService(
-                this.props.domain,
-                this.state.deleteServiceName,
-                this.props._csrf
-            )
+            .deleteService(this.props.domain, deletedName, this.props._csrf)
             .then(() => {
+                this.setState({
+                    deleteServiceName: null,
+                    deleteServiceResourceOwnership: null,
+                    errorMessage: null,
+                    resourceOwnershipCliCommand: null,
+                });
                 this.reloadServices(
-                    `Successfully deleted service ${this.state.deleteServiceName}`,
+                    `Successfully deleted service ${deletedName}`,
                     true
                 );
             })
             .catch((err) => {
+                const isServiceManaged = isServiceResourceObjectManaged(
+                    this.state.deleteServiceResourceOwnership
+                );
+                let resourceOwnershipCliCommand = null;
+                if (
+                    shouldOfferResourceOwnershipCli(isServiceManaged, err) &&
+                    deletedName
+                ) {
+                    resourceOwnershipCliCommand = cliDeleteService(
+                        this.props.domain,
+                        deletedName,
+                        null
+                    );
+                }
                 this.setState({
                     errorMessage: RequestUtils.xhrErrorCheckHelper(err),
+                    resourceOwnershipCliCommand,
                 });
             });
     }
@@ -111,18 +134,33 @@ class ServiceList extends React.Component {
         this.setState({
             showDelete: false,
             deleteServiceName: null,
+            deleteServiceResourceOwnership: null,
+            errorMessage: null,
+            resourceOwnershipCliCommand: null,
         });
     }
 
     onClickDeleteService(serviceName) {
+        const svc = (this.props.services || []).find(
+            (s) => NameUtils.getShortName('.', s.name) === serviceName
+        );
         this.setState({
             showDelete: true,
             deleteServiceName: serviceName,
+            deleteServiceResourceOwnership: svc?.resourceOwnership ?? null,
             errorMessage: null,
+            resourceOwnershipCliCommand: null,
         });
     }
 
     buildServiceRows(services) {
+        const managedResourceIconStripMaxIcons =
+            services &&
+            services.some((item) =>
+                isServiceResourceObjectManaged(item.resourceOwnership)
+            )
+                ? 1
+                : 0;
         const rows = services
             ? services.map((item, i) => {
                   const serviceName = NameUtils.getShortName('.', item.name);
@@ -153,6 +191,10 @@ class ServiceList extends React.Component {
                           }
                           _csrf={this.props._csrf}
                           onClickDeleteService={onClickDeleteService}
+                          resourceOwnership={item.resourceOwnership}
+                          managedResourceIconStripMaxIcons={
+                              managedResourceIconStripMaxIcons
+                          }
                       />
                   );
                   return toReturn;
@@ -286,6 +328,9 @@ class ServiceList extends React.Component {
                         cancel={this.onCancelDeleteService}
                         submit={this.onSubmitDeleteService}
                         errorMessage={this.state.errorMessage}
+                        resourceOwnershipCliCommand={
+                            this.state.resourceOwnershipCliCommand
+                        }
                         message={
                             'Are you sure you want to permanently delete the Service '
                         }
diff --git a/ui/src/components/service/ServiceRow.js b/ui/src/components/service/ServiceRow.js
index c21bc987b63..7c86b6e5e0e 100644
--- a/ui/src/components/service/ServiceRow.js
+++ b/ui/src/components/service/ServiceRow.js
@@ -25,6 +25,26 @@ import { selectProvider } from '../../redux/selectors/services';
 import { deleteService, getProvider } from '../../redux/thunks/services';
 import { connect } from 'react-redux';
 import ProviderTable from './ProviderTable';
+import ManagedResourceIcon from '../resource-ownership/ManagedResourceIcon';
+import { isServiceResourceObjectManaged } from '../utils/resourceOwnership';
+import { roleIconStripMinWidthStyle } from '../utils/roleIconStrip';
+
+const IconStrip = styled.span`
+    display: inline-flex;
+    align-items: center;
+    gap: 2px;
+    flex-shrink: 0;
+    vertical-align: middle;
+    box-sizing: border-box;
+`;
+
+const IconSlot = styled.span`
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 1.35em;
+    vertical-align: middle;
+`;
 
 const TdStyled = styled.td`
     background-color: ${(props) => props.color};
@@ -113,6 +133,13 @@ class ServiceRow extends React.Component {
         let row = [];
         const serviceName = this.props.serviceName;
         const newService = this.props.newService;
+        const managedResourceIconStripMaxIcons =
+            this.props.managedResourceIconStripMaxIcons ?? 0;
+        const serviceManagedResourceIcon = isServiceResourceObjectManaged(
+            this.props.resourceOwnership
+        ) ? (
+            
+        ) : null;
         row.push(
             
                 
-                    {serviceName}
+                    {managedResourceIconStripMaxIcons > 0 ? (
+                        <>
+                            
+                                {serviceManagedResourceIcon ? (
+                                    
+                                        {serviceManagedResourceIcon}
+                                    
+                                ) : null}
+                            
+                            {' ' + serviceName}
+                        
+                    ) : (
+                        serviceName
+                    )}
                 
                 
                     {this.localDate.getLocalDate(
@@ -144,6 +190,7 @@ class ServiceRow extends React.Component {
                 ) : null}
                 
                      {
+                const errMsg = RequestUtils.xhrErrorCheckHelper(err);
+                let resourceOwnershipCliCommand = null;
+                if (
+                    this.props.category === 'role' &&
+                    shouldOfferResourceOwnershipCli(
+                        isRoleResourceMetaManaged(
+                            this.props.collectionDetails.resourceOwnership
+                        ),
+                        err
+                    )
+                ) {
+                    const aud = this.props.isDomainAuditEnabled
+                        ? this.state.justification
+                        : null;
+                    resourceOwnershipCliCommand = cliRoleMetaDiff(
+                        this.props.domain,
+                        this.props.collection,
+                        this.state.originalCollectionDetails,
+                        this.state.copyCollectionDetails,
+                        aud
+                    );
+                }
                 this.setState({
-                    errorMessage: RequestUtils.xhrErrorCheckHelper(err),
+                    errorMessage: errMsg,
+                    resourceOwnershipCliCommand,
                 });
             });
     }
@@ -364,6 +397,9 @@ class SettingTable extends React.Component {
                 showJustification={this.props.isDomainAuditEnabled}
                 onJustification={this.saveJustification}
                 errorMessage={this.state.errorMessage}
+                resourceOwnershipCliCommand={
+                    this.state.resourceOwnershipCliCommand
+                }
             />
         ) : (
             ''
diff --git a/ui/src/components/utils/resourceOwnership.js b/ui/src/components/utils/resourceOwnership.js
new file mode 100644
index 00000000000..2aa14c566c3
--- /dev/null
+++ b/ui/src/components/utils/resourceOwnership.js
@@ -0,0 +1,133 @@
+/*
+ * Copyright The Athenz Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * True when ZMS reports an owner for this field (any non-empty SimpleName).
+ * The actual principal name is deployment-specific; UI treats any non-blank owner as externally managed (label/icon from resourceOwnershipUi config).
+ */
+export function hasResourceOwner(simpleName) {
+    if (simpleName === undefined || simpleName === null) {
+        return false;
+    }
+    return String(simpleName).trim() !== '';
+}
+
+export function isRoleResourceMetaManaged(resourceOwnership) {
+    if (!resourceOwnership) {
+        return false;
+    }
+    return (
+        hasResourceOwner(resourceOwnership.objectOwner) ||
+        hasResourceOwner(resourceOwnership.metaOwner)
+    );
+}
+
+export function isRoleResourceMembersManaged(resourceOwnership) {
+    if (!resourceOwnership) {
+        return false;
+    }
+    return hasResourceOwner(resourceOwnership.membersOwner);
+}
+
+export function isPolicyResourceManaged(resourceOwnership) {
+    if (!resourceOwnership) {
+        return false;
+    }
+    return (
+        hasResourceOwner(resourceOwnership.objectOwner) ||
+        hasResourceOwner(resourceOwnership.assertionsOwner)
+    );
+}
+
+export function isServiceResourceObjectManaged(resourceOwnership) {
+    if (!resourceOwnership) {
+        return false;
+    }
+    return hasResourceOwner(resourceOwnership.objectOwner);
+}
+
+export function isServiceResourceHostsManaged(resourceOwnership) {
+    if (!resourceOwnership) {
+        return false;
+    }
+    return hasResourceOwner(resourceOwnership.hostsOwner);
+}
+
+export function isServiceResourcePublicKeysManaged(resourceOwnership) {
+    if (!resourceOwnership) {
+        return false;
+    }
+    return hasResourceOwner(resourceOwnership.publicKeysOwner);
+}
+
+function extractErrMessage(err) {
+    if (!err) {
+        return '';
+    }
+    if (err.body && err.body.message) {
+        return String(err.body.message);
+    }
+    if (err.output && err.output.message) {
+        return String(err.output.message);
+    }
+    return '';
+}
+
+/** When true, show zms-cli hint even if ownership was not loaded in the UI. */
+export function errorMessageSuggestsResourceOwnership(err) {
+    const m = extractErrMessage(err).toLowerCase();
+    if (!m) {
+        return false;
+    }
+    return (
+        m.includes('ownership') ||
+        m.includes('owner') ||
+        m.includes('resource owner') ||
+        m.includes('resourceowner')
+    );
+}
+
+/**
+ * Show copy-paste CLI when we know the resource is externally managed, or the error text indicates an ownership denial.
+ */
+export function shouldOfferResourceOwnershipCli(isResourceManaged, err) {
+    return !!(isResourceManaged || errorMessageSuggestsResourceOwnership(err));
+}
+
+/**
+ * Build a zms-cli command for modal error feedback when ownership blocks a UI mutation.
+ * @param {boolean} isResourceManaged - from resourceOwnership helpers for the relevant field
+ * @param {*} err - API error
+ * @param {() => (string|null|undefined)} buildCommand - returns command when CLI hint applies
+ * @param {{ when?: boolean }} [options] - set when: false to skip (e.g. missing assertion row)
+ */
+export function resolveResourceOwnershipCliOnError(
+    isResourceManaged,
+    err,
+    buildCommand,
+    options = {}
+) {
+    const when = options.when !== false;
+    if (
+        !when ||
+        !shouldOfferResourceOwnershipCli(isResourceManaged, err) ||
+        typeof buildCommand !== 'function'
+    ) {
+        return null;
+    }
+    const cmd = buildCommand();
+    return cmd === undefined || cmd === null || cmd === '' ? null : cmd;
+}
diff --git a/ui/src/components/utils/resourceOwnershipUi.js b/ui/src/components/utils/resourceOwnershipUi.js
new file mode 100644
index 00000000000..8f7d91540f0
--- /dev/null
+++ b/ui/src/components/utils/resourceOwnershipUi.js
@@ -0,0 +1,100 @@
+/*
+ * Copyright The Athenz Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Default UI branding for externally managed resources (e.g. Terraform, OpenTofu).
+ * Override via `resourceOwnershipUi` in UI config / extended-config.
+ */
+export const DEFAULT_RESOURCE_OWNERSHIP_UI = {
+    /** Denali Icons.js key for the managed-resource indicator */
+    icon: 'terraform',
+    /** Display name substituted into tooltips and warning copy ({{label}}) */
+    label: 'Terraform',
+    managedIconTooltip:
+        'This resource is managed by {{label}} (ownership in ZMS).',
+    membersManagedIconTooltip:
+        'Role membership is managed by {{label}} (members owner).',
+    cliSuggestionBody:
+        'This resource is {{label}}-managed and cannot be edited via the Athenz UI. To maintain environment stability, always apply changes through {{label}} configuration. While the zms-cli can force immediate updates, doing so creates configuration drift, leaving the resource out-of-sync and requiring manual intervention to reconcile.',
+    cliSuggestionEmergencyHeading: 'Use only in emergencies:',
+    cliSuggestionGuideFooter: 'For detailed guidance, refer to',
+};
+
+/**
+ * Replace {{label}} in config strings with the configured owner tool name.
+ */
+export function formatResourceOwnershipUiString(template, label) {
+    if (template === undefined || template === null) {
+        return '';
+    }
+    const name =
+        label !== undefined && label !== null && String(label).trim() !== ''
+            ? String(label)
+            : DEFAULT_RESOURCE_OWNERSHIP_UI.label;
+    return String(template).replace(/\{\{label\}\}/g, name);
+}
+
+/** Merge deployment config with defaults. */
+export function resolveResourceOwnershipUi(fromConfig) {
+    if (!fromConfig || typeof fromConfig !== 'object') {
+        return { ...DEFAULT_RESOURCE_OWNERSHIP_UI };
+    }
+    return {
+        ...DEFAULT_RESOURCE_OWNERSHIP_UI,
+        ...fromConfig,
+    };
+}
+
+export function getManagedIconTooltip(uiConfig) {
+    const ui = resolveResourceOwnershipUi(uiConfig);
+    return formatResourceOwnershipUiString(ui.managedIconTooltip, ui.label);
+}
+
+export function getMembersManagedIconTooltip(uiConfig) {
+    const ui = resolveResourceOwnershipUi(uiConfig);
+    return formatResourceOwnershipUiString(
+        ui.membersManagedIconTooltip,
+        ui.label
+    );
+}
+
+export function getCliSuggestionBody(uiConfig) {
+    const ui = resolveResourceOwnershipUi(uiConfig);
+    return formatResourceOwnershipUiString(ui.cliSuggestionBody, ui.label);
+}
+
+export function getCliSuggestionEmergencyHeading(uiConfig) {
+    const ui = resolveResourceOwnershipUi(uiConfig);
+    return formatResourceOwnershipUiString(
+        ui.cliSuggestionEmergencyHeading,
+        ui.label
+    );
+}
+
+export function getCliSuggestionGuideFooter(uiConfig) {
+    const ui = resolveResourceOwnershipUi(uiConfig);
+    return formatResourceOwnershipUiString(
+        ui.cliSuggestionGuideFooter,
+        ui.label
+    );
+}
+
+/** First sentence of CLI suggestion body — used by functional tests. */
+export function getCliSuggestionWarningLead(uiConfig) {
+    const body = getCliSuggestionBody(uiConfig);
+    const idx = body.indexOf('. ');
+    return idx >= 0 ? body.slice(0, idx) : body;
+}
diff --git a/ui/src/components/utils/roleIconStrip.js b/ui/src/components/utils/roleIconStrip.js
new file mode 100644
index 00000000000..c48a3a699e6
--- /dev/null
+++ b/ui/src/components/utils/roleIconStrip.js
@@ -0,0 +1,59 @@
+/*
+ * Copyright The Athenz Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { isRoleResourceMetaManaged } from './resourceOwnership';
+
+/** Icons shown in order: delegated, audit, externally managed (same as RoleRow). */
+export function roleVisibleIconCount(role) {
+    if (!role) {
+        return 0;
+    }
+    let n = 0;
+    if (role.trust) {
+        n++;
+    }
+    if (role.auditEnabled) {
+        n++;
+    }
+    if (isRoleResourceMetaManaged(role.resourceOwnership)) {
+        n++;
+    }
+    return n;
+}
+
+/** Max icons any single row shows in the list — drives shared IconStrip min-width. */
+export function maxRoleVisibleIconCount(roles) {
+    if (!roles || roles.length === 0) {
+        return 0;
+    }
+    let m = 0;
+    for (let i = 0; i < roles.length; i++) {
+        const c = roleVisibleIconCount(roles[i]);
+        if (c > m) {
+            m = c;
+        }
+    }
+    return m;
+}
+
+/** CSS min-width for IconStrip: matches RoleRow IconSlot width (1.35em) and gap (2px). */
+export function roleIconStripMinWidthStyle(maxIcons) {
+    if (maxIcons <= 0) {
+        return '0';
+    }
+    const gapPx = maxIcons > 1 ? (maxIcons - 1) * 2 : 0;
+    return `calc(${maxIcons} * 1.35em + ${gapPx}px)`;
+}
diff --git a/ui/src/components/utils/zmsCliCommands.js b/ui/src/components/utils/zmsCliCommands.js
new file mode 100644
index 00000000000..868c872b795
--- /dev/null
+++ b/ui/src/components/utils/zmsCliCommands.js
@@ -0,0 +1,388 @@
+/*
+ * Copyright The Athenz Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+import NameUtils from './NameUtils';
+import { getZmsCliUrl } from '../../utils/zmsCliUrl';
+
+/**
+ * zms-cli `-r` value: `ignore` skips resource-owner enforcement for the command.
+ */
+export const ZMS_CLI_RESOURCE_OWNER_FLAG = 'ignore';
+
+/** Shell-safe token for displayed zms-cli lines (bash/zsh copy-paste). */
+export function shellQuote(str) {
+    if (str === undefined || str === null) {
+        return '';
+    }
+    const s = String(str);
+    if (/[^a-zA-Z0-9./_-]/.test(s)) {
+        return '"' + s.replace(/"/g, '\\"') + '"';
+    }
+    return s;
+}
+
+function base(domain, auditRef) {
+    const zmsUrl = getZmsCliUrl();
+    let cmd = 'zms-cli';
+    if (zmsUrl) {
+        cmd += ` -z ${shellQuote(zmsUrl)}`;
+    }
+    cmd += ` -d ${shellQuote(domain)} -r ${shellQuote(
+        ZMS_CLI_RESOURCE_OWNER_FLAG
+    )}`;
+    if (auditRef) {
+        cmd += ` -a ${shellQuote(auditRef)}`;
+    }
+    return cmd;
+}
+
+export function cliAddRoleMember(domain, roleName, memberName, auditRef) {
+    return `${base(domain, auditRef)} add-member ${shellQuote(
+        roleName
+    )} ${shellQuote(memberName)}`;
+}
+
+export function cliAddTemporaryRoleMember(
+    domain,
+    roleName,
+    memberName,
+    expirationRdl,
+    reviewRdl,
+    auditRef
+) {
+    const b = base(domain, auditRef);
+    if (expirationRdl && reviewRdl) {
+        return `${b} add-temporary-member ${shellQuote(roleName)} ${shellQuote(
+            memberName
+        )} ${shellQuote(expirationRdl)} ${shellQuote(reviewRdl)}`;
+    }
+    if (expirationRdl) {
+        return `${b} add-temporary-member ${shellQuote(roleName)} ${shellQuote(
+            memberName
+        )} ${shellQuote(expirationRdl)}`;
+    }
+    if (reviewRdl) {
+        return `${b} add-reviewed-member ${shellQuote(roleName)} ${shellQuote(
+            memberName
+        )} ${shellQuote(reviewRdl)}`;
+    }
+    return cliAddRoleMember(domain, roleName, memberName, auditRef);
+}
+
+export function cliDeleteRoleMember(domain, roleName, memberName, auditRef) {
+    return `${base(domain, auditRef)} delete-member ${shellQuote(
+        roleName
+    )} ${shellQuote(memberName)}`;
+}
+
+export function cliDeleteRole(domain, roleName, auditRef) {
+    return `${base(domain, auditRef)} delete-role ${shellQuote(roleName)}`;
+}
+
+export function cliDeleteService(domain, serviceName, auditRef) {
+    return `${base(domain, auditRef)} delete-service ${shellQuote(
+        serviceName
+    )}`;
+}
+
+export function cliDeletePolicy(domain, policyName, auditRef) {
+    return `${base(domain, auditRef)} delete-policy ${shellQuote(policyName)}`;
+}
+
+export function cliDeletePolicyVersion(domain, policyName, version, auditRef) {
+    return `${base(domain, auditRef)} delete-policy-version ${shellQuote(
+        policyName
+    )} ${shellQuote(version)}`;
+}
+
+export function cliAddPolicyVersion(
+    domain,
+    policyName,
+    sourceVersion,
+    newVersion,
+    auditRef
+) {
+    return `${base(domain, auditRef)} add-policy-version ${shellQuote(
+        policyName
+    )} ${shellQuote(sourceVersion)} ${shellQuote(newVersion)}`;
+}
+
+export function cliAddAssertion(
+    domain,
+    policyName,
+    assertionWords,
+    auditRef,
+    caseSensitive
+) {
+    let cmd = `${base(domain, auditRef)} add-assertion ${shellQuote(
+        policyName
+    )} ${assertionWords}`;
+    if (caseSensitive) {
+        cmd += ' true';
+    }
+    return cmd;
+}
+
+export function cliDeleteAssertion(
+    domain,
+    policyName,
+    assertionWords,
+    auditRef
+) {
+    return `${base(domain, auditRef)} delete-assertion ${shellQuote(
+        policyName
+    )} ${assertionWords}`;
+}
+
+export function cliDeleteAssertionPolicyVersion(
+    domain,
+    policyName,
+    version,
+    assertionWords,
+    auditRef
+) {
+    return `${base(
+        domain,
+        auditRef
+    )} delete-assertion-policy-version ${shellQuote(policyName)} ${shellQuote(
+        version
+    )} ${assertionWords}`;
+}
+
+export function cliAddAssertionPolicyVersion(
+    domain,
+    policyName,
+    version,
+    assertionWords,
+    auditRef,
+    caseSensitive
+) {
+    let cmd = `${base(
+        domain,
+        auditRef
+    )} add-assertion-policy-version ${shellQuote(policyName)} ${shellQuote(
+        version
+    )} ${assertionWords}`;
+    if (caseSensitive) {
+        cmd += ' true';
+    }
+    return cmd;
+}
+
+/** Builds zms-cli assertion token sequence (effect action to role on resource). */
+export function formatAssertionWords(domain, assertion) {
+    const role = NameUtils.getShortName(domain + ':role.', assertion.role);
+    const res = NameUtils.getShortName(domain + ':', assertion.resource);
+    const eff =
+        String(assertion.effect || '').toUpperCase() === 'DENY'
+            ? 'deny'
+            : 'grant';
+    return `${eff} ${shellQuote(assertion.action)} to ${shellQuote(
+        role
+    )} on ${shellQuote(res)}`;
+}
+
+export function cliAddPolicy(
+    domain,
+    policyName,
+    assertionWords,
+    auditRef,
+    caseSensitive
+) {
+    let cmd = `${base(domain, auditRef)} add-policy ${shellQuote(
+        policyName
+    )} ${assertionWords}`;
+    if (caseSensitive) {
+        cmd += ' true';
+    }
+    return cmd;
+}
+
+export function cliAddServiceHost(domain, serviceName, hostnames, auditRef) {
+    const hosts = []
+        .concat(hostnames || [])
+        .map((h) => shellQuote(h))
+        .join(' ');
+    return `${base(domain, auditRef)} add-service-host ${shellQuote(
+        serviceName
+    )} ${hosts}`;
+}
+
+export function cliDeletePublicKey(domain, serviceName, keyId, auditRef) {
+    return `${base(domain, auditRef)} delete-public-key ${shellQuote(
+        serviceName
+    )} ${shellQuote(keyId)}`;
+}
+
+export function cliAddPublicKey(
+    domain,
+    serviceName,
+    keyId,
+    keyValuePathHint,
+    auditRef
+) {
+    let cmd = `${base(domain, auditRef)} add-public-key ${shellQuote(
+        serviceName
+    )} ${shellQuote(keyId)}`;
+    if (keyValuePathHint) {
+        cmd += ` ${shellQuote(keyValuePathHint)}`;
+    }
+    return cmd;
+}
+
+function boolStr(v) {
+    return v ? 'true' : 'false';
+}
+
+/**
+ * Builds set-role-* commands for fields that differ between original and updated
+ * (shape from SettingTable setCollectionDetails for roles).
+ */
+export function cliRoleMetaDiff(domain, roleName, original, updated, auditRef) {
+    if (!original || !updated) {
+        return null;
+    }
+    const b = base(domain, auditRef);
+    const parts = [];
+    const role = shellQuote(roleName);
+
+    const push = (fragment) => parts.push(`${b} ${fragment}`);
+
+    if (original.description !== updated.description) {
+        push(
+            `set-role-description ${role} ${shellQuote(
+                updated.description || ''
+            )}`
+        );
+    }
+    if (original.reviewEnabled !== updated.reviewEnabled) {
+        push(
+            `set-role-review-enabled ${role} ${boolStr(updated.reviewEnabled)}`
+        );
+    }
+    if (original.auditEnabled !== updated.auditEnabled) {
+        push(`set-role-audit-enabled ${role} ${boolStr(updated.auditEnabled)}`);
+    }
+    if (original.deleteProtection !== updated.deleteProtection) {
+        push(
+            `set-role-delete-protection ${role} ${boolStr(
+                updated.deleteProtection
+            )}`
+        );
+    }
+    if (original.selfServe !== updated.selfServe) {
+        push(`set-role-self-serve ${role} ${boolStr(updated.selfServe)}`);
+    }
+    if (original.selfRenew !== updated.selfRenew) {
+        push(`set-role-self-renew ${role} ${boolStr(updated.selfRenew)}`);
+    }
+    if (original.memberExpiryDays !== updated.memberExpiryDays) {
+        push(
+            `set-role-member-expiry-days ${role} ${shellQuote(
+                updated.memberExpiryDays || '0'
+            )}`
+        );
+    }
+    if (original.memberReviewDays !== updated.memberReviewDays) {
+        push(
+            `set-role-member-review-days ${role} ${shellQuote(
+                updated.memberReviewDays || '0'
+            )}`
+        );
+    }
+    if (original.groupExpiryDays !== updated.groupExpiryDays) {
+        push(
+            `set-role-group-expiry-days ${role} ${shellQuote(
+                updated.groupExpiryDays || '0'
+            )}`
+        );
+    }
+    if (original.groupReviewDays !== updated.groupReviewDays) {
+        push(
+            `set-role-group-review-days ${role} ${shellQuote(
+                updated.groupReviewDays || '0'
+            )}`
+        );
+    }
+    if (original.serviceExpiryDays !== updated.serviceExpiryDays) {
+        push(
+            `set-role-service-expiry-days ${role} ${shellQuote(
+                updated.serviceExpiryDays || '0'
+            )}`
+        );
+    }
+    if (original.serviceReviewDays !== updated.serviceReviewDays) {
+        push(
+            `set-role-service-review-days ${role} ${shellQuote(
+                updated.serviceReviewDays || '0'
+            )}`
+        );
+    }
+    if (original.selfRenewMins !== updated.selfRenewMins) {
+        push(
+            `set-role-self-renew-mins ${role} ${shellQuote(
+                updated.selfRenewMins || '0'
+            )}`
+        );
+    }
+    if (original.maxMembers !== updated.maxMembers) {
+        push(
+            `set-role-max-members ${role} ${shellQuote(
+                updated.maxMembers || '0'
+            )}`
+        );
+    }
+    if (original.userAuthorityFilter !== updated.userAuthorityFilter) {
+        push(
+            `set-role-user-authority-filter ${role} ${shellQuote(
+                updated.userAuthorityFilter || ''
+            )}`
+        );
+    }
+    if (original.userAuthorityExpiration !== updated.userAuthorityExpiration) {
+        push(
+            `set-role-user-authority-expiration ${role} ${shellQuote(
+                updated.userAuthorityExpiration || ''
+            )}`
+        );
+    }
+    if (original.principalDomainFilter !== updated.principalDomainFilter) {
+        push(
+            `set-role-principal-domain-filter ${role} ${shellQuote(
+                updated.principalDomainFilter || ''
+            )}`
+        );
+    }
+    if (original.tokenExpiryMins !== updated.tokenExpiryMins) {
+        push(
+            `set-role-token-expiry-mins ${role} ${shellQuote(
+                updated.tokenExpiryMins || '0'
+            )}`
+        );
+    }
+    if (original.certExpiryMins !== updated.certExpiryMins) {
+        push(
+            `set-role-cert-expiry-mins ${role} ${shellQuote(
+                updated.certExpiryMins || '0'
+            )}`
+        );
+    }
+
+    if (parts.length === 0) {
+        return `${b} show-role ${role}`;
+    }
+    return parts.join(' && \\\n');
+}
diff --git a/ui/src/config/default-config.js b/ui/src/config/default-config.js
index e02811c1d5e..05163ea31b0 100644
--- a/ui/src/config/default-config.js
+++ b/ui/src/config/default-config.js
@@ -15,6 +15,20 @@
  */
 'use strict';
 
+/** Defaults mirrored in src/components/utils/resourceOwnershipUi.js */
+const resourceOwnershipUiDefaults = {
+    icon: 'terraform',
+    label: 'Terraform',
+    managedIconTooltip:
+        'This resource is managed by {{label}} (ownership in ZMS).',
+    membersManagedIconTooltip:
+        'Role membership is managed by {{label}} (members owner).',
+    cliSuggestionBody:
+        'This resource is {{label}}-managed and cannot be edited via the Athenz UI. To maintain environment stability, always apply changes through {{label}} configuration. While the zms-cli can force immediate updates, doing so creates configuration drift, leaving the resource out-of-sync and requiring manual intervention to reconcile.',
+    cliSuggestionEmergencyHeading: 'Use only in emergencies:',
+    cliSuggestionGuideFooter: 'For detailed guidance, refer to',
+};
+
 const testdata = {
     user1: {
         name: 'John Doe',
@@ -33,6 +47,8 @@ const testdata = {
      */
     functionalTest: 'functional-test',
     functionalTestNonAdmin: 'functional-test-non-admin',
+    /** Domain with externally managed roles/policies/services for resource-ownership.spec.js */
+    functionalTestResourceOwnership: 'functional-test-tf',
     delegatedParent: 'delegated-parent',
     auditEnabled: 'audit-enabled',
     principalFilter: 'principal-filter',
@@ -117,6 +133,12 @@ const config = {
             url: '',
             target: '_blank',
         },
+        resourceOwnershipGuideLink: {
+            title: 'resource-ownership-guide',
+            url: 'https://example.com/resource-ownership',
+            target: '_blank',
+        },
+        resourceOwnershipUi: { ...resourceOwnershipUiDefaults },
         servicePageConfig: {
             keyCreationLink: {
                 title: 'Key Creation',
@@ -230,6 +252,12 @@ const config = {
                 roleGroupReviewFeatureFlag: true,
             },
         },
+        resourceOwnershipGuideLink: {
+            title: 'resource-ownership-guide',
+            url: 'https://example.com/resource-ownership',
+            target: '_blank',
+        },
+        resourceOwnershipUi: { ...resourceOwnershipUiDefaults },
         serviceHeaderLinks: [
             {
                 description:
diff --git a/ui/src/pages/domain/[domain]/role/[role]/history.js b/ui/src/pages/domain/[domain]/role/[role]/history.js
index 30701288936..54f32e1c529 100644
--- a/ui/src/pages/domain/[domain]/role/[role]/history.js
+++ b/ui/src/pages/domain/[domain]/role/[role]/history.js
@@ -172,6 +172,9 @@ class RoleHistoryPage extends React.Component {
                                             domain={domainName}
                                             role={roleName}
                                             selectedName={'history'}
+                                            resourceOwnership={
+                                                roleDetails.resourceOwnership
+                                            }
                                         />
                                     
                                     
                                     
                                     
                                     
                                     
                                     
                                     
                                     
                                     
                                     
                                      {
         }
         case LOAD_HEADER_DETAILS: {
             const { headerDetails } = payload;
+            setZmsCliUrl(headerDetails && headerDetails.zmsUrl);
             let newState = produce(state, (draft) => {
                 draft.headerDetails = headerDetails;
             });
diff --git a/ui/src/redux/selectors/domains.js b/ui/src/redux/selectors/domains.js
index f47997cd535..0a50abcbdc5 100644
--- a/ui/src/redux/selectors/domains.js
+++ b/ui/src/redux/selectors/domains.js
@@ -15,6 +15,7 @@
  */
 
 import { selectUserPendingMembers } from './user';
+import { resolveResourceOwnershipUi } from '../../components/utils/resourceOwnershipUi';
 
 export const thunkSelectPendingMembersList = (state, domainName) => {
     let domainPendingMembers = selectPendingMembersList(state, domainName);
@@ -64,6 +65,11 @@ export const selectHeaderDetails = (state) => {
     return state.domains.headerDetails ? state.domains.headerDetails : {};
 };
 
+export const selectZmsUrl = (state) => {
+    const hd = selectHeaderDetails(state);
+    return hd.zmsUrl || null;
+};
+
 export const selectTimeZone = (state) => {
     return state.domains.timeZone ? state.domains.timeZone : 'UTC';
 };
@@ -75,6 +81,18 @@ export const selectProductMasterLink = (state) => {
         : {};
 };
 
+export const selectResourceOwnershipGuideLink = (state) => {
+    const hd = selectHeaderDetails(state);
+    return hd && hd.resourceOwnershipGuideLink
+        ? hd.resourceOwnershipGuideLink
+        : {};
+};
+
+export const selectResourceOwnershipUi = (state) => {
+    const hd = selectHeaderDetails(state);
+    return resolveResourceOwnershipUi(hd && hd.resourceOwnershipUi);
+};
+
 export const selectFeatureFlag = (state) => {
     const ff = state.domains.featureFlag;
     if (ff && typeof ff === 'object') return ff.enabled;
diff --git a/ui/src/server/handlers/api.js b/ui/src/server/handlers/api.js
index 48171960fa9..137dbe07ef0 100644
--- a/ui/src/server/handlers/api.js
+++ b/ui/src/server/handlers/api.js
@@ -42,9 +42,9 @@ const responseHandler = function (err, data) {
         debug(
             `principal: ${this.req.session.shortId} rid: ${
                 this.req.headers.rid
-            } Error from ZMS while calling ${this.caller} API: ${JSON.stringify(
-                errorHandler.fetcherError(err)
-            )}`
+            } Error from ${this.backend ? this.backend : 'ZMS'} while calling ${
+                this.caller
+            } API: ${JSON.stringify(errorHandler.fetcherError(err))}`
         );
         return this.callback(errorHandler.fetcherError(err));
     } else {
@@ -1857,7 +1857,12 @@ Fetchr.registerService({
     update(req, resource, params, body, config, callback) {
         req.clients.msd.putStaticWorkload(
             params,
-            responseHandler.bind({ caller: 'add-service-host', callback, req })
+            responseHandler.bind({
+                caller: 'add-service-host',
+                callback,
+                req,
+                backend: 'MSD',
+            })
         );
     },
 });
@@ -2587,6 +2592,9 @@ Fetchr.registerService({
             userId: req.session.shortId,
             createDomainMessage: appConfig.createDomainMessage,
             productMasterLink: appConfig.productMasterLink,
+            resourceOwnershipGuideLink: appConfig.resourceOwnershipGuideLink,
+            resourceOwnershipUi: appConfig.resourceOwnershipUi,
+            zmsUrl: appConfig.zms,
         });
     },
 });
@@ -3488,6 +3496,8 @@ module.exports.load = function (config, secrets) {
         createDomainMessage: config.createDomainMessage,
         servicePageConfig: config.servicePageConfig,
         productMasterLink: config.productMasterLink,
+        resourceOwnershipGuideLink: config.resourceOwnershipGuideLink,
+        resourceOwnershipUi: config.resourceOwnershipUi,
         allPrefixes: config.allPrefixes,
         zmsLoginUrl: config.zmsLoginUrl,
         timeZone: config.timeZone,
diff --git a/ui/src/tests_utils/ComponentsTestUtils.js b/ui/src/tests_utils/ComponentsTestUtils.js
index 94490e79a97..77373d3841b 100644
--- a/ui/src/tests_utils/ComponentsTestUtils.js
+++ b/ui/src/tests_utils/ComponentsTestUtils.js
@@ -18,8 +18,11 @@ import { render } from '@testing-library/react';
 import { Provider } from 'react-redux';
 import { initStore } from '../redux/store';
 import { getExpiryTime } from '../redux/utils';
+import { setZmsCliUrl } from '../utils/zmsCliUrl';
 
 export const renderWithRedux = (component, initialState = {}) => {
+    const zmsUrl = initialState.domains?.headerDetails?.zmsUrl;
+    setZmsCliUrl(zmsUrl);
     return render(
         {component}
     );
diff --git a/ui/src/utils/zmsCliUrl.js b/ui/src/utils/zmsCliUrl.js
new file mode 100644
index 00000000000..ca3ec8aa0df
--- /dev/null
+++ b/ui/src/utils/zmsCliUrl.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright The Athenz Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+/** ZMS API base URL for generated zms-cli commands (matches UI server config.zms). */
+let zmsCliUrl = null;
+
+export function setZmsCliUrl(url) {
+    zmsCliUrl = url || null;
+}
+
+export function getZmsCliUrl() {
+    return zmsCliUrl;
+}