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
231 changes: 231 additions & 0 deletions src/components/system-security-plans/SubscribeOfferingWizard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
<template>
<form @submit.prevent="submit" class="space-y-6">
<div>
<h4 class="text-sm font-medium dark:text-slate-300 mb-2">
Items to inherit
</h4>
<div class="space-y-3">
<div
v-for="item in offering.items ?? []"
:key="item.id"
class="p-3 border border-ccf-200 dark:border-slate-600 rounded"
>
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="selectedItemIds.has(item.id)"
@change="toggleItem(item.id)"
/>
<span class="text-sm dark:text-slate-300">
{{ item.controlId
}}<span v-if="item.statementId">
· Statement {{ item.statementId }}</span
>
</span>
</label>

<div
v-if="selectedItemIds.has(item.id) && item.responsibilities.length"
class="mt-2 ml-6 space-y-1"
>
<div class="text-xs text-gray-500 dark:text-slate-400">
Responsibilities we will satisfy:
</div>
<label
v-for="responsibility in item.responsibilities"
:key="responsibility.responsibilityUuid"
class="flex items-center gap-2"
>
<input
type="checkbox"
:checked="
isSatisfied(item.id, responsibility.responsibilityUuid)
"
@change="
toggleSatisfied(item.id, responsibility.responsibilityUuid)
"
/>
<span class="text-sm dark:text-slate-300">
{{ responsibility.description }}
</span>
</label>
</div>
</div>
</div>
</div>

<div>
<h4 class="text-sm font-medium dark:text-slate-300 mb-2">
Leveraged Authorization
</h4>
<div class="space-y-3">
<div>
<Label for="leverage-title" required>Title</Label>
<InputText
id="leverage-title"
v-model="leveragedAuth.title"
class="w-full"
/>
</div>
<div>
<Label for="leverage-party-uuid" required>Party UUID</Label>
<InputText
id="leverage-party-uuid"
v-model="leveragedAuth.partyUuid"
placeholder="UUID of the party providing the authorization"
class="w-full"
/>
</div>
<div>
<Label for="leverage-date-authorized">Date Authorized</Label>
<input
id="leverage-date-authorized"
v-model="leveragedAuth.dateAuthorized"
type="date"
class="w-full p-2 border border-ccf-300 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 dark:text-slate-300"
/>
</div>
</div>
</div>

<Message v-if="validationError" severity="error">
{{ validationError }}
</Message>

<div
class="flex justify-end gap-3 pt-2 border-t border-gray-200 dark:border-slate-700"
>
<SecondaryButton type="button" @click="$emit('cancel')">
Cancel
</SecondaryButton>
<PrimaryButton type="submit" :disabled="submitting">
{{ submitting ? 'Subscribing...' : 'Subscribe' }}
</PrimaryButton>
</div>
</form>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useToast } from 'primevue/usetoast';
import InputText from '@/volt/InputText.vue';
import Label from '@/volt/Label.vue';
import Message from '@/volt/Message.vue';
import PrimaryButton from '@/volt/PrimaryButton.vue';
import SecondaryButton from '@/volt/SecondaryButton.vue';
import { useAuthenticatedInstance } from '@/composables/axios';
import type { DataResponse, ErrorBody, ErrorResponse } from '@/stores/types';
import type { CatalogOffering } from '@/types/ssp-export-offerings';
import type { SSPLeverageLink, SubscribeRequest } from '@/types/ssp-leverage';
import type { AxiosError } from 'axios';

const props = defineProps<{
sspId: string;
offering: CatalogOffering;
}>();

const emit = defineEmits<{
cancel: [];
subscribed: [links: SSPLeverageLink[]];
}>();

const toast = useToast();
const axiosInstance = useAuthenticatedInstance();

const selectedItemIds = ref(new Set<string>());
const satisfiedByItem = ref(new Map<string, Set<string>>());
const leveragedAuth = reactive({
title: '',
partyUuid: '',
dateAuthorized: '',
});
const submitting = ref(false);
const validationError = ref('');

function toggleItem(itemId: string) {
if (selectedItemIds.value.has(itemId)) {
selectedItemIds.value.delete(itemId);
} else {
selectedItemIds.value.add(itemId);
}
}

function isSatisfied(itemId: string, responsibilityUuid: string): boolean {
return satisfiedByItem.value.get(itemId)?.has(responsibilityUuid) ?? false;
}

function toggleSatisfied(itemId: string, responsibilityUuid: string) {
if (!satisfiedByItem.value.has(itemId)) {
satisfiedByItem.value.set(itemId, new Set());
}
const set = satisfiedByItem.value.get(itemId)!;
if (set.has(responsibilityUuid)) {
set.delete(responsibilityUuid);
} else {
set.add(responsibilityUuid);
}
}

// Converts an <input type="date"> value ("YYYY-MM-DD") to a full RFC3339 timestamp —
// Go's time.Parse(time.RFC3339, ...) rejects a bare date, which is all a date input gives.
function toRfc3339(dateOnly: string): string {
return new Date(dateOnly).toISOString().replace(/\.\d+Z$/, 'Z');
}

async function submit() {
if (submitting.value) return;
validationError.value = '';
if (selectedItemIds.value.size === 0) {
validationError.value = 'Select at least one item to inherit.';
return;
}
if (!leveragedAuth.title.trim() || !leveragedAuth.partyUuid.trim()) {
validationError.value =
'Leveraged Authorization title and Party UUID are required.';
return;
}

const body: SubscribeRequest = {
downstreamSspId: props.sspId,
leveragedAuthorization: {
title: leveragedAuth.title.trim(),
partyUuid: leveragedAuth.partyUuid.trim(),
...(leveragedAuth.dateAuthorized
? { dateAuthorized: toRfc3339(leveragedAuth.dateAuthorized) }
: {}),
},
items: [...selectedItemIds.value].map((itemId) => ({
itemId,
satisfiedResponsibilityUuids: [
...(satisfiedByItem.value.get(itemId) ?? []),
],
})),
};

submitting.value = true;
try {
Comment thread
saltpy-cs marked this conversation as resolved.
const response = await axiosInstance.post<DataResponse<SSPLeverageLink[]>>(
`/api/oscal/ssp-export-offerings/${props.offering.id}/subscribe`,
body,
);
toast.add({
severity: 'success',
summary: `Subscribed to ${response.data.data.length} item${response.data.data.length === 1 ? '' : 's'}.`,
life: 3000,
});
emit('subscribed', response.data.data);
} catch (error) {
const errorResponse = error as AxiosError<ErrorResponse<ErrorBody>>;
toast.add({
severity: 'error',
summary: 'Error',
detail:
errorResponse.response?.data?.errors?.body ||
'Failed to subscribe to export offering.',
life: 5000,
});
} finally {
submitting.value = false;
}
}
</script>
2 changes: 2 additions & 0 deletions src/constants/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const ACTIONS = {
DELETE: 'delete',
PROMOTE: 'promote',
EXPORT: 'export',
SUBSCRIBE: 'subscribe',
INGEST: 'ingest',
REGISTER: 'register',
// admin actions
Expand Down Expand Up @@ -150,6 +151,7 @@ const ACTION_VERBS: Partial<Record<string, string>> = {
[ACTIONS.DELETE]: 'delete',
[ACTIONS.PROMOTE]: 'promote',
[ACTIONS.EXPORT]: 'publish',
[ACTIONS.SUBSCRIBE]: 'subscribe to',
[ACTIONS.REGISTER]: 'register',
[ACTIONS.INGEST]: 'ingest',
[ACTIONS.EXECUTE]: 'import',
Expand Down
8 changes: 8 additions & 0 deletions src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
17 changes: 17 additions & 0 deletions src/types/ssp-export-offerings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SSPExportOffering, 'items'> {
items?: CatalogOfferingItem[];
}
38 changes: 38 additions & 0 deletions src/types/ssp-leverage.ts
Original file line number Diff line number Diff line change
@@ -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[];
}>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@
>
Export Offerings
</RouterLink>
<RouterLink
class="tab-link px-4 py-2 inline-block text-lg border-ccf-300 dark:border-slate-700 dark:hover:bg-slate-900"
:to="{
name: 'system-security-plan-leverage',
params: { id: sspId },
}"
>
Leverage
</RouterLink>
<RouterLink
class="tab-link px-4 py-2 inline-block text-lg border-ccf-300 dark:border-slate-700 dark:hover:bg-slate-900"
:to="{ name: 'system-security-plan-risks', params: { id: sspId } }"
Expand Down
Loading
Loading