diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index 974175f..0538c48 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -85,7 +85,11 @@ ] }, "privacy": { - "article": "no-pii-in-telemetry-message-string" + "articles": [ + "no-pii-in-telemetry-message-string", + "register-owned-log-tables-for-retention-policies", + "ship-a-default-retention-policy-setup" + ] }, "query": { "articles": [ diff --git a/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.bad.al b/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.bad.al new file mode 100644 index 0000000..19ccdef --- /dev/null +++ b/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.bad.al @@ -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; +} diff --git a/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.good.al b/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.good.al new file mode 100644 index 0000000..b9246ec --- /dev/null +++ b/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.good.al @@ -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; +} diff --git a/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.md b/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.md new file mode 100644 index 0000000..1e3bc92 --- /dev/null +++ b/microsoft/knowledge/privacy/register-owned-log-tables-for-retention-policies.md @@ -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`. diff --git a/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.bad.al b/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.bad.al new file mode 100644 index 0000000..fbd6d5a --- /dev/null +++ b/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.bad.al @@ -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; +} diff --git a/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.good.al b/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.good.al new file mode 100644 index 0000000..d85da2a --- /dev/null +++ b/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.good.al @@ -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; +} diff --git a/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.md b/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.md new file mode 100644 index 0000000..b450b6f --- /dev/null +++ b/microsoft/knowledge/privacy/ship-a-default-retention-policy-setup.md @@ -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. diff --git a/microsoft/skills/review/al-privacy-review.md b/microsoft/skills/review/al-privacy-review.md index 17b4e7b..5024252 100644 --- a/microsoft/skills/review/al-privacy-review.md +++ b/microsoft/skills/review/al-privacy-review.md @@ -37,11 +37,17 @@ Discard files that are not applicable. Retain conditionally applicable files (an Narrow the relevant files to the subset that applies to the changes under review. Exclude test codeunits, test libraries, test helper code, files under test/Test/Tests paths, and objects with `Subtype = Test`; test data is synthetic and does not ship to customers. For each relevant file, compute overlap against: -- The changed AL object names and types — especially tables and tableextensions (for `DataClassification` on fields), codeunits that call `Error`, `Session.LogMessage`, or `FeatureTelemetry`, codeunits performing outgoing HTTP requests with customer data, migration codeunits, and objects reading or writing `IsolatedStorage`. +- The changed AL object names and types — especially tables and tableextensions (for `DataClassification` on fields), codeunits that call `Error`, `Session.LogMessage`, or `FeatureTelemetry`, codeunits performing outgoing HTTP requests with customer data, migration codeunits, objects reading or writing `IsolatedStorage`, and install/upgrade codeunits or new tables relevant to retention policies. - The changed procedures and triggers, weighted toward those that call `Error`, construct `ErrorInfo`, call `Session.LogMessage`, `StrSubstNo`, `GetLastErrorText`/`GetLastErrorCallStack`, `FeatureTelemetry.LogUsage`/`LogUptake`/`LogError`, `HttpClient.Post`/`Get`, `IsolatedStorage.Set`/`SetEncrypted`/`Get`, or `PrivacyNotice.GetPrivacyNoticeApprovalState`. - Tokens extracted from the diff that relate to privacy (`DataClassification`, `CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `SystemMetadata`, `ToBeClassified`, `PrivacyNotice`, `ErrorInfo`, `GetLastErrorText`, `GetLastErrorCallStack`, `TelemetryScope`, `FeatureTelemetry`, `CustomDimensions`, `LogUsage`, `LogUptake`, `LogError`, `ErrorText`, `ErrorCallStack`, `alErrorText`, `alErrorCallStack`, `HybridSL`, `HybridGP`, `HybridBC`). - Treat `ErrorInfo.Message`, `ErrorInfo.DataClassification`, `ErrorInfo.ErrorType`, and `ErrorInfo.DetailedMessage` as qualified member signals: accept a call or assignment only when symbol resolution proves that its receiver expression or variable has type `ErrorInfo`. Normalize those accesses to `errorinfo-message`, `errorinfo-dataclassification`, `errorinfo-errortype`, and `errorinfo-detailedmessage` retrieval tokens. Bare `Message` or `DataClassification` tokens MUST NOT trigger this article; do not emit the qualified tokens for `Message(...)` dialog calls, table or table-field `DataClassification` properties, or similarly named members on other types. Resolve the receiver's declaration from the containing object when it is outside the changed hunk. - Worklist ErrorInfo privacy guidance only from those typed `ErrorInfo` member tokens or from construction of an `ErrorInfo` value. For every `FeatureTelemetry.LogError`, inspect the dedicated error text and call-stack arguments in addition to explicit custom dimensions. +- Retention-policy signals. Emit `addallowedtable` for any call to `Codeunit "Reten. Pol. Allowed Tables".AddAllowedTable`, `retention-policy-setup` for any use of `Record "Retention Policy Setup"`, and `retention-policy` for either. Emit `append-only-table` for an extension-owned table (never a tableextension) that the diff adds, or into which the diff adds a new `Insert` path, when the table also passes this bounded check: search the app's `.al` files for the table's object name once each for `AddAllowedTable`, `Delete`, and `DeleteAll`, and emit the token only when none of the three matches. This is a text search per candidate table — do not read or resolve the rest of the app. Emit `deleteall` when the diff adds a `DeleteAll` on an extension-owned table filtered by a date or datetime field inside a job queue codeunit or other scheduled cleanup and the table has no `AddAllowedTable` call. + +Route retention-policy guidance deterministically: + +- `register-owned-log-tables-for-retention-policies.md` is worklisted by `append-only-table` or `deleteall`, or when an `AddAllowedTable` routine is reached only from an install codeunit, or is guarded by an upgrade tag with no `OnRefreshAllowedTables` subscriber that bypasses the tag. Do not use the table's name as the trigger: an append-only table is a candidate whatever it is called ("Incoming Data" as much as "Activity Log"). A name ending in `Log`, `Entry`, `Archive`, `History`, or `Buffer`, an `AutoIncrement` integer primary key, or inserts from event subscribers, job queue codeunits, or API/web-service handlers raise confidence to `medium`; without any of these the finding stays `low`. Findings from `append-only-table` are advisory (`minor`) because table volume cannot be observed from source. +- `ship-a-default-retention-policy-setup.md` is worklisted by `addallowedtable` or `retention-policy-setup`, but registration without a `Retention Policy Setup` is valid and MUST NOT produce a finding. Report only a false claim: a `Message`, notification, label, page `AboutText`/`InstructionalText`, or setup text in the diff stating that the registered table's data is now cleaned up or deleted automatically when no enabled `Retention Policy Setup` is created for it. A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Apply the topic-specific gates above after this overlap check; in particular, bare `Message` and `DataClassification` tokens cannot admit ErrorInfo guidance. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone.