Repository navigation
Expand file tree
/
Copy pathConfigurations.cs
More file actions
411 lines (350 loc) · 21 KB
/
Copy pathConfigurations.cs
File metadata and controls
411 lines (350 loc) · 21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
using LearnStack.Infrastructure.DataProtection;
using LearnStack.Infrastructure.Persistence;
using LearnStack.Modules.Tenancy.Domain;
using LearnStack.SharedKernel.DataProtection;
using LearnStack.SharedKernel.Identifiers;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence;
internal sealed class TenantConfiguration : IEntityTypeConfiguration<Tenant>
{
public void Configure(EntityTypeBuilder<Tenant> builder)
{
builder.ToTable("tenants");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.HasConversion<TenantId.EfCoreValueConverter, TenantId.EfCoreValueComparer>()
.ValueGeneratedNever();
builder.Property(x => x.Slug).HasMaxLength(63).IsRequired();
builder.Property(x => x.DisplayName).HasMaxLength(200).IsRequired();
builder.Property(x => x.Status).HasEnumAsText().IsRequired();
builder.Property(x => x.AdoptedSeedOriginVersion).HasDefaultValue(0).IsRequired();
builder.Property(x => x.DefaultOrganizationId)
.HasConversion<OrganizationId.EfCoreValueConverter, OrganizationId.EfCoreValueComparer>();
// Globally unique, not per tenant: a slug appears in hostnames and is
// public by construction, which is why the leak a unique index causes
// (PostgreSQL enforces them with row security bypassed) is accepted here
// and nowhere else.
builder.HasIndex(x => x.Slug).IsUnique().HasDatabaseName("ux_tenants_slug");
builder.MapAuditColumns<Tenant, TenantId>();
builder.DeclarePersonalData(PersonalDataCategory.None,
"Institution registration, public naming, lifecycle and default branch routing.",
nameof(Tenant.Id), nameof(Tenant.Slug), nameof(Tenant.DisplayName),
nameof(Tenant.Status), nameof(Tenant.DefaultOrganizationId), nameof(Tenant.AdoptedSeedOriginVersion));
// The containment the aggregate boundary decides, expressed for EF — and NOT as
// owned types. An owned mapping looks like the natural way to say "part of the
// root" and silently removes both query filters: the filter builder skips
// entityType.IsOwned(), so the rows would lose the EF half of the four-layer
// isolation and the correspondence test would fail looking for a type that is no
// longer in the model on its own.
//
// OnDelete is explicit because EF defaults a required relationship to Cascade
// while the shipped DDL is ON DELETE RESTRICT; the constraint names match what
// the first migration already created as raw SQL, so no foreign key is scaffolded
// twice.
builder.HasMany(t => t.Locales)
.WithOne()
.HasForeignKey(l => l.TenantId)
.HasConstraintName("fk_tenant_locales_tenant")
.OnDelete(DeleteBehavior.Restrict);
builder.HasMany(t => t.FeatureFlags)
.WithOne()
.HasForeignKey(f => f.TenantId)
.HasConstraintName("fk_tenant_feature_flags_tenant")
.OnDelete(DeleteBehavior.Restrict);
// Through the backing fields, not the read-only views: the views are
// AsReadOnly() wrappers EF cannot add to, and materialising into them would throw
// at the first query that loads a tenant with locales.
builder.Metadata.FindNavigation(nameof(Tenant.Locales))!
.SetPropertyAccessMode(PropertyAccessMode.Field);
builder.Metadata.FindNavigation(nameof(Tenant.FeatureFlags))!
.SetPropertyAccessMode(PropertyAccessMode.Field);
}
}
internal sealed class OrganizationConfiguration : IEntityTypeConfiguration<Organization>
{
public void Configure(EntityTypeBuilder<Organization> builder)
{
builder.ToTable("organizations");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.HasConversion<OrganizationId.EfCoreValueConverter, OrganizationId.EfCoreValueComparer>()
.ValueGeneratedNever();
builder.Property(x => x.TenantId)
.HasConversion<TenantId.EfCoreValueConverter, TenantId.EfCoreValueComparer>()
.IsRequired();
builder.Property(x => x.Slug).HasMaxLength(63).IsRequired();
builder.Property(x => x.DisplayName).HasMaxLength(200).IsRequired();
builder.Property(x => x.CustomSubdomain).HasMaxLength(253);
builder.Property(x => x.Status).HasEnumAsText().IsRequired();
builder.Property(x => x.ReportingParentId)
.HasConversion<OrganizationId.EfCoreValueConverter, OrganizationId.EfCoreValueComparer>();
// Partial on `deleted_at IS NULL`, for the reason ux_tenant_domains_host is:
// a soft-deleted row that keeps its natural key holds that name against the
// tenant forever, and nothing frees it.
builder.HasIndex(x => new { x.TenantId, x.Slug })
.IsUnique()
.HasFilter("deleted_at IS NULL")
.HasDatabaseName("ux_organizations_tenant_id_slug");
// Exists solely so tenants.default_organization_id — and every future
// org-scoped child — can carry a composite foreign key into this table.
// Looks redundant beside the primary key and is not: a single-column FK
// between two tenant-owned tables lets tenant A reference tenant B's row,
// because referential-integrity checks run with row security bypassed.
builder.HasIndex(x => new { x.TenantId, x.Id })
.IsUnique()
.HasDatabaseName("ux_organizations_tenant_id_id");
// fk_organizations_reporting_parent's own columns. Standards 05 § Indexes
// says index every foreign key, and the two indexes above only lead with
// tenant_id — neither can serve the ON DELETE RESTRICT scan that looks for
// children of the organization being deleted.
builder.HasIndex(x => new { x.TenantId, x.ReportingParentId })
.HasDatabaseName("ix_organizations_tenant_id_reporting_parent_id");
builder.MapAuditColumns<Organization, OrganizationId>();
builder.DeclarePersonalData(PersonalDataCategory.None,
"Institution branch identity, public naming, lifecycle and reporting structure.",
nameof(Organization.Id), nameof(Organization.TenantId), nameof(Organization.Slug),
nameof(Organization.DisplayName), nameof(Organization.CustomSubdomain),
nameof(Organization.Status), nameof(Organization.ReportingParentId));
}
}
internal sealed class TenantDomainConfiguration : IEntityTypeConfiguration<TenantDomain>
{
public void Configure(EntityTypeBuilder<TenantDomain> builder)
{
builder.ToTable("tenant_domains");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.HasConversion<TenantDomainId.EfCoreValueConverter, TenantDomainId.EfCoreValueComparer>()
.ValueGeneratedNever();
builder.Property(x => x.TenantId)
.HasConversion<TenantId.EfCoreValueConverter, TenantId.EfCoreValueComparer>()
.IsRequired();
builder.Property(x => x.Host).HasMaxLength(253).IsRequired();
builder.Property(x => x.Kind).HasEnumAsText().IsRequired();
builder.Property(x => x.Status).HasEnumAsText().IsRequired();
builder.Property(x => x.VerifiedAt);
builder.Property(x => x.VerificationAttempts).HasDefaultValue(0).IsRequired();
builder.Property(x => x.LastVerificationError).HasMaxLength(1000);
// Globally unique, and the second — with `tenants.slug` — of the two
// cases Database Standards § Table classes sanctions on a tenant-owned
// table: a host resolving to two tenants is unresolvable regardless of who
// owns it. It costs the same leak the slug costs, because PostgreSQL
// enforces unique indexes with row security bypassed, so a duplicate
// insert reveals that *some* tenant already claims the host.
//
// Partial on `deleted_at IS NULL`, which is not decoration: without the
// predicate a soft-deleted claim keeps the name forever, and ADR-0036
// § Custom domains contemplates a released-then-re-registered domain —
// a lifecycle a table-wide unique makes unimplementable.
builder.HasIndex(x => x.Host)
.IsUnique()
.HasFilter("deleted_at IS NULL")
.HasDatabaseName("ux_tenant_domains_host");
builder.HasIndex(x => x.TenantId).HasDatabaseName("ix_tenant_domains_tenant_id");
builder.MapAuditColumns<TenantDomain, TenantDomainId>();
builder.DeclarePersonalData(PersonalDataCategory.None,
"Institution domain ownership and verification state, rather than a person's address.",
nameof(TenantDomain.Id), nameof(TenantDomain.TenantId), nameof(TenantDomain.Host),
nameof(TenantDomain.Kind), nameof(TenantDomain.Status), nameof(TenantDomain.VerifiedAt),
nameof(TenantDomain.VerificationAttempts));
builder.DeclarePersonalData(PersonalDataCategory.Identity,
"Retained free-text verification diagnostics may contain operator or contact identifiers.",
nameof(TenantDomain.LastVerificationError));
}
}
internal sealed class TenantLocaleConfiguration : IEntityTypeConfiguration<TenantLocale>
{
public void Configure(EntityTypeBuilder<TenantLocale> builder)
{
builder.ToTable("tenant_locales", table => table.HasCheckConstraint(
"ck_tenant_locales_default_enabled", "NOT is_default OR is_enabled"));
// Composite natural key, no surrogate id: a second row for the same
// tenant and locale is not a second locale, it is a duplicate.
builder.HasKey(x => new { x.TenantId, x.Locale })
.HasName("pk_tenant_locales");
builder.Property(x => x.TenantId)
.HasConversion<TenantId.EfCoreValueConverter, TenantId.EfCoreValueComparer>()
.IsRequired();
builder.Property(x => x.Locale).HasMaxLength(35).IsRequired();
builder.Property(x => x.IsDefault).IsRequired();
builder.Property(x => x.IsEnabled).HasDefaultValue(true).IsRequired();
builder.Property(x => x.Sort).HasDefaultValue((short)0).IsRequired();
// The single-default invariant, in the one place it holds across concurrent
// transactions. The aggregate guard on Tenant produces the error message; this
// produces the guarantee — two transactions each promoting a different locale
// both pass an in-memory check and one of them must lose here.
//
// The filter is raw SQL evaluated after the snake-case convention runs, so it is
// spelled is_default and not IsDefault — the same spelling the three shipped
// HasFilter("deleted_at IS NULL") calls use.
builder.HasIndex(x => x.TenantId)
.IsUnique()
.HasFilter("is_default")
.HasDatabaseName("ux_tenant_locales_tenant_id_is_default");
builder.DeclarePersonalData(PersonalDataCategory.None,
"Institution publication languages, enabled state and language-switcher ordering.",
nameof(TenantLocale.TenantId), nameof(TenantLocale.Locale), nameof(TenantLocale.IsDefault),
nameof(TenantLocale.IsEnabled), nameof(TenantLocale.Sort));
}
}
internal sealed class TenantSettingConfiguration : IEntityTypeConfiguration<TenantSetting>
{
public void Configure(EntityTypeBuilder<TenantSetting> builder)
{
builder.ToTable("tenant_settings");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.HasConversion<TenantSettingId.EfCoreValueConverter, TenantSettingId.EfCoreValueComparer>()
.ValueGeneratedNever();
builder.Property(x => x.TenantId)
.HasConversion<TenantId.EfCoreValueConverter, TenantId.EfCoreValueComparer>()
.IsRequired();
builder.Property(x => x.OrganizationId)
.HasConversion<OrganizationId.EfCoreValueConverter, OrganizationId.EfCoreValueComparer>();
builder.Property(x => x.Key).HasMaxLength(200).IsRequired();
builder.Property(x => x.Value).HasColumnType("jsonb").IsRequired();
// NULLS NOT DISTINCT, expressed in the model rather than patched into the
// migration afterwards: organization_id is null on every tenant-wide row,
// and a standard UNIQUE treats nulls as distinct — so without this a tenant
// could hold unlimited duplicate tenant-wide rows for one key, which is
// precisely the set a single-organization tenant creates exclusively, and
// resolution would pick one arbitrarily.
builder.HasIndex(x => new { x.TenantId, x.OrganizationId, x.Key })
.IsUnique()
.AreNullsDistinct(false)
.HasFilter("deleted_at IS NULL")
.HasDatabaseName("ux_tenant_settings_tenant_id_organization_id_key");
builder.HasIndex(x => new { x.TenantId, x.OrganizationId })
.HasDatabaseName("ix_tenant_settings_tenant_id_organization_id");
builder.MapAuditColumns<TenantSetting, TenantSettingId>();
builder.DeclarePersonalData(PersonalDataCategory.None,
"Institution and branch configuration addressing; the payload is classified separately.",
nameof(TenantSetting.Id), nameof(TenantSetting.TenantId),
nameof(TenantSetting.OrganizationId), nameof(TenantSetting.Key));
builder.DeclarePersonalData(PersonalDataCategory.Identity,
"Generic configuration JSON may contain contact or other person-identifying values.",
nameof(TenantSetting.Value));
}
}
internal sealed class TenantFeatureFlagConfiguration : IEntityTypeConfiguration<TenantFeatureFlag>
{
public void Configure(EntityTypeBuilder<TenantFeatureFlag> builder)
{
builder.ToTable("tenant_feature_flags");
builder.HasKey(x => new { x.TenantId, x.Key }).HasName("pk_tenant_feature_flags");
builder.Property(x => x.TenantId)
.HasConversion<TenantId.EfCoreValueConverter, TenantId.EfCoreValueComparer>()
.IsRequired();
builder.Property(x => x.Key).HasMaxLength(200).IsRequired();
builder.Property(x => x.Value).HasColumnType("jsonb").IsRequired();
// DEFAULT now(), as 21-feature-flags.md declares it. A flag row written by
// raw SQL that omits the column would otherwise violate NOT NULL.
builder.Property(x => x.UpdatedAt).HasDefaultValueSql("now()").IsRequired();
builder.Property(x => x.UpdatedBy)
.HasConversion<UserId.EfCoreValueConverter, UserId.EfCoreValueComparer>()
.IsRequired();
builder.DeclarePersonalData(PersonalDataCategory.None,
"Institution capability overrides: declared feature key and rollout value.",
nameof(TenantFeatureFlag.TenantId), nameof(TenantFeatureFlag.Key),
nameof(TenantFeatureFlag.Value));
builder.DeclarePersonalData(PersonalDataCategory.Identity,
"Actor attribution for the latest institution feature-flag change.",
nameof(TenantFeatureFlag.UpdatedBy));
builder.DeclarePersonalData(PersonalDataCategory.Audit,
"Timestamp of the attributed institution feature-flag change.",
nameof(TenantFeatureFlag.UpdatedAt));
}
}
internal sealed class PlatformEntitlementConfiguration : IEntityTypeConfiguration<PlatformEntitlement>
{
public void Configure(EntityTypeBuilder<PlatformEntitlement> builder)
{
builder.ToTable("platform_entitlement_cache");
// One row per tenant; the tenant id IS the key.
builder.HasKey(x => x.TenantId).HasName("pk_platform_entitlement_cache");
builder.Property(x => x.TenantId)
.HasConversion<TenantId.EfCoreValueConverter, TenantId.EfCoreValueComparer>()
.ValueGeneratedNever();
builder.Property(x => x.PlanCode).HasMaxLength(100).IsRequired();
builder.Property(x => x.Features).HasColumnType("jsonb").IsRequired();
builder.Property(x => x.Limits).HasColumnType("jsonb").IsRequired();
builder.Property(x => x.Compliance).HasColumnType("jsonb").IsRequired();
// NOT required: null is "no scheduled expiry", which is what the Hub sends for
// every trial and perpetual licence (ADR-0045 Amendment 1 § 2).
builder.Property(x => x.ValidUntil);
builder.Property(x => x.GraceUntil);
builder.Property(x => x.Generation).HasDefaultValue(1L).IsRequired();
// DEFAULT now(), as 21-feature-flags.md declares it — the same reason as
// tenant_feature_flags.updated_at, and the same provisioning insert.
builder.Property(x => x.RefreshedAt).HasDefaultValueSql("now()").IsRequired();
// Closed set, so text + CHECK rather than a length cap.
builder.Property(x => x.Source).HasColumnType("text").IsRequired();
builder.DeclarePersonalData(PersonalDataCategory.None,
"Institution plan capabilities, numeric limits, compliance settings and projection freshness.",
nameof(PlatformEntitlement.TenantId), nameof(PlatformEntitlement.PlanCode),
nameof(PlatformEntitlement.Features), nameof(PlatformEntitlement.Limits),
nameof(PlatformEntitlement.Compliance), nameof(PlatformEntitlement.ValidUntil),
nameof(PlatformEntitlement.GraceUntil), nameof(PlatformEntitlement.Generation),
nameof(PlatformEntitlement.RefreshedAt), nameof(PlatformEntitlement.Source));
}
}
internal sealed class PlatformKillswitchConfiguration : IEntityTypeConfiguration<PlatformKillswitch>
{
public void Configure(EntityTypeBuilder<PlatformKillswitch> builder)
{
builder.ToTable("platform_killswitches");
// The key is the key. One switch per key, and no surrogate to be out of step
// with it.
builder.HasKey(x => x.Key).HasName("pk_platform_killswitches");
// Long enough for any `{module}.{capability}` key the registry declares, and
// bounded because an unbounded primary key is an unbounded index.
builder.Property(x => x.Key).HasMaxLength(150).IsRequired();
builder.Property(x => x.IsEnabled).IsRequired();
builder.Property(x => x.Reason);
builder.Property(x => x.ToggledAt).IsRequired();
// No conversion and no foreign key: see PlatformKillswitch.ToggledBy.
builder.Property(x => x.ToggledBy);
builder.DeclarePersonalData(PersonalDataCategory.None,
"Deployment-wide capability key and enabled state, with no subject or tenant value.",
nameof(PlatformKillswitch.Key), nameof(PlatformKillswitch.IsEnabled));
builder.DeclarePersonalData(PersonalDataCategory.Identity,
"Operator attribution and free-text explanation of a deployment-wide capability change.",
nameof(PlatformKillswitch.ToggledBy), nameof(PlatformKillswitch.Reason));
builder.DeclarePersonalData(PersonalDataCategory.Audit,
"Timestamp of the attributed deployment-wide capability change.",
nameof(PlatformKillswitch.ToggledAt));
}
}
internal sealed class PlatformHostMappingConfiguration : IEntityTypeConfiguration<PlatformHostMapping>
{
public void Configure(EntityTypeBuilder<PlatformHostMapping> builder)
{
builder.ToTable("platform_host_to_tenant");
// The host is the key: one answer per host, enforced by the primary key
// rather than by a unique index over a surrogate.
builder.HasKey(x => x.Host).HasName("pk_platform_host_to_tenant");
builder.Property(x => x.Host).HasMaxLength(253).IsRequired();
builder.Property(x => x.TenantId)
.HasConversion<TenantId.EfCoreValueConverter, TenantId.EfCoreValueComparer>()
.IsRequired();
builder.Property(x => x.OrganizationId)
.HasConversion<OrganizationId.EfCoreValueConverter, OrganizationId.EfCoreValueComparer>();
builder.Property(x => x.IsActive).IsRequired();
builder.Property(x => x.IsPubliclyLive).IsRequired();
// Two jobs, one index. A tenant listing its own hosts is the second of the
// two read paths the policy admits (the first, by host, is the key), and
// tenant_id leads, so the composite serves it. The organization column is
// there because fk_platform_host_to_tenant_organization is composite on
// (tenant_id, organization_id) and Standards 05 § Indexes says index every
// foreign key — a leading-column-only index does not serve the ON DELETE
// RESTRICT scan, it just narrows it to the tenant.
builder.HasIndex(x => new { x.TenantId, x.OrganizationId })
.HasDatabaseName("ix_platform_host_to_tenant_tenant_id_organization_id");
builder.DeclarePersonalData(PersonalDataCategory.None,
"Institution host-to-branch routing and public serving eligibility.",
nameof(PlatformHostMapping.Host), nameof(PlatformHostMapping.TenantId),
nameof(PlatformHostMapping.OrganizationId), nameof(PlatformHostMapping.IsActive),
nameof(PlatformHostMapping.IsPubliclyLive));
}
}