TenantAtlas/specs/154-finding-risk-acceptance/contracts/finding-risk-acceptance.openapi.yaml
ahmido b1e1e06861 feat: implement finding risk acceptance lifecycle (#184)
## Summary
- add a first-class finding exception domain with request, approval, rejection, renewal, and revocation lifecycle support
- add tenant-scoped exception register, finding governance surfaces, and a canonical workspace approval queue in Filament
- add audit, badge, evidence, and review-pack integrations plus focused Pest coverage for workflow, authorization, and governance validity

## Validation
- vendor/bin/sail bin pint --dirty --format agent
- CI=1 vendor/bin/sail artisan test --compact
- manual integrated-browser smoke test for the request-exception happy path, tenant register visibility, and canonical queue visibility

## Notes
- Filament implementation remains on v5 with Livewire v4-compatible surfaces
- canonical queue lives in the admin panel; provider registration stays in bootstrap/providers.php
- finding exceptions stay out of global search in this rollout

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #184
2026-03-20 01:07:55 +00:00

446 lines
13 KiB
YAML

openapi: 3.1.0
info:
title: Finding Risk Acceptance Internal Contract
version: 0.1.0
description: |
Internal admin-plane contract for the Finding Risk Acceptance Lifecycle.
These endpoints represent the server-side action contract backing Filament and Livewire surfaces.
servers:
- url: /api/internal
paths:
/tenants/{tenantId}/findings/{findingId}/exception-requests:
post:
summary: Request exception
operationId: requestFindingException
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/FindingId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RequestExceptionInput'
responses:
'201':
description: Exception request created
content:
application/json:
schema:
$ref: '#/components/schemas/FindingExceptionResource'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/tenants/{tenantId}/finding-exceptions:
get:
summary: List tenant exceptions
operationId: listTenantFindingExceptions
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/StateFilter'
- $ref: '#/components/parameters/DueFilter'
responses:
'200':
description: Tenant exception register
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/FindingExceptionResource'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/tenants/{tenantId}/finding-exceptions/{exceptionId}:
get:
summary: View exception detail
operationId: showFindingException
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionId'
responses:
'200':
description: Exception detail
content:
application/json:
schema:
$ref: '#/components/schemas/FindingExceptionDetail'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/tenants/{tenantId}/finding-exceptions/{exceptionId}/approve:
post:
summary: Approve exception
operationId: approveFindingException
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ApproveExceptionInput'
responses:
'200':
description: Exception approved
content:
application/json:
schema:
$ref: '#/components/schemas/FindingExceptionResource'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
/tenants/{tenantId}/finding-exceptions/{exceptionId}/reject:
post:
summary: Reject exception
operationId: rejectFindingException
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RejectExceptionInput'
responses:
'200':
description: Exception rejected
content:
application/json:
schema:
$ref: '#/components/schemas/FindingExceptionResource'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
/tenants/{tenantId}/finding-exceptions/{exceptionId}/renew:
post:
summary: Renew exception
operationId: renewFindingException
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RenewExceptionInput'
responses:
'200':
description: Renewal request accepted
content:
application/json:
schema:
$ref: '#/components/schemas/FindingExceptionResource'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
/tenants/{tenantId}/finding-exceptions/{exceptionId}/revoke:
post:
summary: Revoke exception
operationId: revokeFindingException
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RevokeExceptionInput'
responses:
'200':
description: Exception revoked
content:
application/json:
schema:
$ref: '#/components/schemas/FindingExceptionResource'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
/workspaces/{workspaceId}/finding-exceptions/queue:
get:
summary: Canonical exception approval queue
operationId: listCanonicalFindingExceptionQueue
tags: [Finding Exceptions]
parameters:
- $ref: '#/components/parameters/WorkspaceId'
- $ref: '#/components/parameters/StateFilter'
- $ref: '#/components/parameters/DueFilter'
- name: tenantId
in: query
schema:
type: integer
responses:
'200':
description: Canonical queue filtered to entitled tenants
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/FindingExceptionResource'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
components:
parameters:
TenantId:
name: tenantId
in: path
required: true
schema:
type: integer
FindingId:
name: findingId
in: path
required: true
schema:
type: integer
ExceptionId:
name: exceptionId
in: path
required: true
schema:
type: integer
WorkspaceId:
name: workspaceId
in: path
required: true
schema:
type: integer
StateFilter:
name: state
in: query
schema:
type: string
enum: [pending, active, expiring, expired, rejected, revoked, superseded]
DueFilter:
name: due
in: query
schema:
type: string
enum: [all, expiring, expired]
responses:
Forbidden:
description: Member lacks required capability
NotFound:
description: Workspace or tenant scope is not entitled
ValidationError:
description: Input or transition validation failed
schemas:
FindingExceptionResource:
type: object
required:
- id
- tenant_id
- finding_id
- status
- validity_state
properties:
id:
type: integer
tenant_id:
type: integer
finding_id:
type: integer
status:
type: string
enum: [pending, active, expiring, expired, rejected, revoked, superseded]
validity_state:
type: string
enum: [valid, expiring, expired, revoked, rejected, missing_support]
owner_user_id:
type: integer
nullable: true
requested_by_user_id:
type: integer
approved_by_user_id:
type: integer
nullable: true
requested_at:
type: string
format: date-time
approved_at:
type: string
format: date-time
nullable: true
expires_at:
type: string
format: date-time
nullable: true
review_due_at:
type: string
format: date-time
nullable: true
FindingExceptionDetail:
allOf:
- $ref: '#/components/schemas/FindingExceptionResource'
- type: object
properties:
request_reason:
type: string
decisions:
type: array
items:
$ref: '#/components/schemas/FindingExceptionDecision'
evidence_references:
type: array
items:
$ref: '#/components/schemas/FindingExceptionEvidenceReference'
FindingExceptionDecision:
type: object
required:
- decision_type
- actor_user_id
- decided_at
properties:
decision_type:
type: string
enum: [requested, approved, rejected, renewal_requested, renewed, revoked]
actor_user_id:
type: integer
reason:
type: string
nullable: true
effective_from:
type: string
format: date-time
nullable: true
expires_at:
type: string
format: date-time
nullable: true
decided_at:
type: string
format: date-time
FindingExceptionEvidenceReference:
type: object
required:
- source_type
- label
properties:
source_type:
type: string
source_id:
type: string
nullable: true
source_fingerprint:
type: string
nullable: true
label:
type: string
measured_at:
type: string
format: date-time
nullable: true
summary_payload:
type: object
additionalProperties: true
nullable: true
RequestExceptionInput:
type: object
required:
- owner_user_id
- request_reason
- review_due_at
properties:
owner_user_id:
type: integer
request_reason:
type: string
maxLength: 2000
review_due_at:
type: string
format: date-time
expires_at:
type: string
format: date-time
nullable: true
evidence_references:
type: array
items:
$ref: '#/components/schemas/FindingExceptionEvidenceReference'
ApproveExceptionInput:
type: object
required:
- effective_from
- expires_at
properties:
effective_from:
type: string
format: date-time
expires_at:
type: string
format: date-time
approval_reason:
type: string
nullable: true
maxLength: 2000
RejectExceptionInput:
type: object
required:
- rejection_reason
properties:
rejection_reason:
type: string
maxLength: 2000
RenewExceptionInput:
type: object
required:
- request_reason
- review_due_at
properties:
request_reason:
type: string
maxLength: 2000
review_due_at:
type: string
format: date-time
expires_at:
type: string
format: date-time
nullable: true
evidence_references:
type: array
items:
$ref: '#/components/schemas/FindingExceptionEvidenceReference'
RevokeExceptionInput:
type: object
required:
- revocation_reason
properties:
revocation_reason:
type: string
maxLength: 2000