NIS2 Statement of Applicability Contract v1
Contract ID: CONTRACT-NIS2-SOA-V1-018 Status: Active Owner: Policy Guild Schema version: nis2-soa-v1 Bundle schema version: nis2-soa-bundle-v1 Input contract: nis2-control-register-v1
Part of the Stella Ops Contracts index. Audience: Policy and Export Center implementers who produce and sign SoA bundles, and auditors who verify them offline. The source snapshot is the NIS2 Control Register; the production verifier uses the EU Signing Payload Registry.
Purpose
The NIS2 Statement of Applicability (SoA) export pins a tenant control-register snapshot to an export hash. The responsibilities split across three modules:
- Policy persists the source snapshot and the SoA hash refs.
- Export Center renders and signs deterministic SoA bundles.
- EvidenceLocker regulatory artifact-ledger rows make the pinned SoA hash discoverable without copying Policy control state.
Statement of Applicability object (nis2-soa-v1)
The Statement of Applicability is the nis2-soa-v1 payload that is hashed and DSSE-signed. It is the statementOfApplicability member of the surrounding bundle (see Export Bundle).
{
"schemaVersion": "nis2-soa-v1",
"tenantId": "tenant-a",
"generatedAt": "2026-04-30T09:00:00Z",
"controlRegisterSnapshotHash": "sha256:...",
"soaExportHash": "sha256:...",
"evidenceLockerBundleRef": "evidence-locker://bundles/sha256:...",
"controls": []
}
Grounded in Nis2StatementOfApplicability (Nis2SoaModels.cs, StellaOps.ExportCenter.Adapters.Nis2).
Fields
| Field | Type | Required | Notes |
|---|---|---|---|
schemaVersion | string | yes | Must be nis2-soa-v1 (Nis2SoaConstants.SoaSchemaVersion). |
tenantId | string | yes | Tenant that owns the exported register. Lowercased/normalized. |
generatedAt | timestamp | yes | UTC timestamp; taken from the source snapshot generatedAt, used for deterministic export ordering. |
controlRegisterSnapshotHash | string | yes | Canonical sha256:-prefixed hash of the nis2-control-register-v1 snapshot. |
soaExportHash | string | yes | Canonical hash of the SoA hash payload, excluding the self-referential soaExportHash field. |
evidenceLockerBundleRef | string | yes | Content-addressed EvidenceLocker bundle reference. Defaults to evidence-locker://bundles/{controlRegisterSnapshotHash} when not supplied. |
controls[] | array | yes | Exported control rows derived from the pinned control-register snapshot. |
The verified implementation does not emit a
coverageSummaryobject on the SoA payload, and there is noassurance-pack-v1coverage block. The stella-native vs. operator-supplied distinction is carried per-control byevidenceModeandevidencePointers[].sourceType(see below), not by a top-level summary.
controls[] row (Nis2SoaControlRow)
| Field | Type | Notes |
|---|---|---|
controlId | string | Stable tenant-visible control id. |
thematicArea | integer | NIS2 Article 21 thematic area (1–13). |
applicability | string | Read from control metadata key applicability; defaults to applicable. |
art21Refs[] | string array | Article 21 references, ordinal-sorted. |
policyDocRef | string | Policy/module documentation reference. |
responsibleRole | string | Accountable owner role. |
implementingControls[] | array | { kind, ref, description, metadata }; ordinal-sorted by kind then ref. description/metadata are dropped when redactInternalOnly is set. |
evidencePointers[] | array | { ref, sourceType, description, submittedByRole, collectedAt }; ordinal-sorted by sourceType then ref. description is dropped when redactInternalOnly is set. |
evidenceMode | string | stella-native, tenant-supplied, or mixed (from the control register). |
lastReviewed | ISO date | Last review date. |
nextReview | ISO date | Next review date. |
metadata | object | String map, key-sorted; emitted empty when redactInternalOnly is set. |
Export Bundle (nis2-soa-bundle-v1)
The DSSE-signed artifact persisted and downloaded by ExportCenter is a bundle, not the bare SoA. The bundle schema version is nis2-soa-bundle-v1 (Nis2SoaConstants.BundleSchemaVersion), distinct from the SoA payload schema version nis2-soa-v1. Grounded in Nis2SoaBundle (Nis2SoaModels.cs) and Nis2SoaExporter.BuildSignedBundle.
| Member | Type | Notes |
|---|---|---|
schemaVersion | string | nis2-soa-bundle-v1. |
tenantComplianceProfileSchemaVersion | string | tenant-compliance-profile.v1. |
tenantScope | object | { tenantId, legalEntityName, jurisdictionCode, regulatedRegimes[], nis2SectorCode, nis2Classification }. nis2Classification is essential, important, or not-classified. |
controlRegisterSnapshot | object | The normalized nis2-control-register-v1 snapshot (schemaVersion, tenantId, stellaRelease, generatedAt, controls[]). |
statementOfApplicability | object | The nis2-soa-v1 payload above. |
manifest | object | { generatedAt, since, entries[] } where each entry is { path, mediaType, sha256 } for control-register-snapshot.json, statement-of-applicability.json, and statement-of-applicability.dsse.json. |
signature | object | DSSE envelope { payloadType, payload, signatures[] }; payloadType is application/vnd.stellaops.nis2.soa.v1+json; each signature is { keyid, alg, sig }. |
Determinism Rules
- Export consumers must read controls from a stored
policy.control_register_snapshotsrow (table created by Policy migration014_nis2_control_register.sql). - Control order follows the control-register snapshot order (sorted by
thematicArea, thencontrolId; nested arrays ordinal-sorted before hashing). - The export hash is independent from local file paths and wall-clock generation time other than the explicit
generatedAt. soaExportHashis computed over{ schemaVersion, tenantId, generatedAt, controlRegisterSnapshotHash, evidenceLockerBundleRef, controls }(Nis2StatementOfApplicabilityHashPayload/Nis2SoaExporter.ToHashPayload); the hash domain intentionally excludessoaExportHashitself.- Offline verification requires the snapshot JSON, its
content_hash, the SoA export bytes, and thesoa_export_hash. - EvidenceLocker artifact-ledger lookup uses artifact type
nis2-statement-of-applicability(RegulatoryArtifactTypes.Nis2StatementOfApplicability), regimenis2(RegulatoryRegimes.Nis2), andcontentHash = soaExportHash. The ledger row is written by PolicyControlRegisterRepository.PublishSnapshotAsyncwhen a snapshot is published, not by ExportCenter. - SoA exports must preserve operator-supplied gaps separately from Stella-observed software-estate evidence; they must not label legal, physical, HR, governance, or process facts as Stella-native telemetry. This is enforced per-control via
evidenceModeandevidencePointers[].sourceType.
Signing and Verification
- File-backed offline CLI exports (
stella nis2-soa) may use a local deterministic HMAC DSSE-shaped envelope for air-gapped review only. The DSSEalgtag is resolved dynamically from the active compliance profile (ICryptoHmac.GetAlgorithmForPurpose); the default profile emitsHS256. Sender and verifier must run the same regional crypto profile for offline HMAC bundles. - Production ExportCenter signing must sign the canonical
nis2-soa-v1statement bytes through the platform crypto provider path (Nis2SoaCryptoProviderSigneroverICryptoProviderRegistry); the default signing algorithm is ES256 (SignatureAlgorithms.Es256, configurable viaExport:Nis2Soa:SigningAlgorithmId). - Production DSSE payload type is
application/vnd.stellaops.nis2.soa.v1+json(Nis2SoaConstants.PayloadType). - Production verifier behavior is the Signer EU offline verifier contract (
EuArtifactOfflineVerifier,StellaOps.Signer.Core) for payload type idnis2-soa-v1, schema pinnis2-soa-v1, signer profiletenant-regulatory-export, andTenantOperatoraudience (EuSigningPayloadRegistry.Nis2StatementOfApplicability). - Verification validates bundle integrity first (
Nis2SoaBundleVerifier): bundle schema version, control-register snapshot hash (snapshot_hash_mismatch), SoA export hash (soa_hash_mismatch), manifest entries (manifest_entry_missing:<path>,manifest_entry_hash_mismatch:<path>), then the DSSE signature against the supplied tenant export trust roots. - A production bundle without a matching tenant export trust root fails closed with the Signer verifier issues, for example
trust_roots_required,signature_untrusted:<keyId>,signer_key_mismatch:<keyId>,signer_profile_not_allowed:<keyId>, orno_valid_signature. ExportCenter rejects a run whose generated bundle does not verify (InvalidOperationException→ HTTP 400invalid_nis2_soa_export_request).
Web/ExportCenter Trigger
Endpoints are mapped under /v1/exports/nis2/soa by Nis2SoaExportEndpoints.MapNis2SoaExportEndpoints. The group requires authorization; the read endpoints require the export.viewer scope (StellaOpsScopes.ExportViewer / StellaOpsResourceServerPolicies.ExportViewer) and the run-creation endpoint requires the export.operator scope (StellaOpsScopes.ExportOperator).
| Method + route | Scope policy | Purpose |
|---|---|---|
GET /v1/exports/nis2/soa/profile | export.viewer | Discover the active profile (profileId = nis2:statement-of-applicability, schemaVersion = nis2-soa-bundle-v1, status, runEndpoint, requiredScope, description). |
POST /v1/exports/nis2/soa/runs | export.operator | Create a signed run. |
GET /v1/exports/nis2/soa/runs/{runId:guid} | export.viewer | Read the persisted run. |
GET /v1/exports/nis2/soa/runs/{runId:guid}/bundle | export.viewer | Download the signed bundle. |
- The
GET .../profileresponserequiredScopefield is the literal stringexport:operate. This does not match the canonical scope nameexport.operatorthat the run endpoint actually enforces; treat the authorization policies above as the source of truth. - The run id route segment is constrained to
:guid. Run and artifact ids are deterministic (derived by SHA-256 over tenant + snapshot hash +since+ evidence ref + redaction flag), so retried identical requests are idempotent and return the existing run. POST .../runsaccepts aNis2SoaStartRunRequest: requiredcontrolRegisterSnapshot(anis2-control-register-v1snapshot) andsince(yyyy-MM-ddDateOnly), plus optionaltenantId,evidenceLockerBundleRef,redactInternalOnly(bool), andcorrelationId.- Tenant is resolved from the authenticated principal (
stellaops:tenantclaim, falling back totenant_id/tid, thenX-StellaOps-TenantId/X-Tenant-Idheaders). A requesttenantIdthat does not match the resolved tenant fails closed with HTTP 403tenant_mismatch; the snapshottenantIdmust equal the resolved tenant or the request fails with HTTP 400. A missing tenant yields HTTP 400tenant_not_found. - ExportCenter resolves the Authority tenant compliance profile (
INis2SoaTenantProfileClient), signs and verifies the production bundle, persists the completed run + artifact, writes an export audit (ExportAuditOperation.RunCompleted), and returns aNis2SoaRunResponse:jobId,runId,profileId,tenantId,status,progress({ totalItems, processedItems, failedItems, totalSizeBytes, percentComplete }),signedBundleUrl,exportUrl,bundleHash,soaExportHash,controlRegisterSnapshotHash,evidenceLockerBundleRef, anartifactobject, averificationobject, andcreatedAt/startedAt/completedAt.statusis one ofqueued,running,completed,partially_completed,failed,cancelled. GET .../runs/{runId}returns the persisted run status (no freshverificationblock — that is only populated on the originatingPOST .../runsresponse) and 404 when the run is absent.GET .../runs/{runId}/bundlestreams the signed bundle file with media typeapplication/vnd.stellaops.nis2.soa.bundle.v1+json(Nis2SoaExportService.BundleContentType) and 404 when the bundle artifact is missing on disk.
Configuration (Export:Nis2Soa)
Bound to Nis2SoaExportOptions:
StorageRoot(required outside the Testing environment) — bundle storage root.SigningKeyId(required) — crypto provider key id used by the production signer.SigningAlgorithmId— defaultES256.SigningProviderHint,ExpectedSignerKeyId,AllowHistoricalVerification.TrustRootMode—Online,Sealed, orAirGap(defaultAirGap).TrustRoots[](at least one required) — each{ TrustRootId, KeyId, PublicKeyPem, Fingerprint, NotBeforeUtc, NotAfterUtc, SignerProfiles[] }.ArtifactTtlDays— default 7.AuthorityBaseUrl— Authority base URL for tenant compliance-profile reads.
CLI (offline)
stella nis2-soa (Nis2SoaCommandGroup) produces a deterministic, locally HMAC-signed bundle for air-gapped review. Options:
--since(required,yyyy-MM-dd) — date selecting the stored snapshot.--snapshot(required) — path to anis2-control-register-v1snapshot JSON.--signing-key-file(required) — local HMAC signing key file.--tenant-profile/--tenant(-t) — supply atenant-compliance-profile.v1JSON file, or read it from Authority for the given tenant (GET api/v1/tenants/{tenant}/compliance-profile, scopeauthority:tenants.read).--signing-key-id,--evidence-locker-bundle-ref,--output(-o),--overwrite,--redact-internal-only,--json.
The CLI runs offline verification on the generated bundle and exits non-zero (VerificationFailed) if it does not verify. This local HMAC path is for air-gapped review only; it is not the production eIDAS/EU signing path.
