From 7bbc2cbddf3c19f7db5b0a748f51194a46489323 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 11 Sep 2026 19:30:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend):=20the=20tenant=20row=20records?= =?UTF-8?q?=20the=20accepted=20DPA=20version=20=E2=80=94=20HandleNewTenant?= =?UTF-8?q?DomainAsync=20writes=20Constants.CurrentDpaVersion=20(2026-09-1?= =?UTF-8?q?.7)=20into=20TenantConfiguration.DpaVersion=20in=20the=20same?= =?UTF-8?q?=20first=20write=20as=20OnboardedBy,=20once,=20and=20later=20lo?= =?UTF-8?q?gins=20never=20touch=20it;=20the=20field=20is=20system-owned=20?= =?UTF-8?q?(patch=20deny-list,=20PUT=20preserve,=20revert-protected),=20pr?= =?UTF-8?q?ojected=20by=20config/all,=20the=20MCP=20tenant=20list=20and=20?= =?UTF-8?q?the=20GA=20tenant=20report;=20no=20backfill=20(D-252)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- internal | 2 +- .../AuthFunctionSideEffectTests.cs | 19 +++++++++++++++++++ .../TenantConfigProjectionTests.cs | 2 +- .../TableStorage/TableConfigRepository.cs | 2 ++ .../Functions/Infrastructure/AuthFunction.cs | 4 ++++ .../Helpers/TenantConfigProjection.cs | 2 ++ .../Services/TenantConfigPatchService.cs | 8 ++++---- .../src/__tests__/security-guards.test.ts | 3 ++- .../src/generated/wire-types.generated.ts | 2 ++ .../autopilot-monitor-mcp/src/tools/admin.ts | 2 +- .../AutopilotMonitor.Shared/Constants.cs | 11 +++++++++++ .../Models/Config/TenantConfiguration.cs | 8 ++++++++ .../sections/tenantConfigReportCatalog.ts | 1 + .../utils/shared-manifests.generated.ts | 2 ++ .../utils/shared-manifests.json | 12 ++++++++++++ .../utils/wire-types.generated.ts | 2 ++ 16 files changed, 74 insertions(+), 8 deletions(-) diff --git a/internal b/internal index 0f451122..aea94d1b 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 0f451122d4ce92983844ff60bab74cc916e1aeb9 +Subproject commit aea94d1b23bdb7e00dd69868f4519e616b7638bc diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs index 3e0af720..43876e21 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs @@ -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(), It.IsAny()), 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(), It.IsAny(), It.IsAny()), 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(), It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task HandleNewTenantDomain_PreservesExistingOnboardedBy() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs index 31722306..b6569753 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs @@ -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", }; diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index bbe8172b..be2599d4 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -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, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs index be36add3..aa07bd63 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs @@ -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 diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs index ecd40d56..fe75783c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs @@ -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) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs index 649d66db..b807fced 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs @@ -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. /// internal static readonly HashSet 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 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" diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/security-guards.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/security-guards.test.ts index 2763e333..45ab41d4 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/security-guards.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/security-guards.test.ts @@ -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); } diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 644383cd..eaff94dc 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -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. */ diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 217c6435..24492c21 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -170,7 +170,7 @@ import { export const TENANT_SAFE_FIELDS: ReadonlySet = new Set([ 'tenantId', 'domainName', 'planTier', 'trialExpiresUtc', 'trialConsumed', 'disabled', 'disabledReason', - 'onboardedAt', 'onboardedBy', 'lastUpdated', 'dataRetentionDays', + 'onboardedAt', 'onboardedBy', 'dpaVersion', 'lastUpdated', 'dataRetentionDays', ]); /** diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index e5abf717..bbcd8dc9 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -5,6 +5,17 @@ namespace AutopilotMonitor.Shared /// public static class Constants { + // ----------------------------------------------------------------------- + // Legal + // ----------------------------------------------------------------------- + + /// + /// Version of the data processing agreement in force — the "Version" line of the customer + /// DPA page. Written once onto TenantConfiguration.DpaVersion when a tenant is onboarded + /// (D-252); bump it in the same change that publishes a new DPA version. + /// + public const string CurrentDpaVersion = "2026-09-1.7"; + // ----------------------------------------------------------------------- // Agent runtime defaults // ----------------------------------------------------------------------- diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 36695f62..d53e1d4d 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -44,6 +44,14 @@ namespace AutopilotMonitor.Shared.Models /// public string? OnboardedBy { get; set; } + /// + /// Version of the data processing agreement this tenant accepted with its onboarding — + /// at that moment. Set once in + /// HandleNewTenantDomainAsync together with and never + /// overwritten; never client-writable. Null on tenants onboarded before the field existed. + /// + public string? DpaVersion { get; set; } + /// /// 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 diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts index cddb2a6a..7556773c 100644 --- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts @@ -101,6 +101,7 @@ export const TENANT_FIELDS: Record