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 all 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 this.hasSecretsSyncClients this.flags.showSecretsSync}} />

{{! 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 '';
}
}
4 changes: 4 additions & 0 deletions ui/app/routes/vault/cluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
'Cannot use VAULT_CLOUD_ADMIN_NAMESPACE flag with non-enterprise Vault version',
!(managedRoot && this.version.isCommunity)
);

// activatedFlags are called this high in routing to return a response used to show/hide Secrets sync on sidebar nav.
Copy link
Contributor

Choose a reason for hiding this comment

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

Thank you for this comment!

await this.flagsService.fetchActivatedFlags();

if (!namespace && currentTokenName && !Ember.testing) {
// if no namespace queryParam and user authenticated,
// use user's root namespace to redirect to properly param'd url
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,9 +62,9 @@ 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.
if (this.version.isCommunity) return;
Copy link
Contributor

Choose a reason for hiding this comment

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

Edge case question - if a customer downgrades from enterprise, are their activated flags preserved? (Not necessary for this PR, mostly just curious)

Copy link
Contributor Author

@Monkeychip Monkeychip May 31, 2024

Choose a reason for hiding this comment

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

I think I've asked Robert this, and more or less no. I think because there's a conditional wrapping that endpoint which is checking for enterprise, so it would return nothing. That being said I haven't tested this workflow.

try {
const response = await this.store
.adapterFor('application')
Expand All @@ -86,4 +88,21 @@ 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;
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 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;
}
}
6 changes: 6 additions & 0 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,12 @@ 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',
github: 'sys/sync/github-apps',
Monkeychip marked this conversation as resolved.
Show resolved Hide resolved
},
};

const API_PATHS_TO_ROUTE_PARAMS = {
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" />
{{/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
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
8 changes: 8 additions & 0 deletions ui/lib/sync/addon/routes/secrets/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { service } from '@ember/service';
import { hash } from 'rsvp';

import type FlagsService from 'vault/services/flags';
import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type VersionService from 'vault/services/version';

export default class SyncSecretsOverviewRoute extends Route {
@service declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly flags: FlagsService;
@service declare readonly version: VersionService;
Expand All @@ -34,4 +36,10 @@ export default class SyncSecretsOverviewRoute extends Route {
: [],
});
}

redirect() {
if (!this.flags.showSecretsSync) {
this.router.replaceWith('vault.cluster.dashboard');
}
}
}
5 changes: 4 additions & 1 deletion ui/mirage/handlers/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Response } from 'miragejs';
import { camelize } from '@ember/string';
import { findDestination } from 'core/helpers/sync-destinations';
import clientsHandler from './clients';
import modifyPassthroughResponse from '../helpers/modify-passthrough-response';

export const associationsResponse = (schema, req) => {
const { type, name } = req.params;
Expand Down Expand Up @@ -116,7 +117,9 @@ const createOrUpdateDestination = (schema, req) => {
};

export default function (server) {
// default to activated
// default to enterprise with Secrets Sync on the license and activated
server.get('sys/health', (schema, req) => modifyPassthroughResponse(req, { enterprise: true }));
server.get('/sys/license/features', () => ({ features: ['Secrets Sync'] }));
server.get('/sys/activation-flags', () => {
return {
data: {
Expand Down
1 change: 1 addition & 0 deletions ui/mirage/helpers/modify-passthrough-response.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

// passthrough request and modify response from server
// pass object as second arg of properties in response to override
// ex: server.get('sys/health', (schema, req) => modifyPassthroughResponse(req, { enterprise: true }));
export default function (req, props = {}) {
return new Promise((resolve) => {
const xhr = req.passthrough();
Expand Down
2 changes: 1 addition & 1 deletion ui/tests/acceptance/clients/counts/overview-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ module('Acceptance | clients | overview | sync in license, activated', function
});

test('it should render the correct tabs', async function (assert) {
assert.dom(GENERAL.tab('sync')).exists();
assert.dom(GENERAL.tab('sync')).exists('shows the sync tab');
});

test('it should show secrets sync stats', async function (assert) {
Expand Down
17 changes: 14 additions & 3 deletions ui/tests/acceptance/clients/counts/sync-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ module('Acceptance | clients | sync', function (hooks) {
hooks.beforeEach(async function () {
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
syncHandler(this.server);
const version = this.owner.lookup('service:version');
version.type = 'enterprise';
await authPage.login();
return visit('/vault/clients/counts/sync');
});
Expand All @@ -34,22 +36,31 @@ module('Acceptance | clients | sync', function (hooks) {
});
});

module('sync not activated', function (hooks) {
module('sync not activated and on license', function (hooks) {
hooks.beforeEach(async function () {
this.server.get('/sys/internal/counters/config', function () {
return CONFIG_RESPONSE;
});
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
syncHandler(this.server);
server.get('/sys/activation-flags', () => {
return {
data: {
activated: [''],
unactivated: ['secrets-sync'],
},
};
});
await authPage.login();
return visit('/vault/clients/counts/sync');
});

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 cluster route
return {
data: {
activated: [],
Expand Down
2 changes: 1 addition & 1 deletion ui/tests/acceptance/sync/secrets/destination-test.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.

I've done this several times now on the destination vs destinations test, I stumble over what the heck makes these test names different. I clarified it for myself 😬

Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { settled, click, visit, currentURL, fillIn, currentRouteName } from '@em
import { PAGE as ts } from 'vault/tests/helpers/sync/sync-selectors';

// sync is an enterprise feature but since mirage is used the enterprise label has been intentionally omitted from the module name
module('Acceptance | sync | destination', function (hooks) {
module('Acceptance | sync | destination (singular)', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);

Expand Down
2 changes: 1 addition & 1 deletion ui/tests/acceptance/sync/secrets/destinations-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { syncDestinations } from 'vault/helpers/sync-destinations';
const SYNC_DESTINATIONS = syncDestinations();

// sync is an enterprise feature but since mirage is used the enterprise label has been intentionally omitted from the module name
module('Acceptance | sync | destinations', function (hooks) {
module('Acceptance | sync | destinations (plural)', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);

Expand Down
Loading
Loading