Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI Hide Secrets Sync from nav if not on license and/or no policy permissions #27262

Merged
merged 24 commits into from
Jun 11, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
64c2fd0
intial changes, haven't tested client counts or done test coverage
Monkeychip May 29, 2024
931a926
client count rename getter to clairfy
Monkeychip May 29, 2024
ac6b262
fix has-permission api-paths
Monkeychip May 29, 2024
b1ad18f
wip
Monkeychip May 29, 2024
09162c4
wip
Monkeychip May 29, 2024
e67076e
fix: explicitly refresh vault.cluster model to re-fetch activatedFeat…
May 29, 2024
e69075b
tests: fix # of assertions for verifying that activation was called
May 29, 2024
27df01a
tests: tidy overview-test
May 29, 2024
7fc2fd7
add additional api permission path and move fetch back to application
Monkeychip May 30, 2024
36fbb78
add test coverage for the service
Monkeychip May 30, 2024
1f2a88b
Merge branch 'main' into ui/VAULT-27608/hide-secrets-sync-when-no-access
Monkeychip May 30, 2024
462d258
cleanup
Monkeychip May 30, 2024
9f1ab09
remove test that checked for upsell without license or on community
Monkeychip May 30, 2024
e4e1080
small comment change
Monkeychip May 30, 2024
3ec1646
welp missed component getter
Monkeychip May 30, 2024
628ea6b
flaky test fix
Monkeychip May 30, 2024
dccc476
flaky test
Monkeychip May 30, 2024
22ce68a
small nit changes from pr reviews
Monkeychip May 31, 2024
6fd9b21
add defaults to sync mirage handler
Monkeychip May 31, 2024
51bf477
Gate sync overview route for users without access (#27320)
Jun 3, 2024
e6b9811
Merge branch 'main' into ui/VAULT-27608/hide-secrets-sync-when-no-access
Monkeychip Jun 4, 2024
8f0fed0
add type enterprise required now because we do a check for this first
Monkeychip Jun 4, 2024
0e598d8
Merge branch 'main' into ui/VAULT-27608/hide-secrets-sync-when-no-access
Monkeychip Jun 10, 2024
e8bf2c4
fix oss test
Monkeychip Jun 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog/27262.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
ui/secrets-sync: Hide Secrets Sync from the sidebar nav if user does not have access to the feature.
```
2 changes: 1 addition & 1 deletion ui/app/components/clients/counts/nav-bar.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
Entity/Non-entity clients
</LinkTo>
</li>
{{#if @showSecretsSync}}
{{#if @showSecretsSyncClientCounts}}
<li>
<LinkTo @route="vault.cluster.clients.counts.sync" data-test-tab="sync">
Secrets sync clients
Expand Down
2 changes: 1 addition & 1 deletion ui/app/components/clients/page/counts.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@
</Hds::Alert>
{{/if}}

<Clients::Counts::NavBar @showSecretsSync={{this.showSecretsSync}} />
<Clients::Counts::NavBar @showSecretsSyncClientCounts={{(or @hasSecretsSyncClients this.flags.showSecretsSync)}} />
Monkeychip marked this conversation as resolved.
Show resolved Hide resolved

{{! CLIENT COUNT PAGE COMPONENTS RENDER HERE }}
{{yield}}
Expand Down
14 changes: 2 additions & 12 deletions ui/app/components/clients/page/counts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,20 +167,10 @@ export default class ClientsCountsPageComponent extends Component<Args> {
return activity?.total;
}

get showSecretsSync(): boolean {
get hasSecretsSyncClients(): boolean {
const { activity } = this.args;
// if there is any sync client data, show it
if (activity && activity?.total?.secret_syncs > 0) return true;

// otherwise, show the tab based on the cluster type and license
if (this.version.isCommunity) return false;

const isHvd = this.flags.isHvdManaged;
const onLicense = this.version.hasSecretsSync;

// we can't tell if HVD clusters have the feature or not, so we show it by default
// if the cluster is not HVD, show the tab if the feature is on the license
return isHvd || onLicense;
return activity && activity?.total?.secret_syncs > 0;
}

@action
Expand Down
14 changes: 8 additions & 6 deletions ui/app/components/sidebar/nav/cluster.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@
@text="Secrets Engines"
data-test-sidebar-nav-link="Secrets Engines"
/>
<Nav.Link
@route="vault.cluster.sync"
@text="Secrets Sync"
@badge={{this.badgeText}}
data-test-sidebar-nav-link="Secrets Sync"
/>
{{#if this.flags.showSecretsSync}}
<Nav.Link
@route="vault.cluster.sync"
@text="Secrets Sync"
@badge={{if this.flags.isHvdManaged "Plus" ""}}
data-test-sidebar-nav-link="Secrets Sync"
/>
{{/if}}
{{#if (has-permission "access")}}
<Nav.Link
@route={{get (route-params-for "access") "route"}}
Expand Down
12 changes: 0 additions & 12 deletions ui/app/components/sidebar/nav/cluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,4 @@ export default class SidebarNavClusterComponent extends Component {
// should only return true if we're in the true root namespace
return this.namespace.inRootNamespace && !this.cluster?.hasChrootNamespace;
}

get badgeText() {
const isHvdManaged = this.flags.isHvdManaged;
const onLicense = this.version.hasSecretsSync;
const isEnterprise = this.version.isEnterprise;

if (isHvdManaged) return 'Plus';
if (isEnterprise && !onLicense) return 'Premium';
if (!isEnterprise) return 'Enterprise';
// no badge for Enterprise clusters with Secrets Sync on their license--the only remaining option.
return '';
}
}
7 changes: 5 additions & 2 deletions ui/app/routes/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ export default Route.extend({
},
},

beforeModel() {
return this.flagsService.fetchFeatureFlags();
async beforeModel() {
// activatedFlags are called this high in routing to return a response used to show/hide Secrets sync on sidebar nav.
// featureFlags are called this high in routing to determine isHvdManaged things, etc.
// await this.flagsService.fetchActivatedFlags();
await this.flagsService.fetchFeatureFlags();
},
});
2 changes: 2 additions & 0 deletions ui/app/routes/vault/cluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
},

async beforeModel() {
await this.flagsService.fetchActivatedFlags();
const params = this.paramsFor(this.routeName);
let namespace = params.namespaceQueryParam;
const currentTokenName = this.auth.currentTokenName;
const managedRoot = this.flagsService.hvdManagedNamespaceRoot;

assert(
'Cannot use VAULT_CLOUD_ADMIN_NAMESPACE flag with non-enterprise Vault version',
!(managedRoot && this.version.isCommunity)
Expand Down
21 changes: 20 additions & 1 deletion ui/app/services/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DEBUG } from '@glimmer/env';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import type StoreService from 'vault/services/store';
import type VersionService from 'vault/services/version';
import type PermissionsService from 'vault/services/permissions';

const FLAGS = {
vaultCloudNamespace: 'VAULT_CLOUD_ADMIN_NAMESPACE',
Expand All @@ -24,6 +25,7 @@ const FLAGS = {
export default class flagsService extends Service {
@service declare readonly version: VersionService;
@service declare readonly store: StoreService;
@service declare readonly permissions: PermissionsService;

@tracked activatedFlags: string[] = [];
@tracked featureFlags: string[] = [];
Expand Down Expand Up @@ -60,7 +62,6 @@ export default class flagsService extends Service {
}

getActivatedFlags = keepLatestTask(async () => {
if (this.version.isCommunity) return;
// Response could change between user sessions.
// Fire off endpoint without checking if activated features are already set.
try {
Expand All @@ -86,4 +87,22 @@ export default class flagsService extends Service {
this.secretsSyncActivatePath.get('canUpdate') !== false
);
}

get showSecretsSync() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain your reasoning for putting this getter here? Since it's compiling logic from flags, permissions and versions services for just secret sync, it feels a little arbitrary living in the flag service, which I understood to be just for returning info about cluster flags.

Though looking at the other getter here canActivateSecretsSync it seems like this flags service is also sort of a secrets sync permissions service 😅

I realize we're limited on time for this PR, but want to suggest that a future improvement is maybe separating or clarifying concerns here. Perhaps that means having another service (auth service?) responsible for managing user state related logic

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noelle and I chatted about this. Because we're using it in more than one places, and the concept is to keep access to this route consistent in both clients and sidebar nav we opted to leave it in the service.

But yes agree, flags (similar to what has happened to version service) is growing past it's original scope.

const isHvdManaged = this.isHvdManaged;
const onLicense = this.version.hasSecretsSync;
const isEnterprise = this.version.isEnterprise;
const isActivated = this.secretsSyncIsActivated;

if (!isEnterprise) return false;
if (isHvdManaged) return true;
Comment on lines +98 to +99
Copy link
Contributor

@hellobontempo hellobontempo May 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are HVD clusters enterprise? ~ til ~ I was going to suggest rearranging this order, but disregard if HVD clusters are enterprise

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

they sure are 🙂

if (isEnterprise && !onLicense) return false;
// only remaining version is Enterprise with Secrets Sync on their license
if (isActivated) {
// if the feature is activated but the user does not have permissions on the `sys/sync` endpoint, hide navigation link.
return this.permissions.hasNavPermission('sync');
}
// only remaining option now is Enterprise with Secrets Sync on the license but the feature is not activated. In this case, we want to show the upsell page and message about either activating or having an admin activate.
return true;
}
}
13 changes: 11 additions & 2 deletions ui/app/services/permissions.js
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The list here came from me scanning through the sync API. We should test lots of policies to make sure I've gotten all the options.

Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ const API_PATHS = {
settings: {
customMessages: 'sys/config/ui/custom-messages',
},
sync: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still learning out this service works...but do you know why just having

sync: 'sys/sync/',

which ends in a / isn't sufficient here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope! 🙃 This is what I pinged Chelsea about in the dev channel. The logic of comparison is backwards from what you'd think they be comparing. I'll ping you on that thread which has links, etc. But it's also something that I can more easily clarify in standup.

destinations: 'sys/sync/destinations',
associations: 'sys/sync/associations',
config: 'sys/sync/config',
},
};

const API_PATHS_TO_ROUTE_PARAMS = {
Expand Down Expand Up @@ -187,7 +192,11 @@ export default class PermissionsService extends Service {
return this.hasPermission(API_PATHS[navItem][param], capability);
});
}
return Object.values(API_PATHS[navItem]).some((path) => this.hasPermission(path));
return Object.values(API_PATHS[navItem]).some((path) => {
const test = this.hasPermission(path);

return test;
});
}

navPathParams(navItem) {
Expand All @@ -213,7 +222,6 @@ export default class PermissionsService extends Service {
return true;
}
const path = this.pathNameWithNamespace(pathName);

return capabilities.every(
(capability) =>
this.hasMatchingExactPath(path, capability) || this.hasMatchingGlobPath(path, capability)
Expand All @@ -237,6 +245,7 @@ export default class PermissionsService extends Service {

hasMatchingGlobPath(pathName, capability) {
const globPaths = this.globPaths;

if (globPaths) {
const matchingPath = Object.keys(globPaths).find((k) => {
return pathName.includes(k) || pathName.includes(k.replace(/\/$/, ''));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export default class SyncActivationModal extends Component<Args> {
.adapterFor('application')
.ajax('/v1/sys/activation-flags/secrets-sync/activate', 'POST', { namespace });
// must refresh and not transition because transition does not refresh the model from within a namespace
yield this.router.refresh();
yield this.router.refresh('vault.cluster');
Copy link
Contributor

@andaley andaley May 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can probably take this or leave it, but i tried explicitly passing in the parent route that we want refreshed here to see if it'd help us assert that the get activated features was successfully re-fetched. i tried this based on the docs i read here:
Screenshot 2024-05-29 at 3 39 22 PM

doing this works fine in the UI, and after adding a bunch of console.log statements i can see even in the test output that _/activation-flags is fetched after a POST, but i can't get the assertions working how i'd like. because we have these assertions that effectively test that the UI updates once the feature is activated i think we're safe skipping the extra assertions.

} catch (error) {
this.args.onError(errorMessage(error));
this.flashMessages.danger(`Error enabling feature \n ${errorMessage(error)}`);
Expand Down
4 changes: 2 additions & 2 deletions ui/lib/sync/addon/components/sync-header.hbs
Monkeychip marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
<Icon @name={{@icon}} @size="24" />
{{/if}}
{{@title}}
{{#if this.badgeText}}
<Hds::Badge @text={{this.badgeText}} @color="highlight" @size="large" />
{{#if this.flags.isHvdManaged}}
<Hds::Badge @text={{"Plus feature"}} @color="highlight" @size="large" />
Monkeychip marked this conversation as resolved.
Show resolved Hide resolved
{{/if}}
</h1>
</p.levelLeft>
Expand Down
14 changes: 0 additions & 14 deletions ui/lib/sync/addon/components/sync-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';

import type VersionService from 'vault/services/version';
import type FlagsService from 'vault/services/flags';
import type { Breadcrumb } from 'vault/vault/app-types';

Expand All @@ -17,18 +16,5 @@ interface Args {
}

export default class SyncHeaderComponent extends Component<Args> {
@service declare readonly version: VersionService;
@service declare readonly flags: FlagsService;

get badgeText() {
const isHvdManaged = this.flags.isHvdManaged;
const onLicense = this.version.hasSecretsSync;
const isEnterprise = this.version.isEnterprise;

if (isHvdManaged) return 'Plus feature';
if (isEnterprise && !onLicense) return 'Premium feature';
if (!isEnterprise) return 'Enterprise feature';
// no badge for Enterprise clusters with Secrets Sync on their license--the only remaining option.
return '';
}
}
5 changes: 0 additions & 5 deletions ui/lib/sync/addon/routes/secrets.ts
Monkeychip marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,8 @@ export default class SyncSecretsRoute extends Route {
@service declare readonly router: RouterService;
@service declare readonly flags: FlagService;

beforeModel() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commented in slack about refresh ramifications removing this fetch to only happen in the cluster.js route

return this.flags.fetchActivatedFlags();
}

model() {
return {
// TODO will modify when we use the persona service.
activatedFeatures: this.flags.activatedFlags,
};
}
Expand Down
5 changes: 2 additions & 3 deletions ui/tests/acceptance/clients/counts/sync-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ module('Acceptance | clients | sync', function (hooks) {
});

test('it should show an empty state when secrets sync is not activated', async function (assert) {
assert.expect(3);
assert.expect(2);

this.server.get('/sys/activation-flags', () => {
assert.true(true, '/sys/activation-flags/ is called to check if secrets-sync is activated');

// called once from the higher level application route
return {
data: {
activated: [],
Expand All @@ -59,7 +59,6 @@ module('Acceptance | clients | sync', function (hooks) {
});

assert.dom(GENERAL.emptyStateTitle).exists('Shows empty state when secrets-sync is not activated');

await click(`${GENERAL.emptyStateActions} .hds-link-standalone`);
assert.strictEqual(
currentURL(),
Expand Down
27 changes: 18 additions & 9 deletions ui/tests/acceptance/sync/secrets/overview-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,25 @@ module('Acceptance | sync | overview', function (hooks) {
setupMirage(hooks);

hooks.beforeEach(async function () {
syncHandlers(this.server);
this.version = this.owner.lookup('service:version');
this.version.features = ['Secrets Sync'];

await authPage.login();
// await authPage.login();
});

module('when feature is activated', function (hooks) {
hooks.beforeEach(async function () {
syncScenario(this.server);
syncHandlers(this.server);
this.server.get('/sys/activation-flags', () => {
return {
data: {
activated: ['secrets-sync'],
unactivated: [''],
},
};
});

await authPage.login();
});

test('it fetches destinations and associations', async function (assert) {
Expand All @@ -47,6 +56,7 @@ module('Acceptance | sync | overview', function (hooks) {
module('when there are pre-existing destinations', function (hooks) {
hooks.beforeEach(async function () {
syncScenario(this.server);
await authPage.login();
});

test('it should transition to correct routes when performing actions', async function (assert) {
Expand All @@ -71,7 +81,6 @@ module('Acceptance | sync | overview', function (hooks) {
module('when feature is not activated', function (hooks) {
hooks.beforeEach(async function () {
let wasActivatePOSTCalled = false;

// simulate the feature being activated once /secrets-sync/activate has been called
this.server.get('/sys/activation-flags', () => {
if (wasActivatePOSTCalled) {
Expand All @@ -95,6 +104,7 @@ module('Acceptance | sync | overview', function (hooks) {
wasActivatePOSTCalled = true;
return {};
});
await authPage.login();
});

test('it does not fetch destinations and associations', async function (assert) {
Expand Down Expand Up @@ -145,15 +155,16 @@ module('Acceptance | sync | overview', function (hooks) {
module('enterprise with namespaces', function (hooks) {
hooks.beforeEach(async function () {
this.version.features = ['Secrets Sync', 'Namespaces'];
await authPage.login();
await runCmd(`write sys/namespaces/admin -f`, false);
await authPage.loginNs('admin');
await runCmd(`write sys/namespaces/foo -f`, false);
await authPage.loginNs('admin/foo');
});

test('it should make activation-flag requests to correct namespace', async function (assert) {
assert.expect(4);
// should call GET activation-flags twice because we need an updated response after activating the feature
assert.expect(3);
Monkeychip marked this conversation as resolved.
Show resolved Hide resolved

this.server.get('/sys/activation-flags', (_, req) => {
assert.deepEqual(req.requestHeaders, {}, 'Request is unauthenticated and in root namespace');
return {
Expand All @@ -174,16 +185,14 @@ module('Acceptance | sync | overview', function (hooks) {

// confirm we're in admin/foo
assert.dom('[data-test-badge-namespace]').hasText('foo');

await click(ts.navLink('Secrets Sync'));
await click(ts.overview.optInBanner.enable);
await click(ts.overview.activationModal.checkbox);
await click(ts.overview.activationModal.confirm);
});

test('it should make activation-flag requests to correct namespace when managed', async function (assert) {
assert.expect(4);
// should call GET activation-flags twice because we need an updated response after activating the feature
assert.expect(3);
this.owner.lookup('service:flags').featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];

this.server.get('/sys/activation-flags', (_, req) => {
Expand Down
Loading