Skip to content

Compliance

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.

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);
}
}

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; }
string? ProcessingRestrictedBy { get; set; }
}

Granit.Privacy provides the IPersonalDataProvider interface. Each module that stores personal data registers a provider that can export its data in a structured format.

public class PatientPersonalDataProvider : IPersonalDataProvider
{
public string Category => "Medical records";
public async Task<PersonalDataExport> ExportAsync(
string userId, CancellationToken cancellationToken)
{
// Query all personal data for this user
// Return structured export (JSON-serializable)
}
}

The privacy module aggregates all registered providers to produce a complete data export for a given user — satisfying the right to data portability without each 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
AesStringEncryptionServiceLocal config key (AES-256-CBC)Development, testing
VaultTransitEncryptionServiceHashiCorp 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 GranitJwtBearerModule 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.SecurityActor 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.