+ 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`] = `
+ }
+ >
+ {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;
+}