Skip to content

Compliance as Architecture — GDPR & ISO 27001 Built In

Compliance is usually bolted on after the fact. A security audit reveals gaps, someone adds logging in a few places, a consultant writes a policy document, and the team moves on until the next audit.

Granit takes a different approach: compliance requirements are architectural constraints enforced by the framework itself. You cannot accidentally skip the audit trail because the interceptor runs on every write. You cannot forget tenant isolation because the query filter is applied globally. The framework makes the compliant path the default path.

You are targetingStart here
GDPR — erasure, portability, restriction, privacy by designThis page, then Crypto-Shredding (cryptographic erasure) and Privacy (data export API)
ePrivacy / cookie consent (incl. CCPA-style regimes)Cookies — consent ledger, CMP integration, regulation bridge
ISO 27001This page, then Audit Log and Observability
NIS2NIS2-ready .NET applications walkthrough, plus Audit Log
SOC 2 Type IISOC 2 Type II-ready SaaS with Granit walkthrough

The General Data Protection Regulation requires data minimization, right to erasure, processing restriction, data portability, and privacy by design. Granit maps each requirement to a concrete framework mechanism.

Each module stores only what it needs. There is no central “user profile” table with 40 columns — identity data lives in the IdP, cached fields are explicitly declared in UserCacheEntity, and modules reference users by ID only.

Granit uses logical deletion, not physical deletion, through the ISoftDeletable interface and SoftDeleteInterceptor. When Delete() is called on a soft-deletable entity, the interceptor sets IsDeleted = true and DeletedAt / DeletedBy fields. The record stays in the database.

public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTimeOffset? DeletedAt { get; set; }
string? DeletedBy { get; set; }
}

A global query filter on ISoftDeletable excludes deleted records from all queries by default. When administrative access to deleted records is needed (audit, legal hold), the filter can be temporarily disabled:

public async Task<List<PatientResponse>> GetDeletedPatientsAsync(
IDataFilter dataFilter,
AppDbContext db,
CancellationToken cancellationToken)
{
using (dataFilter.Disable<ISoftDeletable>())
{
return await db.Patients
.Where(p => p.IsDeleted)
.Select(p => new PatientResponse(p.Id, p.FullName, p.DeletedAt))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
}

When retention rules require the data itself to become unrecoverable — not just hidden — Crypto-Shredding complements soft delete: destroy the per-entity encryption key and the encrypted columns are permanently unreadable while rows and audit references stay intact.

The IProcessingRestrictable interface marks entities whose processing can be suspended on data subject request. Like ISoftDeletable, it has a corresponding global query filter. Restricted records are invisible to normal queries but remain in the database for legal compliance.

public interface IProcessingRestrictable
{
bool IsProcessingRestricted { get; set; }
DateTimeOffset? ProcessingRestrictedAt { get; set; }
}

Granit.Privacy provides the IPrivacyDataProvider interface (namespace Granit.Privacy.DataExport). Each module that stores personal data registers a provider that streams its data as export fragments.

public sealed class PatientPrivacyDataProvider(
IPatientReader patients,
IStagedFragmentBuilder fragmentBuilder) : IPrivacyDataProvider
{
public static string ProviderName => "medical-records";
public static string DisplayKey => "Privacy.Scopes.MedicalRecords";
public static string? FeatureName => null;
public async ValueTask<bool> HasDataAsync(
PrivacyExportContext context, CancellationToken cancellationToken)
=> await patients.ExistsAsync(context.SubjectUserId, cancellationToken);
public async IAsyncEnumerable<ExportFragment> ExportAsync(
PrivacyExportContext context,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// Stage each record as a fragment; the archive assembler picks them up.
yield return await fragmentBuilder.BuildJsonAsync(
context,
ProviderName,
"medical-records/patient.json",
await patients.GetAsync(context.SubjectUserId, cancellationToken),
cancellationToken);
}
}

The static abstract members (ProviderName, DisplayKey, FeatureName) let the privacy module enumerate and localize export scopes without instantiating a provider. FeatureName gates the scope behind a feature flag when non-null.

Fragments are streamed rather than buffered: StagedExportFragment points at a blob the provider wrote, PassThroughExportFragment references bytes that already live in blob storage. The privacy module aggregates every registered provider into one signed archive — satisfying the right to data portability without any module knowing about the others.

Two encryption strategies are available, both accessible through IStringEncryptionService:

  • Granit.Encryption — AES-256-CBC with a local key. Suitable for development and non-production environments.
  • Granit.Vault — Abstraction layer (ITransitEncryptionService, IDatabaseCredentialProvider). Provider packages supply the implementations.
  • Granit.Vault.HashiCorp — HashiCorp Vault Transit engine. The encryption key never leaves Vault. Required for production under ISO 27001.
  • Granit.Vault.Azure — Azure Key Vault RSA-OAEP-256 encryption. The encryption key never leaves Azure Key Vault. Alternative production backend for Azure-hosted environments.
public class PatientService(IStringEncryptionService encryption)
{
public async Task<string> StoreNationalIdAsync(
string nationalId, CancellationToken cancellationToken)
{
string encrypted = await encryption
.EncryptAsync(nationalId, cancellationToken)
.ConfigureAwait(false);
// Store encrypted value -- plaintext never persisted
return encrypted;
}
}

AuditedEntityInterceptor is registered globally through ApplyGranitConventions. Every entity that inherits from AuditedEntity gets four fields filled automatically on every write:

FieldSet onSource
CreatedAtInsertIClock.Now (always UTC)
CreatedByInsertICurrentUserService.UserId (or "system")
ModifiedAtInsert + UpdateIClock.Now
ModifiedByInsert + UpdateICurrentUserService.UserId (or "system")

Application code never sets these fields. The interceptor runs inside the SaveChangesAsync pipeline, so it participates in the same transaction as the business write. Retention: 3 years minimum.

IStringEncryptionService provides a unified API for encrypting sensitive fields before they reach the database. Two implementations:

ImplementationKey managementUse case
AesStringEncryptionProviderLocal config key (AES-256-CBC)Development, testing
HashiCorpTransitEncryptionServiceHashiCorp Vault TransitProduction (key never leaves Vault)
AzureKeyVaultStringEncryptionProviderAzure Key Vault RSA-OAEP-256Production on Azure (key never leaves Key Vault)

The Vault module is conditionally loaded — it disables itself in Development environment, falling back to the local AES implementation automatically.

Granit enforces HTTPS-only. The GranitAuthenticationJwtBearerModule sets RequireHttpsMetadata = true by default. There is no HTTP fallback configuration option in production — the option validator rejects it outside Development.

Multi-tenancy provides three isolation levels, each mapping to a different ISO 27001 control:

StrategyIsolationData residencyUse case
SharedDatabaseLogical (query filter on TenantId)Same databaseCost-effective, most deployments
SchemaPerTenantSchema-levelSame server, separate schemasStronger isolation
DatabasePerTenantPhysicalSeparate databasesCertification requirement, data sovereignty

The IMultiTenant query filter is applied by ApplyGranitConventions — application code never writes WHERE TenantId = ....

The Timeline module records entity-level events with author, timestamp, and a Markdown body. Unlike the audit trail (which captures field-level changes), the Timeline captures business events: “Invoice approved by Marie”, “Patient file transferred to Dr. Dupont”.

public async Task ApproveInvoiceAsync(
Guid invoiceId,
ITimelineWriter timelineWriter,
CancellationToken cancellationToken)
{
// ... business logic ...
await timelineWriter.WriteAsync(new TimelineEntry
{
EntityType = "Invoice",
EntityId = invoiceId.ToString(),
Action = "Approved",
Body = "Invoice approved for payment."
}, cancellationToken).ConfigureAwait(false);
}

Granit provides IClock (and integrates with .NET’s TimeProvider) as the sole source of time. IClock.Now always returns UTC. The framework never calls DateTime.Now or DateTime.UtcNow directly.

Which Granit modules enforce which compliance requirements:

ModuleGDPR Art. 17GDPR Art. 18GDPR Art. 25ISO 27001 AuditISO 27001 Encryption
Granit.PersistenceSoft deleteProcessing restrictionAudit interceptor
Granit.PrivacyData exportPrivacy by design
Granit.EncryptionPseudonymizationAES-256-CBC
Granit.VaultPseudonymizationAbstraction layer
Granit.Vault.HashiCorpPseudonymizationTransit engine
Granit.Vault.AzurePseudonymizationAzure Key Vault RSA
Granit.MultiTenancyData isolationTenant isolation
Granit.UsersActor attribution
Granit.AuthorizationPermission audit
Granit.ObservabilityStructured logs
Granit.TimelineActivity log
Granit.WorkflowTransition log
Granit.Templating
Granit.TimingUTC enforcement

Why soft delete instead of physical delete? Audit retention. ISO 27001 requires a 3-year audit trail. If you physically delete a record, the audit entries referencing it become orphaned. Soft delete preserves referential integrity while hiding the record from normal queries. Physical deletion is available as an explicit administrative action for end-of-retention cleanup.

Why two encryption implementations? Development velocity. Developers should not need a running Vault instance to work locally. The IsEnabled check on GranitVaultModule automatically falls back to AES in development. In production, the Vault Transit engine provides key rotation, access logging, and HSM backing — none of which a local key can offer.

Why global query filters instead of repository-level checks? A missed WHERE clause is a data breach. Global filters on ISoftDeletable, IMultiTenant, IProcessingRestrictable, IActive, and IPublishable are applied by ApplyGranitConventions in OnModelCreating. You cannot forget them because you never write them.