diff --git a/src/components/system-security-plans/SubscribeOfferingWizard.vue b/src/components/system-security-plans/SubscribeOfferingWizard.vue new file mode 100644 index 00000000..81e1c309 --- /dev/null +++ b/src/components/system-security-plans/SubscribeOfferingWizard.vue @@ -0,0 +1,231 @@ + + + diff --git a/src/constants/permissions.ts b/src/constants/permissions.ts index c5b4f80b..ddfe99ad 100644 --- a/src/constants/permissions.ts +++ b/src/constants/permissions.ts @@ -64,6 +64,7 @@ export const ACTIONS = { DELETE: 'delete', PROMOTE: 'promote', EXPORT: 'export', + SUBSCRIBE: 'subscribe', INGEST: 'ingest', REGISTER: 'register', // admin actions @@ -150,6 +151,7 @@ const ACTION_VERBS: Partial> = { [ACTIONS.DELETE]: 'delete', [ACTIONS.PROMOTE]: 'promote', [ACTIONS.EXPORT]: 'publish', + [ACTIONS.SUBSCRIBE]: 'subscribe to', [ACTIONS.REGISTER]: 'register', [ACTIONS.INGEST]: 'ingest', [ACTIONS.EXECUTE]: 'import', diff --git a/src/router/index.ts b/src/router/index.ts index 2fe7c6ef..b7492dce 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -761,6 +761,14 @@ const authenticatedRoutes = [ '../views/system-security-plans/SystemSecurityPlanExportOfferingsView.vue' ), }, + { + path: 'leverage', + name: 'system-security-plan-leverage', + component: () => + import( + '../views/system-security-plans/SystemSecurityPlanLeverageView.vue' + ), + }, { path: 'risks', name: 'system-security-plan-risks', diff --git a/src/types/ssp-export-offerings.ts b/src/types/ssp-export-offerings.ts index 15f1520a..c5ab4b7e 100644 --- a/src/types/ssp-export-offerings.ts +++ b/src/types/ssp-export-offerings.ts @@ -31,3 +31,20 @@ export interface SSPExportOffering { updatedAt: string; items?: SSPExportOfferingItem[]; } + +// The flat, cross-SSP catalog (GET /oscal/ssp-export-offerings) resolves each item's +// upstream responsibilities server-side, but deliberately omits any upstream SSP +// title/metadata — only a bare sspId. Never resolve that into a friendly name (would +// require ssp:read on the upstream SSP, the exact trust boundary BCH-1345 must not cross). +export interface UpstreamResponsibility { + responsibilityUuid: string; + description: string; +} + +export interface CatalogOfferingItem extends SSPExportOfferingItem { + responsibilities: UpstreamResponsibility[]; +} + +export interface CatalogOffering extends Omit { + items?: CatalogOfferingItem[]; +} diff --git a/src/types/ssp-leverage.ts b/src/types/ssp-leverage.ts new file mode 100644 index 00000000..0fd1485b --- /dev/null +++ b/src/types/ssp-leverage.ts @@ -0,0 +1,38 @@ +// Types for the downstream SSP leverage/subscribe endpoints (BCH-1338). Hand-written, +// camelCase JSON — not OSCAL — so requests must not use decamelizeKeys. + +export type SSPLeverageSatisfaction = 'full' | 'partial'; + +export type SSPLeverageStatus = 'active' | 'drifted' | 'revoked' | 'superseded'; + +export interface SSPLeverageLink { + id: string; + downstreamSspId: string; + upstreamSspId: string; + offeringId: string; + offeringVersion: number; + controlId: string; + statementId?: string; + providedUuid: string; + inheritedUuid: string; + leveragedAuthUuid: string; + satisfaction: SSPLeverageSatisfaction; + status: SSPLeverageStatus; + attestedAt?: string; + attestedById?: string; + createdAt: string; + updatedAt: string; +} + +export interface SubscribeRequest { + downstreamSspId: string; + leveragedAuthorization: { + title: string; + partyUuid: string; + dateAuthorized?: string; + }; + items: Array<{ + itemId: string; + satisfiedResponsibilityUuids?: string[]; + }>; +} diff --git a/src/views/system-security-plans/SystemSecurityPlanEditorView.vue b/src/views/system-security-plans/SystemSecurityPlanEditorView.vue index be2e98f2..c8028b8a 100644 --- a/src/views/system-security-plans/SystemSecurityPlanEditorView.vue +++ b/src/views/system-security-plans/SystemSecurityPlanEditorView.vue @@ -60,6 +60,15 @@ > Export Offerings + + Leverage + +
+
+

+ Leverage +

+ +
+

+ Loading published export offerings... +

+
+ +
+

+ No published export offerings available yet. +

+
+ +
+
+
+
+
+

+ {{ offering.title }} +

+ + v{{ offering.version }} + +
+

+ {{ offering.description }} +

+

+ {{ itemSummary(offering) }} +

+
+ + + + Subscribe + + + +
+
+
+
+ + + + +
+ + + diff --git a/src/views/system-security-plans/__tests__/SystemSecurityPlanLeverageView.spec.ts b/src/views/system-security-plans/__tests__/SystemSecurityPlanLeverageView.spec.ts new file mode 100644 index 00000000..7ed661f8 --- /dev/null +++ b/src/views/system-security-plans/__tests__/SystemSecurityPlanLeverageView.spec.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { flushPromises, mount } from '@vue/test-utils'; +import SystemSecurityPlanLeverageView from '../SystemSecurityPlanLeverageView.vue'; +import { RESOURCES, ACTIONS } from '@/constants/permissions'; +import type { CatalogOffering } from '@/types/ssp-export-offerings'; + +const UPSTREAM_SCOPED_SSP_PATH = /system-security-plans\/(?!undefined)/; + +const { postMock, toastAddMock, permState, offeringsData } = vi.hoisted(() => ({ + postMock: vi.fn(), + toastAddMock: vi.fn(), + permState: { canSubscribe: true, canUpdateSsp: true }, + offeringsData: { current: [] as CatalogOffering[] }, +})); + +vi.mock('vue-router', () => ({ + useRoute: () => ({ params: { id: 'ssp-downstream-1' } }), +})); + +vi.mock('primevue/usetoast', () => ({ + useToast: () => ({ add: toastAddMock }), +})); + +vi.mock('@/composables/usePermissions', () => ({ + usePermissions: () => ({ + can: (resource: string, action: string) => { + if ( + resource === RESOURCES.SSP_EXPORT_OFFERING && + action === ACTIONS.SUBSCRIBE + ) { + return permState.canSubscribe; + } + if (resource === RESOURCES.SSP && action === ACTIONS.UPDATE) { + return permState.canUpdateSsp; + } + return true; + }, + permissionTooltip: () => '', + }), +})); + +const fetchedUrls: string[] = []; +vi.mock('@/composables/axios', async () => { + const { ref } = await import('vue'); + return { + useDataApi: (url: string) => { + fetchedUrls.push(url); + return { data: ref(offeringsData.current), isLoading: ref(false) }; + }, + useAuthenticatedInstance: () => ({ post: postMock }), + }; +}); + +const stubs = { + Dialog: { + props: ['visible'], + template: '
', + }, + PrimaryButton: { + props: ['disabled'], + emits: ['click'], + template: + '', + }, + SecondaryButton: { + emits: ['click'], + template: '', + }, + InputText: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: + '', + }, + Label: { template: '' }, + Message: { template: '
' }, +}; + +function makeOffering( + overrides: Partial = {}, +): CatalogOffering { + return { + id: 'offering-1', + sspId: 'ssp-upstream-1', + title: 'GovCloud Baseline', + description: 'A useful offering', + version: 2, + status: 'published', + contentHash: '', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + items: [ + { + id: 'item-1', + offeringId: 'offering-1', + controlId: 'ac-1', + componentUuid: 'comp-1', + providedUuid: 'p-1', + responsibilities: [ + { responsibilityUuid: 'r-1', description: 'Rotate credentials' }, + ], + }, + ], + ...overrides, + }; +} + +function findButton(wrapper: ReturnType, text: string) { + const button = wrapper.findAll('button').find((b) => b.text() === text); + if (!button) throw new Error(`Button with text "${text}" not found`); + return button; +} + +function mountView() { + return mount(SystemSecurityPlanLeverageView, { global: { stubs } }); +} + +describe('SystemSecurityPlanLeverageView', () => { + beforeEach(() => { + vi.clearAllMocks(); + permState.canSubscribe = true; + permState.canUpdateSsp = true; + offeringsData.current = []; + fetchedUrls.length = 0; + postMock.mockResolvedValue({ data: { data: [] } }); + }); + + it('renders an empty state when there are no published offerings', () => { + const wrapper = mountView(); + expect(wrapper.text()).toContain( + 'No published export offerings available yet.', + ); + }); + + it('renders an offering with title, version, and item summary', () => { + offeringsData.current = [makeOffering()]; + const wrapper = mountView(); + expect(wrapper.text()).toContain('GovCloud Baseline'); + expect(wrapper.text()).toContain('v2'); + expect(wrapper.text()).toContain('1 item: ac-1'); + }); + + it('never fetches anything upstream-scoped — only the flat catalog', () => { + offeringsData.current = [makeOffering()]; + mountView(); + expect(fetchedUrls).toEqual(['/api/oscal/ssp-export-offerings']); + for (const url of fetchedUrls) { + expect(url).not.toContain('ssp-upstream-1'); + expect(url).not.toMatch(UPSTREAM_SCOPED_SSP_PATH); + } + }); + + it('subscribes to selected items with satisfied responsibilities', async () => { + offeringsData.current = [makeOffering()]; + postMock.mockResolvedValueOnce({ + data: { + data: [ + { + id: 'link-1', + downstreamSspId: 'ssp-downstream-1', + upstreamSspId: 'ssp-upstream-1', + offeringId: 'offering-1', + offeringVersion: 2, + controlId: 'ac-1', + providedUuid: 'p-1', + inheritedUuid: 'inherited-1', + leveragedAuthUuid: 'auth-1', + satisfaction: 'full', + status: 'active', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ], + }, + }); + + const wrapper = mountView(); + await findButton(wrapper, 'Subscribe').trigger('click'); + + const itemCheckbox = wrapper.find('input[type="checkbox"]'); + await itemCheckbox.setValue(true); + + const responsibilityCheckboxes = wrapper.findAll('input[type="checkbox"]'); + await responsibilityCheckboxes[1].setValue(true); + + const inputs = wrapper.findAll( + 'input:not([type="checkbox"]):not([type="date"])', + ); + await inputs[0].setValue('My Leveraged Auth'); + await inputs[1].setValue('11111111-1111-1111-1111-111111111111'); + + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(postMock).toHaveBeenCalledWith( + '/api/oscal/ssp-export-offerings/offering-1/subscribe', + { + downstreamSspId: 'ssp-downstream-1', + leveragedAuthorization: { + title: 'My Leveraged Auth', + partyUuid: '11111111-1111-1111-1111-111111111111', + }, + items: [{ itemId: 'item-1', satisfiedResponsibilityUuids: ['r-1'] }], + }, + ); + expect(toastAddMock).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'success' }), + ); + }); + + it('blocks submitting with zero items selected', async () => { + offeringsData.current = [makeOffering()]; + const wrapper = mountView(); + await findButton(wrapper, 'Subscribe').trigger('click'); + + const inputs = wrapper.findAll( + 'input:not([type="checkbox"]):not([type="date"])', + ); + await inputs[0].setValue('My Leveraged Auth'); + await inputs[1].setValue('11111111-1111-1111-1111-111111111111'); + + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(postMock).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('Select at least one item to inherit.'); + }); + + it('hides Subscribe without ssp-export-offering:subscribe', () => { + permState.canSubscribe = false; + offeringsData.current = [makeOffering()]; + const wrapper = mountView(); + expect(wrapper.findAll('button').map((b) => b.text())).not.toContain( + 'Subscribe', + ); + }); + + it('hides Subscribe without ssp:update on the current SSP', () => { + permState.canUpdateSsp = false; + offeringsData.current = [makeOffering()]; + const wrapper = mountView(); + expect(wrapper.findAll('button').map((b) => b.text())).not.toContain( + 'Subscribe', + ); + }); + + it('hides Subscribe when both permissions are denied', () => { + permState.canSubscribe = false; + permState.canUpdateSsp = false; + offeringsData.current = [makeOffering()]; + const wrapper = mountView(); + expect(wrapper.findAll('button').map((b) => b.text())).not.toContain( + 'Subscribe', + ); + }); +});