feat: Implement DefaultCryptoHmac for compliance-aware HMAC operations

- Added DefaultCryptoHmac class implementing ICryptoHmac interface.
- Introduced purpose-based HMAC computation methods.
- Implemented verification methods for HMACs with constant-time comparison.
- Created HmacAlgorithms and HmacPurpose classes for well-known identifiers.
- Added compliance profile support for HMAC algorithms.
- Included asynchronous methods for HMAC computation from streams.
This commit is contained in:
StellaOps Bot
2025-12-06 00:41:04 +02:00
parent 43c281a8b2
commit f0662dd45f
362 changed files with 8441 additions and 22338 deletions

View File

@@ -19,6 +19,7 @@
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |
| UI-POLICY-23-002 | DONE (2025-12-05) | YAML editor route `/policy-studio/packs/:packId/yaml` with canonical preview and lint diagnostics. |
| UI-POLICY-23-003 | DONE (2025-12-05) | Rule Builder route `/policy-studio/packs/:packId/rules` with guided inputs and deterministic preview JSON. |
| UI-POLICY-23-004 | DONE (2025-12-05) | Approval workflow UI updated with readiness checklist, schedule window card, comment thread, and two-person indicator; tests attempted but Angular CLI hit missing rxjs util module. |
| UI-POLICY-23-005 | DONE (2025-12-05) | Simulator updated with SBOM/advisory pickers and explain trace view; uses PolicyApiService simulate. |
| UI-POLICY-23-006 | DOING (2025-12-05) | Explain view route `/policy-studio/packs/:packId/explain/:runId` with trace + JSON export; PDF export pending backend. |
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |

View File

@@ -19,6 +19,9 @@ describe('PolicyApprovalsComponent', () => {
'getApprovalWorkflow',
'submitForReview',
'addReview',
'updateApprovalSchedule',
'updateChecklist',
'addComment',
]);
api.getApprovalWorkflow.and.returnValue(
@@ -46,11 +49,31 @@ describe('PolicyApprovalsComponent', () => {
],
requiredApprovers: 2,
currentApprovers: 1,
checklist: [
{ id: 'c1', label: 'Coverage present', hint: 'Link to coverage run', required: true, status: 'pending' },
{ id: 'c2', label: 'Simulated', hint: 'Simulation diff attached', required: true, status: 'complete' },
],
comments: [
{ id: 'cm1', authorId: 'user-a', authorName: 'User A', message: 'Initial submit', createdAt: '2025-12-05T00:05:00Z' },
],
schedule: {
start: '2025-12-10T00:00',
end: '2025-12-11T00:00',
},
}) as any
);
api.submitForReview.and.returnValue(of({}) as any);
api.addReview.and.returnValue(of({}) as any);
api.updateApprovalSchedule.and.returnValue(of({}) as any);
api.updateChecklist.and.returnValue(of([]) as any);
api.addComment.and.returnValue(of({
id: 'cm2',
authorId: 'user-x',
authorName: 'User X',
message: 'Ack',
createdAt: '2025-12-05T02:00:00Z',
}) as any);
auth = {
canApprovePolicies: () => true,
@@ -86,11 +109,13 @@ describe('PolicyApprovalsComponent', () => {
expect(reviews[1].reviewerId).toBe('user-b');
});
it('includes schedule fields in submission payload', () => {
it('submits with schedule window attached', () => {
component.submitForm.patchValue({
message: 'Please review',
scheduleStart: '2025-12-10T00:00',
scheduleEnd: '2025-12-11T00:00',
});
component.scheduleForm.patchValue({
start: '2025-12-10T00:00',
end: '2025-12-11T00:00',
});
component.onSubmit();
@@ -106,13 +131,27 @@ describe('PolicyApprovalsComponent', () => {
});
});
it('calls addReview with decision', fakeAsync(() => {
component.reviewForm.setValue({ comment: 'Approve now' });
component.onReview('approve');
tick();
expect(api.addReview).toHaveBeenCalledWith('pack-1', '1.0.0', {
decision: 'approve',
comment: 'Approve now',
it('persists schedule changes via updateApprovalSchedule', () => {
component.scheduleForm.patchValue({ start: '2025-12-12T00:00', end: '2025-12-13T00:00' });
component.onScheduleSave();
expect(api.updateApprovalSchedule).toHaveBeenCalledWith('pack-1', '1.0.0', {
start: '2025-12-12T00:00',
end: '2025-12-13T00:00',
});
});
it('updates checklist status', fakeAsync(() => {
component.setChecklistStatus(component.checklistSorted[0], 'complete');
tick();
const sentChecklist = api.updateChecklist.calls.mostRecent().args[2];
expect(sentChecklist[0].status).toBe('complete');
}));
it('posts a comment', fakeAsync(() => {
component.commentForm.setValue({ message: 'Looks good' });
component.onComment();
tick();
expect(api.addComment).toHaveBeenCalledWith('pack-1', '1.0.0', 'Looks good');
expect(component.sortedComments.length).toBeGreaterThan(1);
}));
});

View File

@@ -7,7 +7,10 @@ import { finalize } from 'rxjs/operators';
import { AUTH_SERVICE, AuthService } from '../../../core/auth';
import {
type ApprovalChecklistItem,
type ApprovalComment,
type ApprovalReview,
type ApprovalScheduleWindow,
type ApprovalWorkflow,
type PolicySubmissionRequest,
} from '../models/policy.models';
@@ -25,7 +28,7 @@ import { PolicyApiService } from '../services/policy-api.service';
<p class="approvals__eyebrow">Policy Studio · Approvals</p>
<h1>Submit, review, approve</h1>
<p class="approvals__lede">
Two-person approval with deterministic audit trail. Status: {{ workflow?.status || 'unknown' | titlecase }}
Two-person approval with deterministic audit trail and scoped activation windows.
</p>
</div>
<div class="approvals__meta" *ngIf="workflow">
@@ -37,6 +40,12 @@ import { PolicyApiService } from '../services/policy-api.service';
<span class="approvals__badge" [class.approvals__badge--ready]="isReadyToApprove" [class.approvals__badge--missing]="!isReadyToApprove">
Two-person rule: {{ isReadyToApprove ? 'Satisfied' : 'Missing second approver' }}
</span>
<span class="approvals__badge" [class.approvals__badge--ready]="pendingChecklist === 0" [class.approvals__badge--missing]="pendingChecklist > 0">
Checklist: {{ pendingChecklist === 0 ? 'Ready' : pendingChecklist + ' open item' + (pendingChecklist === 1 ? '' : 's') }}
</span>
<span class="approvals__badge" [class.approvals__badge--ready]="scheduleSummary !== 'Unscheduled'" [class.approvals__badge--missing]="scheduleSummary === 'Unscheduled'">
Schedule: {{ scheduleSummary }}
</span>
</div>
</header>
@@ -59,17 +68,35 @@ import { PolicyApiService } from '../services/policy-api.service';
<span>Simulation diff reference (optional)</span>
<input formControlName="simulationDiff" placeholder="Run ID or artifact path" />
</label>
<button class="btn" type="submit" [disabled]="submitForm.invalid || submitting">
{{ submitting ? 'Submitting…' : 'Submit for review' }}
</button>
</form>
</div>
<div class="card card--accent">
<header>
<h3>Scope scheduling</h3>
<p>Define when the approved scope becomes active.</p>
</header>
<form [formGroup]="scheduleForm" (ngSubmit)="onScheduleSave()" class="stack">
<div class="grid">
<label class="field">
<span>Scope start (UTC)</span>
<input type="datetime-local" formControlName="scheduleStart" />
<input type="datetime-local" formControlName="start" />
</label>
<label class="field">
<span>Scope end (UTC)</span>
<input type="datetime-local" formControlName="scheduleEnd" />
<input type="datetime-local" formControlName="end" />
</label>
</div>
<button class="btn" type="submit" [disabled]="submitForm.invalid || submitting">{{ submitting ? 'Submitting…' : 'Submit for review' }}</button>
<div class="schedule__summary">
<strong>{{ scheduleSummary }}</strong>
<span class="muted">Persisted per policy version; deterministic ISO-8601.</span>
</div>
<button class="btn btn--ghost" type="submit" [disabled]="scheduleSaving">
{{ scheduleSaving ? 'Saving…' : 'Save schedule' }}
</button>
</form>
</div>
@@ -92,6 +119,36 @@ import { PolicyApiService } from '../services/policy-api.service';
</div>
</div>
<section class="readiness" *ngIf="workflow">
<header class="readiness__header">
<div>
<h3>Readiness checklist</h3>
<p>Shared guardrails for authors and reviewers.</p>
</div>
<span class="badge" [class.badge--ready]="pendingChecklist === 0">
{{ pendingChecklist === 0 ? 'All items complete' : pendingChecklist + ' open' }}
</span>
</header>
<ul class="checklist">
<li *ngFor="let item of checklistSorted">
<div class="checklist__row">
<div>
<div class="checklist__label">{{ item.label }}</div>
<div class="checklist__hint">{{ item.hint }}</div>
</div>
<span class="checklist__pill" [attr.data-status]="item.status">
{{ item.status === 'complete' ? 'Complete' : item.status === 'blocked' ? 'Blocked' : 'Pending' }}
</span>
</div>
<div class="checklist__actions">
<button class="btn btn--ghost" type="button" [disabled]="checklistSaving" (click)="setChecklistStatus(item, 'complete')">Mark complete</button>
<button class="btn btn--ghost" type="button" [disabled]="checklistSaving" (click)="setChecklistStatus(item, 'pending')">Reset</button>
<button class="btn btn--warn" type="button" [disabled]="checklistSaving" (click)="setChecklistStatus(item, 'blocked')">Flag block</button>
</div>
</li>
</ul>
</section>
<section class="timeline" *ngIf="workflow">
<header class="timeline__header">
<h3>Approvals log</h3>
@@ -111,6 +168,31 @@ import { PolicyApiService } from '../services/policy-api.service';
</li>
</ol>
</section>
<section class="comments" *ngIf="workflow">
<header class="comments__header">
<h3>Comments</h3>
<p>Deterministic thread; newest first.</p>
</header>
<ol class="comments__list">
<li *ngFor="let comment of sortedComments">
<div class="comments__meta">
<strong>{{ comment.authorName }}</strong>
<span class="muted">{{ comment.createdAt | date:'medium' }}</span>
</div>
<p class="comments__body">{{ comment.message }}</p>
</li>
</ol>
<form [formGroup]="commentForm" (ngSubmit)="onComment()" class="stack comments__form">
<label class="field">
<span>Add comment</span>
<textarea rows="2" formControlName="message" placeholder="Share context, risks, or requests"></textarea>
</label>
<button class="btn" type="submit" [disabled]="commentForm.invalid || commenting">
{{ commenting ? 'Posting…' : 'Post comment' }}
</button>
</form>
</section>
</section>
`,
styles: [
@@ -126,12 +208,16 @@ import { PolicyApiService } from '../services/policy-api.service';
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.approvals__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.approvals__eyebrow {
@@ -174,7 +260,6 @@ import { PolicyApiService } from '../services/policy-api.service';
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
margin: 1rem 0;
}
.card {
@@ -182,7 +267,12 @@ import { PolicyApiService } from '../services/policy-api.service';
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1rem;
box-shadow: 0 15px 40px rgba(0,0,0,0.25);
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.25);
}
.card--accent {
border-color: #2563eb;
box-shadow: 0 15px 40px rgba(37, 99, 235, 0.25);
}
.card h3 {
@@ -219,6 +309,12 @@ import { PolicyApiService } from '../services/policy-api.service';
font-family: 'Monaco','Consolas', monospace;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.5rem;
}
.actions {
display: flex;
gap: 0.5rem;
@@ -243,8 +339,84 @@ import { PolicyApiService } from '../services/policy-api.service';
.btn--ghost { background: transparent; border-color: #334155; color: #cbd5e1; }
.approvals__badge {
padding: 0.3rem 0.6rem;
border-radius: 8px;
font-size: 0.9rem;
border: 1px solid #334155;
}
.approvals__badge--ready { border-color: #22c55e; color: #22c55e; }
.approvals__badge--missing { border-color: #f59e0b; color: #f59e0b; }
.readiness {
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1rem;
}
.readiness__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.badge {
padding: 0.35rem 0.6rem;
border-radius: 10px;
border: 1px solid #f59e0b;
color: #f59e0b;
}
.badge--ready {
border-color: #22c55e;
color: #22c55e;
}
.checklist {
list-style: none;
margin: 0.8rem 0 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.checklist__row {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.checklist__label {
font-weight: 700;
color: #f8fafc;
}
.checklist__hint { color: #94a3b8; margin-top: 0.1rem; }
.checklist__pill {
align-self: start;
padding: 0.25rem 0.55rem;
border-radius: 8px;
border: 1px solid #334155;
font-weight: 700;
text-transform: capitalize;
}
.checklist__pill[data-status='complete'] { border-color: #22c55e; color: #22c55e; }
.checklist__pill[data-status='pending'] { border-color: #f59e0b; color: #f59e0b; }
.checklist__pill[data-status='blocked'] { border-color: #ef4444; color: #ef4444; }
.checklist__actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.timeline {
margin-top: 1rem;
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 12px;
@@ -271,28 +443,54 @@ import { PolicyApiService } from '../services/policy-api.service';
.timeline__time { font-size: 0.9rem; }
.timeline__comment { margin: 0.15rem 0 0; color: #e5e7eb; }
.comments {
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1rem;
}
.comments__list { list-style: none; margin: 0.5rem 0 1rem; padding: 0; display: grid; gap: 0.6rem; }
.comments__meta { display: flex; gap: 0.5rem; align-items: baseline; }
.comments__body { margin: 0.15rem 0 0; color: #e5e7eb; }
.muted { color: #94a3b8; font-size: 0.9rem; }
.schedule__summary { display: flex; flex-direction: column; gap: 0.15rem; color: #cbd5e1; }
@media (max-width: 960px) { .approvals__header { flex-direction: column; } }
`,
],
})
export class PolicyApprovalsComponent {
protected workflow?: ApprovalWorkflow;
protected checklist: ApprovalChecklistItem[] = [];
protected comments: ApprovalComment[] = [];
protected loading = false;
protected submitting = false;
protected reviewing = false;
protected checklistSaving = false;
protected scheduleSaving = false;
protected commenting = false;
protected readonly submitForm = this.fb.group({
message: ['', [Validators.required, Validators.minLength(5)]],
coverageResults: [''],
simulationDiff: [''],
scheduleStart: [''],
scheduleEnd: [''],
});
protected readonly reviewForm = this.fb.group({
comment: ['', [Validators.required, Validators.minLength(3)]],
});
protected readonly scheduleForm = this.fb.group({
start: [''],
end: [''],
});
protected readonly commentForm = this.fb.group({
message: ['', [Validators.required, Validators.minLength(2)]],
});
private readonly fb = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly policyApi = inject(PolicyApiService);
@@ -300,7 +498,21 @@ export class PolicyApprovalsComponent {
get sortedReviews(): ApprovalReview[] {
if (!this.workflow?.reviews) return [];
return [...this.workflow.reviews].sort((a, b) => b.reviewedAt.localeCompare(a.reviewedAt) || a.reviewerId.localeCompare(b.reviewerId));
return [...this.workflow.reviews].sort((a, b) =>
b.reviewedAt.localeCompare(a.reviewedAt) || a.reviewerId.localeCompare(b.reviewerId)
);
}
get checklistSorted(): ApprovalChecklistItem[] {
return this.sortChecklist(this.checklist);
}
get sortedComments(): ApprovalComment[] {
return this.sortComments(this.comments);
}
get pendingChecklist(): number {
return this.checklist.filter((item) => item.status !== 'complete').length;
}
get isReadyToApprove(): boolean {
@@ -308,6 +520,15 @@ export class PolicyApprovalsComponent {
return this.workflow.currentApprovers >= this.workflow.requiredApprovers;
}
get scheduleSummary(): string {
const start = this.workflow?.schedule?.start;
const end = this.workflow?.schedule?.end;
if (start && end) return `${start}${end} UTC`;
if (start) return `Starts ${start} UTC`;
if (end) return `Ends ${end} UTC`;
return 'Unscheduled';
}
ngOnInit(): void {
this.refresh();
}
@@ -317,14 +538,16 @@ export class PolicyApprovalsComponent {
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
if (!packId || this.submitForm.invalid) return;
const schedule = this.schedulePayload();
const payload: PolicySubmissionRequest = {
policyId: packId,
version: version ?? 'latest',
message: this.submitForm.value.message ?? '',
coverageResults: this.submitForm.value.coverageResults ?? undefined,
simulationDiff: this.submitForm.value.simulationDiff ?? undefined,
scheduleStart: this.submitForm.value.scheduleStart ?? undefined,
scheduleEnd: this.submitForm.value.scheduleEnd ?? undefined,
scheduleStart: schedule.start,
scheduleEnd: schedule.end,
};
this.submitting = true;
@@ -336,6 +559,19 @@ export class PolicyApprovalsComponent {
});
}
onScheduleSave(): void {
if (!this.workflow) return;
const schedule = this.schedulePayload();
this.scheduleSaving = true;
this.policyApi
.updateApprovalSchedule(this.workflow.policyId, this.workflow.policyVersion, schedule)
.pipe(finalize(() => (this.scheduleSaving = false)))
.subscribe({
next: () => this.refresh(),
});
}
onReview(decision: 'approve' | 'reject' | 'request_changes'): void {
if (!this.workflow || this.reviewForm.invalid) return;
if (decision === 'approve' && !this.auth.canApprovePolicies?.()) return;
@@ -353,6 +589,44 @@ export class PolicyApprovalsComponent {
});
}
setChecklistStatus(item: ApprovalChecklistItem, status: ApprovalChecklistItem['status']): void {
if (!this.workflow) return;
const updated = this.checklist.map((entry) =>
entry.id === item.id
? {
...entry,
status,
updatedAt: new Date().toISOString(),
}
: entry
);
this.checklist = this.sortChecklist(updated);
this.checklistSaving = true;
this.policyApi
.updateChecklist(this.workflow.policyId, this.workflow.policyVersion, updated)
.pipe(finalize(() => (this.checklistSaving = false)))
.subscribe({
next: (serverChecklist) => (this.checklist = this.sortChecklist(serverChecklist ?? updated)),
});
}
onComment(): void {
if (!this.workflow || this.commentForm.invalid) return;
const message = this.commentForm.value.message ?? '';
this.commenting = true;
this.policyApi
.addComment(this.workflow.policyId, this.workflow.policyVersion, message)
.pipe(finalize(() => (this.commenting = false)))
.subscribe({
next: (comment) => {
this.commentForm.reset();
this.comments = this.sortComments([comment, ...this.comments]);
},
});
}
private refresh(): void {
const packId = this.route.snapshot.paramMap.get('packId');
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
@@ -363,7 +637,37 @@ export class PolicyApprovalsComponent {
.getApprovalWorkflow(packId, version ?? 'latest')
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: (wf) => (this.workflow = wf),
next: (wf) => this.applyWorkflow(wf),
});
}
private applyWorkflow(workflow: ApprovalWorkflow): void {
this.workflow = workflow;
this.checklist = this.sortChecklist(workflow.checklist ?? []);
this.comments = this.sortComments(workflow.comments ?? []);
this.scheduleForm.patchValue(
{
start: workflow.schedule?.start ?? '',
end: workflow.schedule?.end ?? '',
},
{ emitEvent: false }
);
}
private sortChecklist(items: readonly ApprovalChecklistItem[]): ApprovalChecklistItem[] {
return [...items].sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
}
private sortComments(items: readonly ApprovalComment[]): ApprovalComment[] {
return [...items].sort(
(a, b) => b.createdAt.localeCompare(a.createdAt) || a.authorId.localeCompare(b.authorId)
);
}
private schedulePayload(): ApprovalScheduleWindow {
return {
start: this.scheduleForm.value.start || undefined,
end: this.scheduleForm.value.end || undefined,
};
}
}

View File

@@ -276,6 +276,9 @@ export interface ApprovalWorkflow {
readonly reviews: readonly ApprovalReview[];
readonly requiredApprovers: number;
readonly currentApprovers: number;
readonly checklist: readonly ApprovalChecklistItem[];
readonly comments: readonly ApprovalComment[];
readonly schedule?: ApprovalScheduleWindow;
}
/**
@@ -299,6 +302,38 @@ export interface ApprovalReview {
readonly reviewedAt: string;
}
/**
* Approval checklist item describing pre-merge guardrails.
*/
export interface ApprovalChecklistItem {
readonly id: string;
readonly label: string;
readonly hint?: string;
readonly required: boolean;
readonly status: 'pending' | 'complete' | 'blocked';
readonly updatedAt?: string;
readonly updatedBy?: string;
}
/**
* Comment on an approval workflow.
*/
export interface ApprovalComment {
readonly id: string;
readonly authorId: string;
readonly authorName: string;
readonly message: string;
readonly createdAt: string;
}
/**
* Scheduled activation window for policy scope.
*/
export interface ApprovalScheduleWindow {
readonly start?: string;
readonly end?: string;
}
/**
* Policy run dashboard data.
*/
@@ -350,6 +385,8 @@ export interface PolicySubmissionRequest {
readonly message: string;
readonly coverageResults?: string;
readonly simulationDiff?: string;
readonly scheduleStart?: string;
readonly scheduleEnd?: string;
}
/**

View File

@@ -28,6 +28,9 @@ import type {
PolicyRunDashboard,
PolicySubmissionRequest,
PolicyPromotionRequest,
ApprovalChecklistItem,
ApprovalComment,
ApprovalScheduleWindow,
} from '../models/policy.models';
/**
@@ -238,6 +241,20 @@ export class PolicyApiService {
);
}
/**
* Update the activation window for an approval workflow.
*/
updateApprovalSchedule(
packId: string,
version: string,
schedule: ApprovalScheduleWindow
): Observable<ApprovalScheduleWindow> {
return this.http.put<ApprovalScheduleWindow>(
`${API_BASE}/packs/${packId}/versions/${version}/approval/schedule`,
schedule
);
}
/**
* Add a review to the approval workflow.
*
@@ -259,6 +276,34 @@ export class PolicyApiService {
);
}
/**
* Replace the approval checklist for a policy version.
*/
updateChecklist(
packId: string,
version: string,
checklist: ApprovalChecklistItem[]
): Observable<ApprovalChecklistItem[]> {
return this.http.put<ApprovalChecklistItem[]>(
`${API_BASE}/packs/${packId}/versions/${version}/approval/checklist`,
{ checklist }
);
}
/**
* Add a discussion comment.
*/
addComment(
packId: string,
version: string,
message: string
): Observable<ApprovalComment> {
return this.http.post<ApprovalComment>(
`${API_BASE}/packs/${packId}/versions/${version}/approval/comments`,
{ message }
);
}
/**
* Promote a policy to a target environment.
* Requires interactive authentication (policy:promote scope).