mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 14:46:55 +01:00
Merge f199ba9566 into 0867171b1a
This commit is contained in:
commit
29b8b50ca8
8 changed files with 333 additions and 2 deletions
|
|
@ -0,0 +1,33 @@
|
|||
table 50567 "Contoso Activity Log"
|
||||
{
|
||||
DataClassification = SystemMetadata;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { AutoIncrement = true; }
|
||||
field(2; "Activity"; Text[250]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50563 "Contoso Activity Log Cleanup"
|
||||
{
|
||||
Access = Internal;
|
||||
|
||||
// The log table is never added to the allowed tables, so it cannot appear
|
||||
// on the Retention Policies page. Cleanup is hard-coded here instead:
|
||||
// the period is not configurable, the deletion is not written to the
|
||||
// Retention Policy Log, and an administrator cannot switch it off.
|
||||
trigger OnRun()
|
||||
var
|
||||
ContosoActivityLog: Record "Contoso Activity Log";
|
||||
begin
|
||||
ContosoActivityLog.SetFilter(
|
||||
SystemCreatedAt, '<%1', CreateDateTime(CalcDate('<-30D>', Today()), 0T));
|
||||
ContosoActivityLog.DeleteAll();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
table 50566 "Contoso Activity Log"
|
||||
{
|
||||
DataClassification = SystemMetadata;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { AutoIncrement = true; }
|
||||
field(2; "Activity"; Text[250]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50560 "Contoso Reten. Pol. Setup"
|
||||
{
|
||||
Access = Internal;
|
||||
|
||||
procedure AddAllowedTables()
|
||||
begin
|
||||
AddAllowedTables(false);
|
||||
end;
|
||||
|
||||
// ForceUpdate re-registers even after the upgrade tag is set, so the
|
||||
// table comes back when an administrator refreshes the allowed tables.
|
||||
procedure AddAllowedTables(ForceUpdate: Boolean)
|
||||
var
|
||||
ContosoActivityLog: Record "Contoso Activity Log";
|
||||
RetenPolAllowedTables: Codeunit "Reten. Pol. Allowed Tables";
|
||||
UpgradeTag: Codeunit "Upgrade Tag";
|
||||
IsInitialSetup: Boolean;
|
||||
begin
|
||||
IsInitialSetup := not UpgradeTag.HasUpgradeTag(AllowedTableTag());
|
||||
if not (IsInitialSetup or ForceUpdate) then
|
||||
exit;
|
||||
|
||||
if not RetenPolAllowedTables.IsAllowedTable(Database::"Contoso Activity Log") then
|
||||
RetenPolAllowedTables.AddAllowedTable(
|
||||
Database::"Contoso Activity Log",
|
||||
ContosoActivityLog.FieldNo(SystemCreatedAt),
|
||||
28); // support cases need at least four weeks of log history
|
||||
|
||||
if IsInitialSetup then
|
||||
UpgradeTag.SetUpgradeTag(AllowedTableTag());
|
||||
end;
|
||||
|
||||
local procedure AllowedTableTag(): Code[250]
|
||||
begin
|
||||
exit('Contoso-ActivityLogAllowedTable-20260910');
|
||||
end;
|
||||
|
||||
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Reten. Pol. Allowed Tables", OnRefreshAllowedTables, '', false, false)]
|
||||
local procedure AddAllowedTablesOnRefreshAllowedTables()
|
||||
begin
|
||||
AddAllowedTables(true);
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50561 "Contoso Reten. Pol. Install"
|
||||
{
|
||||
Subtype = Install;
|
||||
Access = Internal;
|
||||
|
||||
trigger OnInstallAppPerCompany()
|
||||
var
|
||||
ContosoRetenPolSetup: Codeunit "Contoso Reten. Pol. Setup";
|
||||
begin
|
||||
ContosoRetenPolSetup.AddAllowedTables();
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50562 "Contoso Reten. Pol. Upgrade"
|
||||
{
|
||||
Subtype = Upgrade;
|
||||
Access = Internal;
|
||||
|
||||
// Install code does not run on upgrade, so tenants that already have the
|
||||
// app get their registration here.
|
||||
trigger OnUpgradePerCompany()
|
||||
var
|
||||
ContosoRetenPolSetup: Codeunit "Contoso Reten. Pol. Setup";
|
||||
begin
|
||||
ContosoRetenPolSetup.AddAllowedTables();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
bc-version: [22..]
|
||||
domain: privacy
|
||||
keywords: [retention-policy, allowed-tables, addallowedtable, reten-pol-allowed-tables, onrefreshallowedtables, append-only-table, log-table-growth, deleteall, mandatory-minimum-retention, install-upgrade-codeunit]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Extension-owned log tables must be registered as retention-policy allowed tables
|
||||
|
||||
## Description
|
||||
|
||||
The retention policy engine only ever deletes from tables that appear in its allowed-tables list, and an extension may register only tables it owns — it cannot add a base application table or a table from another extension. Registration is a call to `Codeunit "Reten. Pol. Allowed Tables".AddAllowedTable`, passing the table ID and the field number of the Date or DateTime field that ages each record (`SystemCreatedAt` is the usual choice). Until that call has run in a company, the table cannot be selected on the **Retention Policies** page at all, so an activity log, integration log, or archive table the extension writes to grows with no supported way for an administrator to trim it. The registration is stored per company and is not part of the table's metadata — it exists only because install or upgrade code put it there.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Register every table the extension owns that accumulates rows over time: activity and audit logs, integration and API request logs, archived documents. Call a shared routine from both the install codeunit (`OnInstallAppPerCompany`) and an upgrade codeunit (`OnUpgradePerCompany`), because install code does not run when an existing installation moves to a new version — registration added only to install code never reaches tenants that already have the app. Guard the routine with `IsAllowedTable` and an upgrade tag so repeated runs are idempotent, but keep a force path past the tag: the **Retention Policies** pages raise `Reten. Pol. Allowed Tables.OnRefreshAllowedTables`, and the platform's own installers (System Application, Base Application, Shopify) subscribe to it and re-run registration with `ForceUpdate`. A routine that exits whenever the tag is set cannot take part in that refresh, so the upgrade tag should gate one-time setup only, not re-registration. Pass `MandatoryMinRetenDays` when the data must survive a minimum period for audit or support reasons; the platform then rejects any shorter period an administrator configures. When only a subset of rows should ever expire, build the filter with `AddTableFilterToJsonArray` and pass it to the `AddAllowedTable` overload that takes a `JsonArray` — a filter added as locked cannot be removed later by the administrator.
|
||||
|
||||
Registering a table only makes it eligible; the policy itself is a separate concern, covered by [`ship-a-default-retention-policy-setup.md`](ship-a-default-retention-policy-setup.md).
|
||||
|
||||
See sample: [`register-owned-log-tables-for-retention-policies.good.al`](register-owned-log-tables-for-retention-policies.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
An extension-owned table that only grows — the app inserts into it but never deletes from it — with no `AddAllowedTable` call anywhere in the app. The name is not a reliable signal: an "Incoming Data" buffer-history table grows the same way an "Activity Log" does, and such tables have reached hundreds of gigabytes on customer tenants. A variant is a table cleaned by hand-rolled code — a job queue codeunit or scheduled task running `DeleteAll` against a hard-coded date window: the deletion happens outside the **Retention Policy Log**, the administrator has no page on which to lengthen, shorten, or disable it, and the table is invisible during a data-retention review. The same defect in slower form is registration performed only in the install codeunit: new tenants are covered, every existing tenant stays unregistered after the upgrade.
|
||||
|
||||
See sample: [`register-owned-log-tables-for-retention-policies.bad.al`](register-owned-log-tables-for-retention-policies.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [Clean up data with retention policies](https://learn.microsoft.com/en-us/dynamics365/business-central/admin-data-retention-policies), section *Include your extension in a retention policy*.
|
||||
- [`RetenPolAllowedTables.Codeunit.al`](https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Retention%20Policy/src/Retention%20Policy%20Allowed%20Tables/RetenPolAllowedTables.Codeunit.al) in microsoft/BCApps — the `AddAllowedTable` overloads, `IsAllowedTable`, `AddTableFilterToJsonArray`, and `OnRefreshAllowedTables`.
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
table 50569 "Contoso Activity Log"
|
||||
{
|
||||
DataClassification = SystemMetadata;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { AutoIncrement = true; }
|
||||
field(2; "Activity"; Text[250]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
page 50570 "Contoso Activity Log"
|
||||
{
|
||||
PageType = List;
|
||||
ApplicationArea = All;
|
||||
UsageCategory = Lists;
|
||||
SourceTable = "Contoso Activity Log";
|
||||
Editable = false;
|
||||
// Registration only makes the table selectable on the Retention Policies
|
||||
// page. No Retention Policy Setup exists and nothing is deleted, yet the
|
||||
// page tells the administrator that cleanup is running.
|
||||
AboutTitle = 'About the activity log';
|
||||
AboutText = 'Entries older than six months are deleted automatically, so the log never needs manual cleanup.';
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
repeater(Entries)
|
||||
{
|
||||
field("Entry No."; Rec."Entry No.") { ToolTip = 'Specifies the entry number.'; }
|
||||
field(Activity; Rec.Activity) { ToolTip = 'Specifies the logged activity.'; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50565 "Contoso Reten. Pol. Register"
|
||||
{
|
||||
Access = Internal;
|
||||
|
||||
procedure AddAllowedTables()
|
||||
var
|
||||
ContosoActivityLog: Record "Contoso Activity Log";
|
||||
RetenPolAllowedTables: Codeunit "Reten. Pol. Allowed Tables";
|
||||
begin
|
||||
if not RetenPolAllowedTables.IsAllowedTable(Database::"Contoso Activity Log") then
|
||||
RetenPolAllowedTables.AddAllowedTable(
|
||||
Database::"Contoso Activity Log", ContosoActivityLog.FieldNo(SystemCreatedAt));
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50571 "Contoso Reten. Pol. Install"
|
||||
{
|
||||
Subtype = Install;
|
||||
Access = Internal;
|
||||
|
||||
trigger OnInstallAppPerCompany()
|
||||
var
|
||||
ContosoRetenPolRegister: Codeunit "Contoso Reten. Pol. Register";
|
||||
begin
|
||||
ContosoRetenPolRegister.AddAllowedTables();
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50572 "Contoso Reten. Pol. Upgrade"
|
||||
{
|
||||
Subtype = Upgrade;
|
||||
Access = Internal;
|
||||
|
||||
trigger OnUpgradePerCompany()
|
||||
var
|
||||
ContosoRetenPolRegister: Codeunit "Contoso Reten. Pol. Register";
|
||||
begin
|
||||
ContosoRetenPolRegister.AddAllowedTables();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
table 50568 "Contoso Activity Log"
|
||||
{
|
||||
DataClassification = SystemMetadata;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { AutoIncrement = true; }
|
||||
field(2; "Activity"; Text[250]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50564 "Contoso Reten. Pol. Default"
|
||||
{
|
||||
Access = Internal;
|
||||
Permissions = tabledata "Retention Policy Setup" = ri;
|
||||
|
||||
procedure CreateDefaultPolicy()
|
||||
var
|
||||
RetentionPolicySetup: Record "Retention Policy Setup";
|
||||
RetentionPolicySetupMgt: Codeunit "Retention Policy Setup";
|
||||
RetenPolAllowedTables: Codeunit "Reten. Pol. Allowed Tables";
|
||||
UpgradeTag: Codeunit "Upgrade Tag";
|
||||
begin
|
||||
// A setup can only be created for a table that is already registered.
|
||||
if not RetenPolAllowedTables.IsAllowedTable(Database::"Contoso Activity Log") then
|
||||
exit;
|
||||
|
||||
// Created once per company: an administrator who deletes the policy
|
||||
// does not get it back on the next upgrade.
|
||||
if UpgradeTag.HasUpgradeTag(DefaultPolicyTag()) then
|
||||
exit;
|
||||
|
||||
if not RetentionPolicySetup.Get(Database::"Contoso Activity Log") then begin
|
||||
RetentionPolicySetup.Validate("Table Id", Database::"Contoso Activity Log");
|
||||
RetentionPolicySetup.Validate("Apply to all records", true);
|
||||
RetentionPolicySetup.Validate(
|
||||
"Retention Period",
|
||||
RetentionPolicySetupMgt.FindOrCreateRetentionPeriod("Retention Period Enum"::"6 Months"));
|
||||
RetentionPolicySetup.Validate(Enabled, false); // the administrator opts in to deletion
|
||||
RetentionPolicySetup.Insert(true);
|
||||
end;
|
||||
|
||||
UpgradeTag.SetUpgradeTag(DefaultPolicyTag());
|
||||
end;
|
||||
|
||||
local procedure DefaultPolicyTag(): Code[250]
|
||||
begin
|
||||
exit('Contoso-ActivityLogDefaultPolicy-20260910');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
bc-version: [22..]
|
||||
domain: privacy
|
||||
keywords: [retention-policy, retention-policy-setup, addallowedtable, findorcreateretentionperiod, retention-period, default-policy, unbounded-table-growth, opt-in-deletion, upgrade-tag]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Registering a table does not delete anything — consider shipping a default setup
|
||||
|
||||
## Description
|
||||
|
||||
`AddAllowedTable` only makes a table selectable on the **Retention Policies** page. Nothing is deleted until a `Retention Policy Setup` record exists for that table, names a `Retention Period`, and is enabled. Registration alone is a valid pattern — several Microsoft apps register tables and leave the policy entirely to the administrator — but it means the table keeps growing until someone discovers the page, works out which of the extension's tables are safe to trim, and picks a period. Shipping a default setup is optional; where the extension's author knows a sensible period, it removes that discovery step. Microsoft's `Codeunit 3907 "Retention Policy Installer"` shows the shape — it registers `Retention Policy Log Entry`, then creates a setup record with a six-month period on first install, inserted disabled, guarded by an upgrade tag so a policy the administrator later deleted is not recreated on the next upgrade.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When you ship a default, do it in the same install and upgrade routine that registers the table (see [`register-owned-log-tables-for-retention-policies.md`](register-owned-log-tables-for-retention-policies.md)), and create the `Retention Policy Setup` record: get the period code from `Codeunit "Retention Policy Setup".FindOrCreateRetentionPeriod`, which reuses an existing `Retention Period` with the requested enum value and otherwise creates one without colliding on an existing code (a hand-written lookup-then-insert fails when a period with the chosen code already exists for a different value), then `Validate` `"Table Id"`, `"Apply to all records"` and `"Retention Period"` before inserting. The codeunit that inserts the record needs `tabledata "Retention Policy Setup" = ri`. Gate the creation on an upgrade tag so it happens once per company rather than on every upgrade. Default to inserting with `Enabled` set to false: pre-creating the line puts a reviewed, sensible period in front of the administrator while leaving the decision to delete tenant data with them. Shipping the policy enabled is defensible for rows that are purely diagnostic and documented as transient — state that choice, and the default period, in the app's onboarding material either way.
|
||||
|
||||
See sample: [`ship-a-default-retention-policy-setup.good.al`](ship-a-default-retention-policy-setup.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Registering a table and then claiming, in a message, notification, label, teaching tip, or setup text, that its data is now cleaned up automatically. Registration only makes the table selectable; without an enabled `Retention Policy Setup` nothing is deleted, so the administrator is told a cleanup is running when none is, and the table grows unnoticed. Registration without a default setup is not itself a defect.
|
||||
|
||||
See sample: [`ship-a-default-retention-policy-setup.bad.al`](ship-a-default-retention-policy-setup.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [Clean up data with retention policies](https://learn.microsoft.com/en-us/dynamics365/business-central/admin-data-retention-policies) — retention periods, enabling a policy, and the job queue entry that applies it.
|
||||
- [`RetentionPolicyInstaller.Codeunit.al`](https://github.com/microsoft/BCApps/blob/main/src/System%20Application/App/Retention%20Policy/src/Install/RetentionPolicyInstaller.Codeunit.al) in microsoft/BCApps — the platform's own register-then-create-disabled-setup pattern.
|
||||
Loading…
Add table
Add a link
Reference in a new issue