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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,25 @@ This file will also be used when running in Docker.
{
"API_URL": "http://localhost:9000",
"LOGIN_BANNER": "",
"LOGIN_BANNER_SEVERITY": "info"
"LOGIN_BANNER_SEVERITY": "info",
"FILTER_COMPONENT_LINKING_ENABLED": false
}
```

`API_URL` is the url to connect to for the CCF API
`LOGIN_BANNER` displays a dismissible notice on the login page when set to a non-empty string.
`LOGIN_BANNER_SEVERITY` controls its styling and can be `info`, `warn`, `error`, or `success`.
`FILTER_COMPONENT_LINKING_ENABLED` shows the UI for linking filters to system components

Developers can override these values at build time with `VITE_LOGIN_BANNER` and
`VITE_LOGIN_BANNER_SEVERITY`. `VITE_LOGIN_BANNER` takes precedence over `config.json`; setting it to
an explicit empty string disables the banner for that build without editing `config.json`.

`FILTER_COMPONENT_LINKING_ENABLED` is a feature flag (default `false`) that shows the UI for linking
filters to system components: the component "Dashboards" drawer on SSP pages and the Components
selector on the filter form. It can be overridden at build time with
`VITE_FILTER_COMPONENT_LINKING_ENABLED=true|false`.

#### In Production

Mount a file called `/app/config.json` with your configuration.
Expand Down
7 changes: 2 additions & 5 deletions env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
readonly VITE_LOGIN_BANNER?: string;
readonly VITE_LOGIN_BANNER_SEVERITY?:
| 'info'
| 'warn'
| 'error'
| 'success';
readonly VITE_LOGIN_BANNER_SEVERITY?: 'info' | 'warn' | 'error' | 'success';
readonly VITE_FILTER_COMPONENT_LINKING_ENABLED?: string;
}

interface ImportMeta {
Expand Down
14 changes: 12 additions & 2 deletions src/components/system-security-plans/ComponentsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@
</template>
<div class="py-3 px-4 flex justify-end items-center">
<div class="flex gap-2">
<TertiaryButton @click.stop="openDashboardDrawer(component)">
<TertiaryButton
v-if="filterComponentLinkingEnabled"
@click.stop="openDashboardDrawer(component)"
>
Dashboards
</TertiaryButton>
<TertiaryButton
Expand Down Expand Up @@ -158,6 +161,7 @@

<!-- Component Dashboard Drawer -->
<Drawer
v-if="filterComponentLinkingEnabled"
v-model:visible="dashboardDrawerOpen"
header="Evidence Filters"
position="right"
Expand Down Expand Up @@ -206,7 +210,7 @@

<script setup lang="ts">
import TooltipTitle from '@/components/TooltipTitle.vue';
import { computed, watch, ref } from 'vue';
import { computed, onMounted, watch, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { downloadJson } from '@/utils/download-json';
Expand All @@ -225,6 +229,7 @@ import { useDataApi } from '@/composables/axios';
import { useDeleteConfirmationDialog } from '@/utils/delete-dialog';
import { usePermissions } from '@/composables/usePermissions';
import { RESOURCES, ACTIONS } from '@/constants/permissions';
import { useConfigStore } from '@/stores/config';
import type { Risk, SystemComponent, SystemUser } from '@/oscal';
import {
getRiskComponentIds,
Expand All @@ -245,6 +250,11 @@ const router = useRouter();
const toast = useToast();
const { can, permissionTooltip } = usePermissions();
const { confirmDeleteDialog } = useDeleteConfirmationDialog();
const configStore = useConfigStore();
const filterComponentLinkingEnabled = computed(
() => configStore.filterComponentLinkingEnabled,
);
onMounted(() => configStore.getConfig());

const componentsEndpoint = computed(() => {
if (!props.sspId) return null;
Expand Down
3 changes: 2 additions & 1 deletion src/defaultconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"API_URL": "",
"LOGIN_BANNER": "",
"LOGIN_BANNER_SEVERITY": "info"
"LOGIN_BANNER_SEVERITY": "info",
"FILTER_COMPONENT_LINKING_ENABLED": false
}
31 changes: 31 additions & 0 deletions src/stores/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe('config store', () => {
vi.stubEnv('VITE_API_URL', '');
vi.stubEnv('VITE_LOGIN_BANNER', undefined);
vi.stubEnv('VITE_LOGIN_BANNER_SEVERITY', undefined);
vi.stubEnv('VITE_FILTER_COMPONENT_LINKING_ENABLED', undefined);
});

afterEach(() => {
Expand Down Expand Up @@ -104,4 +105,34 @@ describe('config store', () => {
expect(config.LOGIN_BANNER).toBe('Local build notice');
expect(config.LOGIN_BANNER_SEVERITY).toBe('success');
});

it('disables filter component linking by default', async () => {
mockConfigResponse({ API_URL: 'https://api.example.test' });

const store = useConfigStore();
expect(store.filterComponentLinkingEnabled).toBe(false);
const config = await store.getConfig();

expect(config.FILTER_COMPONENT_LINKING_ENABLED).toBe(false);
expect(store.filterComponentLinkingEnabled).toBe(false);
});

it('enables filter component linking from config.json', async () => {
mockConfigResponse({ FILTER_COMPONENT_LINKING_ENABLED: true });

const store = useConfigStore();
await store.getConfig();

expect(store.filterComponentLinkingEnabled).toBe(true);
});

it('lets VITE_FILTER_COMPONENT_LINKING_ENABLED override config.json', async () => {
vi.stubEnv('VITE_FILTER_COMPONENT_LINKING_ENABLED', 'false');
mockConfigResponse({ FILTER_COMPONENT_LINKING_ENABLED: true });

const store = useConfigStore();
await store.getConfig();

expect(store.filterComponentLinkingEnabled).toBe(false);
});
});
24 changes: 20 additions & 4 deletions src/stores/config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import builtInConfig from '../defaultconfig.json';

export interface Config {
API_URL: string;
LOGIN_BANNER?: string;
LOGIN_BANNER_SEVERITY?: LoginBannerSeverity;
// Feature flag: allow filters (dashboards) to be linked to system components.
FILTER_COMPONENT_LINKING_ENABLED?: boolean;
}

const loginBannerSeverities = ['info', 'warn', 'error', 'success'] as const;
Expand All @@ -20,9 +22,18 @@ function isLoginBannerSeverity(value: unknown): value is LoginBannerSeverity {
);
}

function applyBannerOverrides(cfg: Config): Config {
function parseBooleanEnv(value: string | undefined): boolean | undefined {
if (value === 'true') return true;
if (value === 'false') return false;
return undefined;
}

function applyOverrides(cfg: Config): Config {
return {
...cfg,
FILTER_COMPONENT_LINKING_ENABLED:
parseBooleanEnv(import.meta.env.VITE_FILTER_COMPONENT_LINKING_ENABLED) ??
cfg.FILTER_COMPONENT_LINKING_ENABLED === true,
LOGIN_BANNER:
import.meta.env.VITE_LOGIN_BANNER !== undefined
? import.meta.env.VITE_LOGIN_BANNER
Expand All @@ -48,7 +59,7 @@ export const useConfigStore = defineStore('config', () => {
}

if (import.meta.env.VITE_API_URL) {
config.value = applyBannerOverrides({
config.value = applyOverrides({
...defaultConfig,
API_URL: import.meta.env.VITE_API_URL,
});
Expand All @@ -72,13 +83,17 @@ export const useConfigStore = defineStore('config', () => {
}
}
}
config.value = applyBannerOverrides({
config.value = applyOverrides({
...defaultConfig,
...returnedConfig,
});
return config.value;
}

const filterComponentLinkingEnabled = computed(
() => config.value?.FILTER_COMPONENT_LINKING_ENABLED === true,
);

function toggleLabels() {
showLabels.value = !showLabels.value;
}
Expand All @@ -93,5 +108,6 @@ export const useConfigStore = defineStore('config', () => {
toggleLabels,
toggleHiddenLabels,
getConfig,
filterComponentLinkingEnabled,
};
});
11 changes: 9 additions & 2 deletions src/views/dashboard/partials/FilterForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
</MultiSelect>
</div>

<div class="mb-4">
<div v-if="filterComponentLinkingEnabled" class="mb-4">
<label class="inline-block pb-2">Components</label>
<MultiSelect
v-model="selectedComponents"
Expand Down Expand Up @@ -107,7 +107,7 @@
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { FilterParser, serializeFilter } from '@/parsers/labelfilter.ts';
import type { Dashboard, DashboardCreate } from '@/stores/filters.ts';
import FormInput from '@/components/forms/FormInput.vue';
Expand All @@ -124,6 +124,7 @@ import type {
import { useDataApi } from '@/composables/axios';
import { usePermissions } from '@/composables/usePermissions';
import { RESOURCES, ACTIONS } from '@/constants/permissions';
import { useConfigStore } from '@/stores/config';

const props = withDefaults(
defineProps<{
Expand All @@ -149,6 +150,12 @@ const emit = defineEmits<{

const { can, permissionTooltip } = usePermissions();

const configStore = useConfigStore();
const filterComponentLinkingEnabled = computed(
() => configStore.filterComponentLinkingEnabled,
);
onMounted(() => configStore.getConfig());

// Editing requires update, creating requires create.
const permissionAction = computed(() =>
props.dashboard ? ACTIONS.UPDATE : ACTIONS.CREATE,
Expand Down
42 changes: 40 additions & 2 deletions src/views/dashboard/partials/__tests__/FilterForm.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import type { Dashboard } from '@/stores/filters';
import type { Control, SystemComponent } from '@/oscal';
import { FilterParser } from '@/parsers/labelfilter';

const configState = vi.hoisted(() => ({
filterComponentLinkingEnabled: true,
}));

vi.mock('@/stores/config', () => ({
useConfigStore: () => ({
get filterComponentLinkingEnabled() {
return configState.filterComponentLinkingEnabled;
},
getConfig: async () => ({}),
}),
}));

vi.mock('@/composables/usePermissions', () => ({
usePermissions: () => ({ can: () => true, permissionTooltip: () => '' }),
}));
Expand Down Expand Up @@ -47,7 +60,10 @@ function mountForm(dashboard: Dashboard) {
template: '<input :value="modelValue" />',
},
Select: { props: ['modelValue'], template: '<div />' },
MultiSelect: { props: ['modelValue'], template: '<div />' },
MultiSelect: {
props: ['modelValue', 'placeholder'],
template: '<div class="multiselect">{{ placeholder }}</div>',
},
PrimaryButton: {
template: '<button type="submit"><slot /></button>',
},
Expand All @@ -60,6 +76,10 @@ function mountForm(dashboard: Dashboard) {
}

describe('FilterForm (edit prefill)', () => {
beforeEach(() => {
configState.filterComponentLinkingEnabled = true;
});

it('prefills name and the serialized filter string', () => {
const wrapper = mountForm(makeDashboard());
const inputs = wrapper.findAll('input');
Expand Down Expand Up @@ -90,4 +110,22 @@ describe('FilterForm (edit prefill)', () => {
new FilterParser('team=payments AND env!=dev').parse(),
);
});

it('shows the Components selector when filter component linking is enabled', () => {
const wrapper = mountForm(makeDashboard());
expect(wrapper.text()).toContain('Select Components');
});

it('hides the Components selector but keeps existing links when the flag is disabled', async () => {
configState.filterComponentLinkingEnabled = false;
const wrapper = mountForm(makeDashboard());
expect(wrapper.text()).not.toContain('Select Components');
expect(wrapper.text()).toContain('Select Controls');

await wrapper.find('form').trigger('submit');
const payload = wrapper.emitted('submit')?.[0]?.[0] as {
components: string[];
};
expect(payload.components).toEqual(['comp-1']);
});
});
37 changes: 37 additions & 0 deletions src/views/system/__tests__/systemViews.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ const {
routerPush,
toastAdd,
loadInitialProfiles,
configState,
} = vi.hoisted(() => {
return {
configState: { filterComponentLinkingEnabled: false },
activePlan: {
uuid: 'ssp-1',
metadata: {
Expand Down Expand Up @@ -57,6 +59,15 @@ vi.mock('@/stores/system.ts', () => ({
}),
}));

vi.mock('@/stores/config', () => ({
useConfigStore: () => ({
get filterComponentLinkingEnabled() {
return configState.filterComponentLinkingEnabled;
},
getConfig: vi.fn(async () => ({})),
}),
}));

vi.mock('@/utils/delete-dialog', () => ({
useDeleteConfirmationDialog: () => ({
confirmDeleteDialog: vi.fn(),
Expand Down Expand Up @@ -199,6 +210,7 @@ describe('System area views', () => {
routerPush.mockReset();
toastAdd.mockReset();
loadInitialProfiles.mockClear();
configState.filterComponentLinkingEnabled = false;
});

it.each([null, []])(
Expand Down Expand Up @@ -235,6 +247,31 @@ describe('System area views', () => {
},
);

it.each([
[false, false],
[true, true],
])(
'shows the component Dashboards button only when filter component linking is enabled (%s)',
async (enabled, expected) => {
configState.filterComponentLinkingEnabled = enabled;
apiPayloads.set(endpoint.components, [
{ uuid: 'comp-1', title: 'API Gateway', type: 'software' },
]);
apiPayloads.set('/api/oscal/system-security-plans/ssp-1/risks', []);
apiPayloads.set(endpoint.users, []);

const wrapper = mount(ComponentsView, {
global: { stubs },
});
await flushPromises();

expect(wrapper.text()).toContain('API Gateway');
expect(
wrapper.findAll('button').some((b) => b.text() === 'Dashboards'),
).toBe(expected);
},
);

it.each([null, []])(
'renders the leveraged authorizations empty state when the API returns %s',
async (payload) => {
Expand Down
Loading