feat(backend): the tenant row records the accepted DPA version — HandleNewTenantDomainAsync writes Constants.CurrentDpaVersion (2026-09-1.7) into TenantConfiguration.DpaVersion in the same first write as OnboardedBy, once, and later logins never touch it; the field is system-owned (patch deny-list, PUT preserve, revert-protected), projected by config/all, the MCP tenant list and the GA tenant report; no backfill (D-252)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-11 19:30:16 +02:00
parent 9a78daa880
commit 7bbc2cbddf
16 changed files with 74 additions and 8 deletions
@@ -219,6 +219,8 @@ public class AuthFunctionSideEffectTests
// OnboardedBy is the immutable copy of the first-login UPN that auto-promote on
// preview approval reads — UpdatedBy may later be clobbered by background syncs.
Assert.Equal(Upn, config.OnboardedBy);
// The DPA version rides along in the same onboarding write (D-252).
Assert.Equal(CurrentDpa, config.DpaVersion);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
@@ -304,6 +306,23 @@ public class AuthFunctionSideEffectTests
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
private const string CurrentDpa = AutopilotMonitor.Shared.Constants.CurrentDpaVersion;
[Fact]
public async Task HandleNewTenantDomain_WhenDomainAlreadySet_NeverStampsDpaVersion()
{
// The DPA version is written once, with the onboarding (D-252): an existing tenant's
// login writes nothing — no stamp, no save.
var config = DefaultConfig();
config.DomainName = "contoso.com";
config.DpaVersion = null;
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
Assert.Null(config.DpaVersion);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task HandleNewTenantDomain_PreservesExistingOnboardedBy()
{
@@ -30,7 +30,7 @@ public class TenantConfigProjectionTests
"tenantId", "domainName", "planTier", "trialExpiresUtc", "trialConsumed", "maxDelegatedTenantsOverride",
"mcpUsagePlanOverride", "payingCustomer", "managedByProTenantId",
"disabled", "disabledReason", "mcpDisabled", "mcpDisabledReason",
"onboardedAt", "onboardedBy", "lastUpdated", "dataRetentionDays",
"onboardedAt", "onboardedBy", "dpaVersion", "lastUpdated", "dataRetentionDays",
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
};
@@ -662,6 +662,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "LastUpdated", config.LastUpdated },
{ "UpdatedBy", config.UpdatedBy },
{ "OnboardedBy", config.OnboardedBy },
{ "DpaVersion", config.DpaVersion },
{ "ContactEmail", config.ContactEmail },
{ "CompanyName", config.CompanyName },
{ "Disabled", config.Disabled },
@@ -775,6 +776,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow,
UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown",
OnboardedBy = entity.GetString("OnboardedBy"),
DpaVersion = entity.GetString("DpaVersion"),
ContactEmail = entity.GetString("ContactEmail"),
CompanyName = entity.GetString("CompanyName"),
Disabled = entity.GetBoolean("Disabled") ?? false,
@@ -387,6 +387,10 @@ public class AuthFunction
// sync jobs that mutate UpdatedBy cannot leak sentinel strings into TenantAdmins.
if (string.IsNullOrWhiteSpace(tenantConfig.OnboardedBy))
tenantConfig.OnboardedBy = upn;
// The DPA is accepted with the onboarding itself (D-251/D-252): record the version in force,
// once, in this same first write — later logins never touch it.
if (string.IsNullOrWhiteSpace(tenantConfig.DpaVersion))
tenantConfig.DpaVersion = Constants.CurrentDpaVersion;
// Dual app-reg window: home a NEW tenant on the primary app ONLY when its first login
// actually arrived via the primary app. A first login via the legacy app leaves the field
// null (= legacy) — keeps the "null = legacy" invariant clean and never routes a tenant
@@ -39,6 +39,8 @@ namespace AutopilotMonitor.Functions.Helpers
("mcpDisabledReason", c => c.McpDisabledReason),
("onboardedAt", c => c.OnboardedAt),
("onboardedBy", c => c.OnboardedBy),
// DPA version accepted at onboarding — a version label, not a secret.
("dpaVersion", c => c.DpaVersion),
("lastUpdated", c => c.LastUpdated),
("dataRetentionDays", c => c.DataRetentionDays),
// App-reg migration observability: which app the tenant is homed on (null = legacy)
@@ -104,12 +104,12 @@ namespace AutopilotMonitor.Functions.Services
/// with dedicated endpoints (plan/trial), and system-written provenance:
/// HomedAppClientId caused the 2026-07-31 prod incident when a stale round-trip
/// reverted it — only the app-homing flow writes it; LastAuthClientId*/OnboardedBy/
/// OnboardedAt are AuthFunction-owned; LastUpdated/UpdatedBy are stamped server-side.
/// OnboardedAt/DpaVersion are AuthFunction-owned; LastUpdated/UpdatedBy are stamped server-side.
/// </summary>
internal static readonly HashSet<string> BaseDeniedFields = new(StringComparer.OrdinalIgnoreCase)
{
"TenantId", "DomainName", "PartitionKey", "RowKey", "Timestamp", "ETag",
"LastUpdated", "UpdatedBy", "OnboardedAt", "OnboardedBy",
"LastUpdated", "UpdatedBy", "OnboardedAt", "OnboardedBy", "DpaVersion",
"HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince",
"PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy",
"ProDowngradedUtc", "MaxDelegatedTenantsOverride", "McpUsagePlanOverride", "PayingCustomer",
@@ -159,7 +159,7 @@ namespace AutopilotMonitor.Functions.Services
internal static readonly HashSet<string> RevertProtectedFields = new(StringComparer.OrdinalIgnoreCase)
{
"HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince",
"OnboardedBy", "OnboardedAt",
"OnboardedBy", "OnboardedAt", "DpaVersion",
"PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy",
"ProDowngradedUtc", "MaxDelegatedTenantsOverride", "McpUsagePlanOverride", "PayingCustomer",
};
@@ -505,7 +505,7 @@ namespace AutopilotMonitor.Functions.Services
{
"TenantId" or "DomainName" => "identity — never writable",
"LastUpdated" or "UpdatedBy" => "stamped server-side on every write",
"OnboardedAt" or "OnboardedBy" => "system-owned onboarding provenance (set at first login)",
"OnboardedAt" or "OnboardedBy" or "DpaVersion" => "system-owned onboarding provenance (set at first login)",
"HomedAppClientId" => "system-owned — written only by the app-homing flow",
"LastAuthClientId" or "LastAuthClientIdSince" => "system-owned auth provenance",
"PlanTier" or "TrialExpiresUtc" or "TrialStartedUtc" or "TrialConsumed" or "TrialGrantedBy"
@@ -533,6 +533,7 @@ describe('list_tenants — extractTenantList keep-list projection', () => {
disabled: false,
onboardedAt: '2026-01-15T00:00:00Z',
onboardedBy: 'alice@contoso.example.com',
dpaVersion: '2026-09-1.7',
lastUpdated: '2026-05-01T00:00:00Z',
dataRetentionDays: 90,
// ── sensitive / must NOT leak ──
@@ -553,7 +554,7 @@ describe('list_tenants — extractTenantList keep-list projection', () => {
for (const key of Object.keys(t)) expect(TENANT_SAFE_FIELDS.has(key)).toBe(true);
for (const present of [
'tenantId', 'domainName', 'planTier', 'disabled',
'onboardedAt', 'onboardedBy', 'lastUpdated', 'dataRetentionDays',
'onboardedAt', 'onboardedBy', 'dpaVersion', 'lastUpdated', 'dataRetentionDays',
]) {
expect(t).toHaveProperty(present);
}
@@ -4666,6 +4666,8 @@ export interface TenantConfiguration {
updatedBy: string;
/** UPN of the user whose first login created this tenant configuration. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten. Used by the preview-approval auto-promote path so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel string into the TenantAdmins table. Null on rows that pre-date the OnboardedBy field — auto-promote falls back to UpdatedBy with a UPN-shape guard. */
onboardedBy?: string | null;
/** Version of the data processing agreement this tenant accepted with its onboarding — CurrentDpaVersion at that moment. Set once in HandleNewTenantDomainAsync together with OnboardedBy and never overwritten; never client-writable. Null on tenants onboarded before the field existed. */
dpaVersion?: string | null;
/** Address used to reach this tenant about the service itself — a technical problem, a security matter, or a change that needs an administrator's attention. Editable by the tenant's own admins under Settings → Tenant → Contact. Seeded once at onboarding from the tenant's notification address if one was given, and never re-synced afterwards: from that point the value belongs to the tenant, and a later edit must not be overwritten by the onboarding source. Purpose-limited by design — service communication only. It is never used for marketing and never disclosed. Null means we have no way to reach this tenant, which is why enforcement actions cannot promise prior warning. */
contactEmail?: string | null;
/** Organization name behind this tenant, as the tenant's admins want it read by a support engineer — the counterpart of ContactEmail in the tenant's contact profile. Editable under Settings → Tenant → Contact. Optional on Community. Together with it is required at the self-service Pro entry point (trial start) so a paying tenant is reachable and identifiable for support; it is never a runtime gate on Pro features, and a GA plan assignment does not require it (the admin UI warns instead). Never derived from : the domain is a technical label, the company name is what the tenant tells us. Null means not provided. */
@@ -170,7 +170,7 @@ import {
export const TENANT_SAFE_FIELDS: ReadonlySet<string> = new Set([
'tenantId', 'domainName', 'planTier', 'trialExpiresUtc', 'trialConsumed',
'disabled', 'disabledReason',
'onboardedAt', 'onboardedBy', 'lastUpdated', 'dataRetentionDays',
'onboardedAt', 'onboardedBy', 'dpaVersion', 'lastUpdated', 'dataRetentionDays',
]);
/**
@@ -5,6 +5,17 @@ namespace AutopilotMonitor.Shared
/// </summary>
public static class Constants
{
// -----------------------------------------------------------------------
// Legal
// -----------------------------------------------------------------------
/// <summary>
/// Version of the data processing agreement in force — the "Version" line of the customer
/// DPA page. Written once onto <c>TenantConfiguration.DpaVersion</c> when a tenant is onboarded
/// (D-252); bump it in the same change that publishes a new DPA version.
/// </summary>
public const string CurrentDpaVersion = "2026-09-1.7";
// -----------------------------------------------------------------------
// Agent runtime defaults
// -----------------------------------------------------------------------
@@ -44,6 +44,14 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string? OnboardedBy { get; set; }
/// <summary>
/// Version of the data processing agreement this tenant accepted with its onboarding —
/// <see cref="Constants.CurrentDpaVersion"/> at that moment. Set once in
/// <c>HandleNewTenantDomainAsync</c> together with <see cref="OnboardedBy"/> and never
/// overwritten; never client-writable. Null on tenants onboarded before the field existed.
/// </summary>
public string? DpaVersion { get; set; }
/// <summary>
/// Address used to reach this tenant about the service itself — a technical problem,
/// a security matter, or a change that needs an administrator's attention. Editable by
@@ -101,6 +101,7 @@ export const TENANT_FIELDS: Record<keyof TenantConfiguration, TenantRow | Exclud
mcpDisabledReason: { section: "Tenant Status", label: "MCP Disabled Reason", informational: true },
onboardedAt: { section: "Tenant Status", label: "Onboarded At", kind: "date", informational: true },
onboardedBy: { section: "Tenant Status", label: "Onboarded By", informational: true },
dpaVersion: { section: "Tenant Status", label: "DPA Version", informational: true },
contactEmail: { section: "Tenant Status", label: "Contact Email", informational: true },
companyName: { section: "Tenant Status", label: "Company", informational: true },
@@ -124,6 +124,7 @@ export const SHARED_MANIFEST = {
"lastUpdated",
"updatedBy",
"onboardedBy",
"dpaVersion",
"contactEmail",
"companyName",
"onboardedAt",
@@ -228,6 +229,7 @@ export const SHARED_MANIFEST = {
"lastUpdated": "0001-01-01T00:00:00.0000000",
"updatedBy": null,
"onboardedBy": null,
"dpaVersion": null,
"contactEmail": null,
"companyName": null,
"onboardedAt": null,
@@ -120,6 +120,7 @@
"lastUpdated",
"updatedBy",
"onboardedBy",
"dpaVersion",
"contactEmail",
"companyName",
"onboardedAt",
@@ -224,6 +225,7 @@
"lastUpdated": "0001-01-01T00:00:00.0000000",
"updatedBy": null,
"onboardedBy": null,
"dpaVersion": null,
"contactEmail": null,
"companyName": null,
"onboardedAt": null,
@@ -21413,6 +21415,16 @@
"optional": true,
"doc": "UPN of the user whose first login created this tenant configuration. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten. Used by the preview-approval auto-promote path so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel string into the TenantAdmins table. Null on rows that pre-date the OnboardedBy field — auto-promote falls back to UpdatedBy with a UPN-shape guard."
},
{
"name": "dpaVersion",
"type": {
"kind": "primitive",
"name": "string",
"nullable": true
},
"optional": true,
"doc": "Version of the data processing agreement this tenant accepted with its onboarding — CurrentDpaVersion at that moment. Set once in HandleNewTenantDomainAsync together with OnboardedBy and never overwritten; never client-writable. Null on tenants onboarded before the field existed."
},
{
"name": "contactEmail",
"type": {
@@ -4666,6 +4666,8 @@ export interface TenantConfiguration {
updatedBy: string;
/** UPN of the user whose first login created this tenant configuration. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten. Used by the preview-approval auto-promote path so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel string into the TenantAdmins table. Null on rows that pre-date the OnboardedBy field — auto-promote falls back to UpdatedBy with a UPN-shape guard. */
onboardedBy?: string | null;
/** Version of the data processing agreement this tenant accepted with its onboarding — CurrentDpaVersion at that moment. Set once in HandleNewTenantDomainAsync together with OnboardedBy and never overwritten; never client-writable. Null on tenants onboarded before the field existed. */
dpaVersion?: string | null;
/** Address used to reach this tenant about the service itself — a technical problem, a security matter, or a change that needs an administrator's attention. Editable by the tenant's own admins under Settings → Tenant → Contact. Seeded once at onboarding from the tenant's notification address if one was given, and never re-synced afterwards: from that point the value belongs to the tenant, and a later edit must not be overwritten by the onboarding source. Purpose-limited by design — service communication only. It is never used for marketing and never disclosed. Null means we have no way to reach this tenant, which is why enforcement actions cannot promise prior warning. */
contactEmail?: string | null;
/** Organization name behind this tenant, as the tenant's admins want it read by a support engineer — the counterpart of ContactEmail in the tenant's contact profile. Editable under Settings → Tenant → Contact. Optional on Community. Together with it is required at the self-service Pro entry point (trial start) so a paying tenant is reachable and identifiable for support; it is never a runtime gate on Pro features, and a GA plan assignment does not require it (the admin UI warns instead). Never derived from : the domain is a technical label, the company name is what the tenant tells us. Null means not provided. */