mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 09:26:52 +01:00
Regenerate microsoft/knowledge from upstream BCApps instructions
The previous LLM-generated knowledge files contained factual
hallucinations. The most visible was the claim that `FindFirst` /
`FindLast` "forces a full-table scan" on an unfiltered record - it does
not; those APIs return a single row via the current key.
Other inaccuracies the audit found and fixed:
* `FindSet(true)` was described as "taking a LockTable". The correct
upstream phrasing is that `FindSet(true)` sets
`ReadIsolation::UpdLock` on the read. UpdLock and LockTable are
related but distinct mechanisms.
* The list of production-scale tables had been invented beyond the
upstream source (e.g. "Detailed Cust. Ledg. Entry") without a
citation. The regenerated list matches the ten tables upstream lists
with their P95 row counts.
* `SetLoadFields` guidance had been augmented with an extra mechanism
claim ("the database resolves the filter using the index without
hydrating the value") not present in upstream.
Approach: full regeneration of `microsoft/knowledge/` from the six
upstream BCApps Code Review instruction files, with Microsoft Learn /
the AL language reference as a secondary source. Every claim in every
regenerated file is anchored to a verbatim upstream quote (or a Learn
URL); the audit trail lives in artifacts/trace-<domain>.json on the
session workspace.
The PR #11 transaction/error-handling cluster is preserved verbatim:
* performance/understand-implicit-transaction-boundary.md
* performance/codeunit-run-as-atomic-sub-operation.{md,good.al,bad.al}
* performance/codeunit-run-requires-prior-commit-inside-transaction.{md,good.al,bad.al}
* performance/use-tryfunction-for-error-catching-not-rollback.{md,good.al,bad.al}
* performance/avoid-commit-inside-loops.{md,good.al,bad.al}
* security/commitbehavior-attribute-scopes-explicit-commits.{md,good.al,bad.al}
* testing/transactionmodel-attribute-governs-test-transactions.{md,good.al,bad.al}
These articles already cite Microsoft Learn and were carefully
cross-referenced; the regeneration skips their topics rather than
duplicating them.
File counts after regeneration:
performance 35 .md (5 preserved + 30 new)
privacy 17 .md
security 18 .md (1 preserved + 17 new)
style 33 .md
testing 1 .md (preserved)
ui 19 .md
upgrade 18 .md
Total 141 atomic knowledge files, each strictly one rule. All pass
.github/scripts/validate_frontmatter.py with 0 errors and 0 warnings.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
613c4b4019
commit
a9f3c50863
562 changed files with 6293 additions and 4869 deletions
|
|
@ -0,0 +1,11 @@
|
|||
codeunit 50207 "Privacy Sample StrSubstNo Bad"
|
||||
{
|
||||
procedure ReportFailure(var Customer: Record Customer)
|
||||
var
|
||||
ErrorMsg: Text;
|
||||
begin
|
||||
ErrorMsg := StrSubstNo('Customer %1 (%2) at %3 has invalid data',
|
||||
Customer.Name, Customer."E-Mail", Customer.Address);
|
||||
Error(ErrorMsg);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
codeunit 50206 "Privacy Sample StrSubstNo Good"
|
||||
{
|
||||
procedure ReportFailure(var Customer: Record Customer)
|
||||
var
|
||||
CustomerInvalidErr: Label 'Customer %1 has invalid data (email: %2).', Comment = '%1 = Customer No., %2 = E-Mail';
|
||||
begin
|
||||
Error(CustomerInvalidErr, Customer."No.", Customer."E-Mail");
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [strsubstno, error, telemetry, pii, prebuild, text-variable]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Do not pre-build an error string with `StrSubstNo` before calling `Error()`
|
||||
|
||||
## Description
|
||||
|
||||
`StrSubstNo` returns a plain `Text` value with the substitutions already performed. When that result is then passed to `Error()`, the platform sees a single plain-text parameter with no field references left to inspect, so it cannot apply `DataClassification` to anything inside it. Whatever PII the `StrSubstNo` call interpolated — customer name, e-mail, address, error text — is logged verbatim to telemetry. This is the canonical way to accidentally leak customer data through error telemetry, and it is the only `Error()` shape that needs to be flagged.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Call `Error()` directly with the format string and the substitution parameters. The platform classifies each parameter individually and handles telemetry correctly even when the parameters are PII fields (see `error-direct-substitution-safe-for-telemetry.md`). If the message text needs to be a `Label`, pass the `Label` and the parameters to `Error()` — do not pre-render via `StrSubstNo`.
|
||||
|
||||
See sample: `avoid-strsubstno-prebuild-before-error.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Assigning `StrSubstNo('Customer %1 (%2) ...', Customer.Name, Customer."E-Mail")` to a `Text` variable and then calling `Error(ErrorMsg)`. The platform has nothing to classify by the time `Error` runs — the PII is baked into the string and goes straight to telemetry. Detection signal for a reviewer: any `Text` variable assigned from `StrSubstNo` and later passed as the *only* parameter to `Error()`.
|
||||
|
||||
See sample: `avoid-strsubstno-prebuild-before-error.bad.al`.
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
table 50936 "Migrated Employee"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Employee No."; Code[20])
|
||||
{
|
||||
DataClassification = ToBeClassified;
|
||||
}
|
||||
field(2; "Tax Identification No."; Text[30])
|
||||
{
|
||||
DataClassification = SystemMetadata;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
table 50935 "Migrated Employee"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Employee No."; Code[20])
|
||||
{
|
||||
DataClassification = EndUserPseudonymousIdentifiers;
|
||||
}
|
||||
field(2; "Tax Identification No."; Text[30])
|
||||
{
|
||||
DataClassification = EndUserIdentifiableInformation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [migration, dataclassification, hybrid, destination, pii]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Classify migrated data at the destination field
|
||||
|
||||
## Description
|
||||
|
||||
Hybrid migration codeunits such as HybridSL, HybridGP, and HybridBC legitimately process sensitive source data: tax IDs, employee identifiers, financial balances, and customer records. The privacy concern is not that the migration code touches the data. The concern is where the data lands: the destination table field must have a DataClassification value that matches the migrated content.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When reviewing migration code, follow the assignment to the destination field and verify that the destination table declares an appropriate field-level or inherited DataClassification. Treat the migration procedure itself as expected business functionality; flag only missing or understated classification on the persistent destination.
|
||||
|
||||
See sample: `classify-data-at-migration-destination.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Flagging a migration procedure merely because it copies tax IDs or names from a source system. That creates false positives and misses the real issue: a destination field with no classification, `ToBeClassified`, or `SystemMetadata` for customer or employee data.
|
||||
|
||||
See sample: `classify-data-at-migration-destination.bad.al`.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [data-classification, page-field, table-field, api-page, card-page, list-page]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# DataClassification is a table-field property, not a page-field property
|
||||
|
||||
## Description
|
||||
|
||||
`DataClassification` is defined on table fields. Pages — including `Card`, `List`, `API`, and `ListPart` — do not own a classification; they simply expose fields whose classification is inherited from the underlying table. A page-level `DataClassification` property does not exist, so neither a missing nor a "wrong" classification can be reported against a page. When the underlying table field is misclassified, the fix is on the table definition, not on every page that surfaces the field.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When reviewing a page that exposes a field believed to be under-classified, follow the field back to its source table and inspect (or correct) the `DataClassification` there. A single corrected table field propagates to every page, report and API that uses it.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Flagging a page (or trying to add a `DataClassification` property to a page field) because the page displays personal data. Pages display data that authenticated, permissioned users are already entitled to see; the classification belongs on the table field that stores the data, not on the UI that renders it.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
tableextension 50201 "Customer Contact Ext Bad" extends Customer
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(50201; "Secondary Email"; Text[80])
|
||||
{
|
||||
DataClassification = SystemMetadata;
|
||||
Caption = 'Secondary Email';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
tableextension 50200 "Customer Contact Ext" extends Customer
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(50200; "Secondary Email"; Text[80])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
Caption = 'Secondary Email';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [data-classification, pii, gdpr, customer-content, table-field, under-classified]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# DataClassification is required on table fields containing sensitive data
|
||||
|
||||
## Description
|
||||
|
||||
`DataClassification` is the AL property that tells the platform what kind of data a table field stores so that telemetry, GDPR data-subject requests, and the platform's audit surfaces can treat it correctly. It is required on any field that holds personal or customer data. The default value `SystemMetadata` means "no user or customer data" — applying it to a field that actually holds PII (an email address, a customer name, an employee code) is an under-classification and a privacy bug, even though the code still compiles.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Set `DataClassification` to the value that matches the data the field actually stores. A `Customer."E-Mail"`-style field is `CustomerContent` (data belonging to the tenant's customers); a personal identifier such as an employee number or user ID is `EndUserIdentifiableInformation` or `EndUserPseudonymousIdentifiers` depending on whether it is directly identifying. Choose the classification at field definition time — fixing it later is a schema change.
|
||||
|
||||
See sample: `data-classification-required-on-pii-fields.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Declaring a field that stores PII with `DataClassification = SystemMetadata` to silence the compiler warning. The field compiles but the platform now treats customer data as system metadata in telemetry, GDPR exports and admin reports.
|
||||
|
||||
See sample: `data-classification-required-on-pii-fields.bad.al`.
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [dataclassification, table-field, page, api-page, scope]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# DataClassification is a table-field property, not a page property
|
||||
|
||||
## Description
|
||||
|
||||
DataClassification governs how the platform handles a field's data in telemetry, data-subject requests, and retention tooling. It is declared on the table field, not on the page that displays the field. Pages — card pages, list pages, API pages — simply render fields sourced from a table. A privacy issue with classification is always an issue on the table definition; the page is a display surface.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Flag missing or wrong DataClassification on the table field where the data lives. When a field is exposed through an API page or any other page type, the source table's classification governs. Do not report the same issue on every page that happens to include the field.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Reporting a privacy finding on `page 50100 "Customer API"` because it exposes an email field, rather than on `table Customer`'s email field. Fix at the source; the page is not the offender and the same correction applied per-page produces churn without changing the data-classification story.
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
tableextension 50911 "Privacy Sample IS Bad" extends "Sales & Receivables Setup"
|
||||
{
|
||||
fields
|
||||
{
|
||||
// Refactor moves the delta URL out of encrypted IsolatedStorage into a
|
||||
// plain table field. Value is now plaintext in SQL, unscoped, indistinguishable
|
||||
// from non-sensitive content.
|
||||
field(50100; "Delta Url"; Text[250])
|
||||
{
|
||||
DataClassification = EndUserPseudonymousIdentifiers;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
codeunit 50910 "Privacy Sample IS Good"
|
||||
{
|
||||
procedure StoreDeltaUrl(DeltaUrl: Text)
|
||||
var
|
||||
DeltaKeyTok: Label 'SyncDeltaUrl', Locked = true;
|
||||
begin
|
||||
// Sensitive delta URL remains encrypted and scoped to the extension.
|
||||
IsolatedStorage.SetEncrypted(DeltaKeyTok, DeltaUrl, DataScope::Company);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [isolatedstorage, encryption, tokens, refactor, regression]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Do not move PII or secrets from IsolatedStorage to plain table fields
|
||||
|
||||
## Description
|
||||
|
||||
IsolatedStorage with SetEncrypted keeps sensitive values — tokens, URLs carrying identifiers, delta cursors with embedded user context — encrypted at rest and scoped to the extension. Moving the same value to a normal table field is a refactor that looks structural but is a privacy and security regression: the value is now plaintext in SQL, visible to every reader of that table, backed up and replicated as ordinary business data. Reviews of existing integrations frequently see this change justified as "easier to query" — the concern is the storage model, not the ergonomics.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Keep tokens, secrets, personal-context URLs, and similar sensitive values in IsolatedStorage (SetEncrypted) or Azure Key Vault. When a refactor moves the value, require an explicit justification and a mitigating control (restricted-read permission set, value-level encryption, redaction in the access path). Otherwise leave it where it was.
|
||||
|
||||
See sample: `do-not-move-pii-from-isolated-storage-to-plain-fields.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A diff that deletes an `IsolatedStorage.SetEncrypted` call and writes the same value into a new `Text` column on a business table. The value is now unencrypted, unscoped, and indistinguishable from non-sensitive content to any caller reading the table.
|
||||
|
||||
See sample: `do-not-move-pii-from-isolated-storage-to-plain-fields.bad.al`.
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
codeunit 50205 "Privacy Sample Direct Error"
|
||||
{
|
||||
procedure ValidateCustomer(var Customer: Record Customer)
|
||||
var
|
||||
InvalidEmailErr: Label 'Customer %1 has an invalid e-mail address: %2.', Comment = '%1 = Customer No., %2 = E-Mail';
|
||||
begin
|
||||
if not Customer."E-Mail".Contains('@') then
|
||||
Error(InvalidEmailErr, Customer."No.", Customer."E-Mail");
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [error, strsubstno, direct-substitution, telemetry, classification, label]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# `Error()` with direct substitution parameters is always safe for telemetry
|
||||
|
||||
## Description
|
||||
|
||||
When `Error()` is called with a format string and direct substitution parameters (`%1`, `%2`, …), the BC platform intercepts the call, inspects each parameter individually, and applies the `DataClassification` of the source field — stripping or masking sensitive data before writing the message to telemetry. This is true regardless of whether a parameter is a record field reference, a local variable, a function return value, or any other expression. Patterns such as `Error('Invalid email: %1', Customer."E-Mail")` are therefore safe even when the parameter is PII: the platform sees `Customer."E-Mail"` as a `CustomerContent` field reference and handles it correctly.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Pass values to `Error()` as direct substitution parameters — either inline or via a `Label` with `Comment = '%1 = …'` placeholders. Let the platform do the per-parameter classification. This works equally well for record fields, local text variables, and document IDs.
|
||||
|
||||
See sample: `error-direct-substitution-safe-for-telemetry.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Treating any `Error()` call that mentions PII as a leak. A review skill that flags `Error('Invalid email: %1', EmailAddress)` is wrong; the platform handles that pattern correctly. The only `Error()` shape that genuinely leaks PII to telemetry is the pre-built `StrSubstNo` form covered in `avoid-strsubstno-prebuild-before-error.md`.
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
codeunit 50903 "Privacy Sample ErrorVsMsg Bad"
|
||||
{
|
||||
procedure ConfirmThenFail(var Customer: Record Customer)
|
||||
var
|
||||
ConfirmQst: Label 'Send welcome email to %1 at %2?', Comment = '%1 = name, %2 = email';
|
||||
FailureWithPiiErr: Text;
|
||||
begin
|
||||
if not Confirm(ConfirmQst, false, Customer.Name, Customer."E-Mail") then
|
||||
exit;
|
||||
|
||||
// Pre-built Text with PII, passed to Error: customer name and email reach telemetry.
|
||||
FailureWithPiiErr := StrSubstNo(
|
||||
'Could not send welcome to %1 at %2.', Customer.Name, Customer."E-Mail");
|
||||
Error(FailureWithPiiErr);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
codeunit 50902 "Privacy Sample ErrorVsMsg Good"
|
||||
{
|
||||
procedure ConfirmThenFail(var Customer: Record Customer)
|
||||
var
|
||||
ConfirmQst: Label 'Send welcome email to %1 at %2?', Comment = '%1 = name, %2 = email';
|
||||
GenericFailureErr: Label 'The welcome email could not be sent.';
|
||||
begin
|
||||
// Confirm is not logged to telemetry. PII in the prompt is fine.
|
||||
if not Confirm(ConfirmQst, false, Customer.Name, Customer."E-Mail") then
|
||||
exit;
|
||||
|
||||
// Error is logged. Keep PII out of the message.
|
||||
Error(GenericFailureErr);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [error, message, confirm, notification, telemetry, pii]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Error logs to telemetry; Message, Confirm, and Notification do not
|
||||
|
||||
## Description
|
||||
|
||||
The privacy concern with user-facing text is not what the authenticated user sees — it is what the platform exports to telemetry. Error is captured automatically; Message, Confirm, StrMenu, and Notification are not. Reviews that flag PII in any user-facing dialog over-report. Reviews that ignore PII in Error under-report. The distinction is the delivery surface, not the presence of a person's name on screen.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Free-text business content — customer names, email addresses, document numbers — is acceptable in Message, Confirm, and Notification. Treat Error text as if it will be read by telemetry consumers, because it will be, but use direct Error substitution rather than pre-building the message. `Error(MyErr, EmailAddress)` is telemetry-safe; `Error(StrSubstNo(..., EmailAddress))` is not.
|
||||
|
||||
See sample: `error-is-logged-to-telemetry-message-is-not.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Embedding customer emails, phone numbers, addresses, or names as literals in an Error label or baking them into a Text value with StrSubstNo before calling Error. The user also sees Message and Confirm, but those are not logged. Error is logged, so dynamic customer data must stay as direct substitution arguments.
|
||||
|
||||
See sample: `error-is-logged-to-telemetry-message-is-not.bad.al`.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [error, message, confirm, notification, telemetry, logging, ui-dialog]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Only `Error()` is logged to telemetry — `Message`, `Confirm`, `Notification` are not
|
||||
|
||||
## Description
|
||||
|
||||
The privacy concern with dialog APIs is not what the signed-in user sees on the screen — it is what the platform writes to telemetry. The BC platform automatically captures `Error()` invocations in the telemetry stream; it does not capture `Message()`, `Confirm()` or `Notification` calls. That asymmetry is the reason privacy review focuses on `Error()` text and ignores the other dialog APIs: a `Message` that shows a customer's email to the signed-in user reveals nothing they were not already entitled to see, while an `Error` carrying the same email leaks it to a separate, longer-lived telemetry destination.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Treat `Error()` as a telemetry surface, not just a UI surface — review the message text and parameters with the same scrutiny you apply to `Session.LogMessage`. Treat `Message()`, `Confirm()`, and `Notification` as pure UI: showing business data the user is permissioned for is normal functionality.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Flagging `Message`/`Confirm`/`Notification` calls for "showing PII" — they are not logged to telemetry, and the user already has permission to the underlying data. The inverse anti-pattern is treating `Error()` as harmless because the user sees only a dialog: the message is also written verbatim to telemetry.
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
codeunit 50215 "Privacy Sample FeatureTelemetry Bad"
|
||||
{
|
||||
procedure LogDocumentReleased(ExpenseHeader: Record "Sales Header"; var User: Record User)
|
||||
var
|
||||
FeatureTelemetry: Codeunit "Feature Telemetry";
|
||||
CustomDimensions: Dictionary of [Text, Text];
|
||||
begin
|
||||
CustomDimensions.Add('EmployeeNo', ExpenseHeader."Sell-to Customer No.");
|
||||
CustomDimensions.Add('UserName', User."Full Name");
|
||||
FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
codeunit 50214 "Privacy Sample FeatureTelemetry Good"
|
||||
{
|
||||
procedure LogUptake()
|
||||
var
|
||||
FeatureTelemetry: Codeunit "Feature Telemetry";
|
||||
begin
|
||||
FeatureTelemetry.LogUptake('0000EA2', 'Expense Agent',
|
||||
Enum::"Feature Uptake Status"::"Set up");
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [feature-telemetry, customdimensions, logusage, loguptake, logerror, pii, euii, eupi]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# `FeatureTelemetry` `CustomDimensions` follow the same privacy rules as `Session.LogMessage`
|
||||
|
||||
## Description
|
||||
|
||||
`Codeunit "Feature Telemetry"` is the second telemetry surface in AL. Its methods — `LogUsage()`, `LogUptake()` and `LogError()` — each accept a `CustomDimensions` dictionary parameter whose contents are sent to telemetry as-is. The platform does not classify per-dimension values for you, so any customer or employee data placed into the dictionary is logged verbatim. The privacy rules that apply to `Session.LogMessage` message text apply to every value in `CustomDimensions`: no customer or employee names, email addresses, phone numbers (`CustomerContent`/EUII); no employee codes, user IDs or user security IDs (EUPI); no user-provided content (addresses, descriptions, notes); no `GetLastErrorText()` output.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Pass only non-personal context through `CustomDimensions` — feature names, status enums, counts, error codes, durations. For uptake or usage signals that do not need per-call context, prefer the parameterless overload of `LogUptake`/`LogUsage` over a `CustomDimensions` dictionary that risks accreting PII over time.
|
||||
|
||||
See sample: `featuretelemetry-customdimensions-no-pii.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`CustomDimensions.Add('EmployeeNo', ExpenseHeader."Employee No.")` followed by `FeatureTelemetry.LogUsage(...)` — the employee number is a pseudonymous user identifier (EUPI) and is now in telemetry. Same pattern with `'UserName'`, `'CustomerEmail'`, `'AttachmentName'` etc.
|
||||
|
||||
See sample: `featuretelemetry-customdimensions-no-pii.bad.al`.
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
tableextension 50203 "Customer Order Stats" extends Customer
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(50203; "Open Order Count"; Integer)
|
||||
{
|
||||
FieldClass = FlowField;
|
||||
CalcFormula = count("Sales Header" where("Sell-to Customer No." = field("No.")));
|
||||
Caption = 'Open Order Count';
|
||||
}
|
||||
|
||||
field(50204; "Date Filter"; Date)
|
||||
{
|
||||
FieldClass = FlowFilter;
|
||||
Caption = 'Date Filter';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [flowfield, flowfilter, data-classification, systemmetadata, calculated]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# FlowFields and FlowFilters are classified `SystemMetadata` automatically
|
||||
|
||||
## Description
|
||||
|
||||
`FlowField` and `FlowFilter` are not stored fields — a FlowField is computed from a CalcFormula at read time and a FlowFilter is a transient filter scoped to the record variable. Because nothing is ever written to the database for these fields, the platform automatically classifies them as `DataClassification = SystemMetadata` and AL does not require — or expect — the developer to set `DataClassification` on them. A FlowField that surfaces PII (e.g., a sum or lookup over a `CustomerContent` table) is still `SystemMetadata` at the FlowField level; the privacy classification lives on the underlying stored field that the CalcFormula references.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Do not declare `DataClassification` on `FieldClass = FlowField` or `FieldClass = FlowFilter` fields — the inherited `SystemMetadata` is correct and the property is redundant. If a FlowField exposes sensitive data, ensure the underlying source field has the right `DataClassification`; that is where the platform reads classification from for GDPR and telemetry purposes.
|
||||
|
||||
See sample: `flowfield-flowfilter-classification-systemmetadata.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Flagging a FlowField for "missing `DataClassification`" or trying to override it to `CustomerContent` because the formula references customer data. The platform's automatic `SystemMetadata` value is the documented, intentional behavior for non-stored fields; overriding it adds nothing and misrepresents the field as if it were stored.
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [flowfield, flowfilter, dataclassification, systemmetadata, default]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# FlowFields and FlowFilters automatically inherit DataClassification SystemMetadata
|
||||
|
||||
## Description
|
||||
|
||||
FlowFields and FlowFilters are virtual — they carry no stored data of their own, and their values are computed on demand from the source table the CalcFormula references. The platform classifies them as SystemMetadata automatically and does not require (or respect) a per-field DataClassification declaration. Flagging a FlowField as missing DataClassification, or as under-classified because the computed value may be CustomerContent, is a false positive: the underlying source field carries the classification that matters, and that is what telemetry and compliance tooling inspects.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Leave DataClassification off FlowFields and FlowFilters. If the computed value is sensitive, the fix is to ensure the source table's field has the correct classification. Verify source-field classification rather than trying to re-classify the computed view.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Reporting "missing DataClassification" on a FlowField, or attempting to set a FlowField's DataClassification to CustomerContent because the SUM aggregates a sensitive amount. The declaration has no effect; the platform uses the source-field classification.
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
codeunit 50209 "Privacy Sample GetLastError Bad"
|
||||
{
|
||||
procedure AddAttachment()
|
||||
var
|
||||
ErrorMsg: Text;
|
||||
begin
|
||||
if not TryAddAttachment() then begin
|
||||
ErrorMsg := StrSubstNo('Attachment failed: %1', GetLastErrorText(true));
|
||||
Error(ErrorMsg);
|
||||
end;
|
||||
end;
|
||||
|
||||
[TryFunction]
|
||||
local procedure TryAddAttachment()
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
codeunit 50208 "Privacy Sample GetLastError Good"
|
||||
{
|
||||
procedure AddAttachmentSafely()
|
||||
var
|
||||
AttachmentFailedErr: Label 'Failed to add email attachment. Please try again.';
|
||||
begin
|
||||
if not TryAddAttachment() then
|
||||
Error(AttachmentFailedErr);
|
||||
end;
|
||||
|
||||
[TryFunction]
|
||||
local procedure TryAddAttachment()
|
||||
begin
|
||||
// ... attachment logic that may fail with a customer-data-bearing error ...
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [getlasterrortext, error, strsubstno, telemetry, customer-data, attachment]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Treat `GetLastErrorText()` as potential customer content
|
||||
|
||||
## Description
|
||||
|
||||
`GetLastErrorText()` returns the text of the last error that occurred in the context where it is called. That text routinely contains customer content — field values that triggered the validation, record keys, customer names, file names from upload failures, and similar fragments lifted from the failing operation. Re-emitting it through `StrSubstNo` into `Error()` bakes that customer data into a single plain-text parameter that the platform can no longer classify, so it is logged verbatim to telemetry (the same problem as any other `StrSubstNo`-pre-built error — see `avoid-strsubstno-prebuild-before-error.md`).
|
||||
|
||||
## Best Practice
|
||||
|
||||
When the goal is to surface a recoverable failure to the user, raise a generic message that does not embed `GetLastErrorText()` content, and log technical detail separately via `Session.LogMessage` with the correct `DataClassification`. If you must propagate the inner error verbatim, re-raise it as a direct parameter of `Error()` (e.g., `Error('%1', GetLastErrorText())`) rather than concatenating with `StrSubstNo` so the platform can apply its own handling.
|
||||
|
||||
See sample: `getlasterrortext-customer-content-in-errors.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`ErrorMsg := StrSubstNo('Attachment failed: %1', GetLastErrorText(true)); Error(ErrorMsg);` — the inner error text may carry filenames or record values, and `StrSubstNo` strips the platform's ability to filter them before they hit telemetry.
|
||||
|
||||
See sample: `getlasterrortext-customer-content-in-errors.bad.al`.
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [memory, dictionary, list, temporary-record, scope, false-positive]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# In-memory variables are not a privacy concern in Business Central
|
||||
|
||||
## Description
|
||||
|
||||
Business Central runs in a managed server environment. Local variables, Dictionary, List, and temporary Record buffers exist only for the duration of the request or session; the runtime reclaims them when the scope exits. Memory dumps are not a realistic threat vector in this architecture, and flagging an in-memory collection of customer emails or names as a privacy issue misstates the product's security model.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Focus privacy review on persistence, transit, and telemetry: what is written to tables, sent over the network, or logged. Treat in-memory handling of personal data as normal business functionality. When an in-memory buffer is copied into IsolatedStorage, a table, or a telemetry call, that downstream write is what gets reviewed.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Flagging `Dictionary of [Code[20], Text]`, `List of [Text]`, or `Record Customer temporary` variables that hold customer data during a calculation as a privacy concern. The flag is a false positive that trains authors to avoid a normal pattern and distracts from the persistent storage that does matter.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [in-memory, dictionary, list, temporary-table, variable, memory-dump]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# In-memory variables, dictionaries, lists and temporary tables are not a privacy concern
|
||||
|
||||
## Description
|
||||
|
||||
AL runs in a managed server environment. Local variables, `Dictionary`, `List`, temporary `Record` variables, and other in-process data structures exist only for the duration of the request or session and are released by the runtime when it ends — they are not persisted, not visible across sessions, and not exposed outside the server process. Memory dumps are not a realistic threat vector against Business Central's hosted architecture, so holding business data (emails, names, addresses, document content) in these structures while processing a request is normal and expected.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use whatever in-memory shape (`Dictionary`, `List`, temporary tables, plain variables) the algorithm needs. The privacy review applies to *persistent* surfaces — table fields, telemetry, outgoing HTTP — not to per-request memory.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Flagging a `Dictionary of [Text, Text]` populated with customer emails, or a temporary `Record Customer` holding rows mid-processing, as a privacy leak. These structures are scoped to the request and do not leave the server's memory.
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
codeunit 50936 "Privacy FeatureTelemetry Bad"
|
||||
{
|
||||
procedure LogExpenseReleased(EmployeeNo: Code[20]; UserName: Text)
|
||||
var
|
||||
FeatureTelemetry: Codeunit "Feature Telemetry";
|
||||
CustomDimensions: Dictionary of [Text, Text];
|
||||
begin
|
||||
CustomDimensions.Add('EmployeeNo', EmployeeNo);
|
||||
CustomDimensions.Add('UserName', UserName);
|
||||
CustomDimensions.Add('LastError', GetLastErrorText());
|
||||
|
||||
FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
codeunit 50935 "Privacy FeatureTelemetry Good"
|
||||
{
|
||||
procedure LogExpenseReleased()
|
||||
var
|
||||
FeatureTelemetry: Codeunit "Feature Telemetry";
|
||||
CustomDimensions: Dictionary of [Text, Text];
|
||||
begin
|
||||
CustomDimensions.Add('DocumentType', 'Expense');
|
||||
CustomDimensions.Add('LineCountBucket', '10-20');
|
||||
|
||||
FeatureTelemetry.LogUsage('0000EA1', 'Expense Agent', 'Document Released', CustomDimensions);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [featuretelemetry, customdimensions, telemetry, pii, customercontent]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Keep customer data out of FeatureTelemetry custom dimensions
|
||||
|
||||
## Description
|
||||
|
||||
`Codeunit "Feature Telemetry"` writes telemetry through methods such as `LogUsage`, `LogUptake`, and `LogError`. The `CustomDimensions` dictionary passed to those methods is exported to the telemetry pipeline, so it has the same privacy boundary as `Session.LogMessage` dimensions. Customer names, email addresses, employee numbers, user IDs, security IDs, notes, and `GetLastErrorText()` do not become safe merely because they are structured dimensions.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Log feature state, event names, counts, enum values, and non-personal technical identifiers. Omit customer and employee identifiers from `CustomDimensions`; if diagnostics need correlation, use a non-personal event ID or aggregate count instead.
|
||||
|
||||
See sample: `keep-customer-data-out-of-featuretelemetry-dimensions.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Adding employee numbers, user names, customer emails, free-text descriptions, or raw `GetLastErrorText()` to the `CustomDimensions` dictionary before calling `FeatureTelemetry.LogUsage`, `LogUptake`, or `LogError`.
|
||||
|
||||
See sample: `keep-customer-data-out-of-featuretelemetry-dimensions.bad.al`.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [data-migration, hybridsl, hybridgp, hybridbc, destination-classification, ssn, tin]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# In data migration code, classify the destination — not the migration itself
|
||||
|
||||
## Description
|
||||
|
||||
Migration codeunits such as `HybridSL`, `HybridGP`, and `HybridBC` exist to copy sensitive data — TINs, Federal IDs, social security numbers, financial records — from a source system into Business Central. The fact that PII flows through these codeunits is the entire point of their existence, not a defect. The privacy concern is whether the destination field where the data lands carries the correct `DataClassification`. If it does, the migration is doing its job; if it doesn't, the right fix is on the destination table field, never on the migration code that writes to it.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When reviewing a migration codeunit, trace each `Dest."<Field>" := Source."<Field>"` assignment to the destination field's `DataClassification`. Confirm that fields receiving PII (SSNs, Federal IDs, customer names, addresses) are classified `EndUserIdentifiableInformation` or `CustomerContent` as appropriate — and not left as `SystemMetadata` or `ToBeClassified`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Flagging the migration code itself for "processing sensitive data" or recommending that it filter, hash, or skip PII fields — these tables exist to migrate that data. The actionable finding is always on the destination field's classification, not on the migration's assignment statement.
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
codeunit 50213 "Privacy Sample Telemetry Bad"
|
||||
{
|
||||
procedure LogCustomerProcessed(var Customer: Record Customer)
|
||||
begin
|
||||
Session.LogMessage('0000', StrSubstNo('Processed %1', Customer.Name), Verbosity::Normal,
|
||||
DataClassification::SystemMetadata, TelemetryScope::All,
|
||||
'Category', 'Privacy');
|
||||
end;
|
||||
|
||||
procedure LogFileError(FileName: Text)
|
||||
begin
|
||||
Session.LogMessage('0001', StrSubstNo('Error processing file %1', FileName), Verbosity::Error,
|
||||
DataClassification::SystemMetadata, TelemetryScope::All);
|
||||
end;
|
||||
|
||||
procedure LogEmployeeUpdate(EmployeeCode: Code[20])
|
||||
begin
|
||||
Session.LogMessage('0002', StrSubstNo('Employee %1 updated record', EmployeeCode), Verbosity::Normal,
|
||||
DataClassification::SystemMetadata, TelemetryScope::All);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
codeunit 50212 "Privacy Sample Telemetry Good"
|
||||
{
|
||||
procedure LogCustomerProcessed(var Customer: Record Customer)
|
||||
begin
|
||||
Session.LogMessage('0000', 'Customer record processed', Verbosity::Normal,
|
||||
DataClassification::SystemMetadata, TelemetryScope::All,
|
||||
'Category', 'Privacy');
|
||||
end;
|
||||
|
||||
procedure LogFileError()
|
||||
begin
|
||||
Session.LogMessage('0001', 'Error processing uploaded file', Verbosity::Error,
|
||||
DataClassification::SystemMetadata, TelemetryScope::All);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [telemetry, session-logmessage, strsubstno, pii, customer-data, employee-code, filename]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Do not embed customer data in the telemetry message text
|
||||
|
||||
## Description
|
||||
|
||||
`Session.LogMessage`'s message argument is a plain `Text`. Unlike `Error()`, the platform does not inspect this string field-by-field — whatever is in the text is what telemetry receives. So a call that builds the message via `StrSubstNo` from customer-bearing fields ships those values to telemetry verbatim, regardless of the `DataClassification` argument on the same call. Flagged content includes customer names, email addresses, phone numbers, addresses, employee codes or IDs, attachment filenames, user-provided text that may carry PII, and dumps of `Record` content.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Keep the telemetry message a static, non-personal string ("Customer record processed", "Error processing uploaded file"). When structured context is genuinely needed, attach it through custom dimensions, where individual values can be reviewed and classified at the dimension level rather than baked into a free-text message.
|
||||
|
||||
See sample: `no-pii-in-telemetry-message-string.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`Session.LogMessage('0000', StrSubstNo('Processed %1', Customer.Name), ...)` — the customer name is in telemetry the moment the line runs. Detection signal: a `StrSubstNo` whose result is the second argument of `Session.LogMessage`. The same shape with `FileName`, `EmployeeCode`, or any record field is the same problem.
|
||||
|
||||
See sample: `no-pii-in-telemetry-message-string.bad.al`.
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
table 50909 "Privacy Sample Override Bad"
|
||||
{
|
||||
DataClassification = SystemMetadata;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { }
|
||||
// Customer name inherits SystemMetadata from the table. Subject-access
|
||||
// and retention tooling treats the value as system housekeeping.
|
||||
field(2; "Customer Name"; Text[100]) { }
|
||||
field(3; "E-Mail"; Text[80]) { }
|
||||
field(4; "Logged At"; DateTime) { }
|
||||
}
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
table 50908 "Privacy Sample Override Good"
|
||||
{
|
||||
DataClassification = SystemMetadata;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { }
|
||||
field(2; "Customer Name"; Text[100])
|
||||
{
|
||||
// Table default is SystemMetadata; this field is personal data.
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
field(3; "E-Mail"; Text[80])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
field(4; "Logged At"; DateTime) { }
|
||||
}
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [dataclassification, inheritance, table-level, field-level, override]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Override inherited DataClassification when a field doesn't fit the table default
|
||||
|
||||
## Description
|
||||
|
||||
When a table declares `DataClassification` at the table level, every field inherits that value unless the field declares its own. This is efficient for homogeneous tables — a SystemMetadata log table whose fields are all system-generated, a CustomerContent transaction table whose fields are all business data. It is a privacy regression when a table is classified SystemMetadata but contains a field that holds personal data: the field silently inherits the wrong classification, and telemetry tooling treats its content as safe to log when it is not.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Review every field on a table with a table-level DataClassification. Fields whose content matches the table's default need no per-field declaration. Fields that carry a different kind of data — a customer name on an otherwise-system-metadata log table, a personal identifier on a mixed-content table — must declare their own DataClassification that overrides the table default.
|
||||
|
||||
See sample: `override-inherited-dataclassification-per-field.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A table declared `DataClassification = SystemMetadata` with fields like `Customer Name`, `E-Mail`, `Phone No.` — the fields inherit SystemMetadata, which is wrong for CustomerContent. Subject-access-request and retention tooling treats the personal data as system housekeeping.
|
||||
|
||||
See sample: `override-inherited-dataclassification-per-field.bad.al`.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [page, card, list, api, listpart, permission-system, display, ui-dialog]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Displaying fields on a page (or in a UI dialog) is not a privacy concern
|
||||
|
||||
## Description
|
||||
|
||||
Every page in Business Central — `Card`, `List`, `API`, `ListPart`, request pages — renders data to an authenticated user who has been granted permission to see it. The BC permission system, not the page definition, controls who sees what; once a user is permissioned to a table, displaying any field of that table is normal business functionality. The same logic extends to `Message`, `Notification` and `Confirm` dialogs: the signed-in user already has access to the data the dialog is showing them. Privacy review for pages and dialogs is therefore the wrong layer — the actionable findings live on the underlying data (table-field classification, telemetry message text, outbound HTTP consent), not on the UI.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When asked "is it OK to show this email/name/employee code on this page?", the answer is yes — provided the user has permission to the underlying record. Drive privacy concerns to the data layer (classification, telemetry, external transfer) rather than the UI layer.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Flagging an API page, list, card, or notification for surfacing customer-bearing fields (`E-Mail`, `Name`, `Phone No.`, audit fields, `User ID`). The permission system governs visibility; the page does not.
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [page, display, permission, authenticated, false-positive]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Pages displaying data to permitted users are not a privacy concern
|
||||
|
||||
## Description
|
||||
|
||||
Every page in Business Central displays data to an authenticated user who holds the permissions required to see it. The permission system — table permissions, entitlements, field-level restrictions where configured — is the access-control boundary. Flagging a page for showing customer emails, names, addresses, document numbers, or system audit fields treats display as a leak when it is the product's intended function.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Privacy review of pages is about data classification on the source table and about consent on outgoing integrations reached through page actions. Displaying business data to a user with permission to view it is correct behaviour, including on API pages that are gated by the same permission model.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Reporting "customer email is shown on the page" or "user ID visible in the list" as privacy findings. The finding does not reflect a privacy regression and redirects the author toward hiding data that the permitted user is entitled to see. The same logic produces noise on Confirm, Message, and Notification that surface business identifiers.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
codeunit 50217 "Privacy Sample Consent Bad"
|
||||
{
|
||||
procedure SendDataToExternalService(Customer: Record Customer)
|
||||
var
|
||||
HttpClient: HttpClient;
|
||||
Content: HttpContent;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}',
|
||||
Customer."E-Mail", Customer.Name));
|
||||
HttpClient.Post('https://api.externalservice.com/sync', Content, Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
codeunit 50216 "Privacy Sample Consent Good"
|
||||
{
|
||||
procedure SendDataToExternalService(Customer: Record Customer)
|
||||
var
|
||||
PrivacyNotice: Codeunit "Privacy Notice";
|
||||
PrivacyNoticeRegistrations: Codeunit "Privacy Notice Registrations";
|
||||
HttpClient: HttpClient;
|
||||
Content: HttpContent;
|
||||
Response: HttpResponseMessage;
|
||||
PrivacyConsentRequiredErr: Label 'Privacy notice consent is required for this integration.';
|
||||
begin
|
||||
if PrivacyNotice.GetPrivacyNoticeApprovalState(
|
||||
PrivacyNoticeRegistrations.GetExchangePrivacyNoticeId())
|
||||
<> "Privacy Notice Approval State"::Agreed
|
||||
then
|
||||
Error(PrivacyConsentRequiredErr);
|
||||
|
||||
Content.WriteFrom(StrSubstNo('{"email":"%1","name":"%2"}',
|
||||
Customer."E-Mail", Customer.Name));
|
||||
HttpClient.Post('https://api.externalservice.com/sync', Content, Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [privacy-notice, consent, http-client, outgoing-request, external-service, getprivacynoticeapprovalstate]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Outgoing requests to external services require a Privacy Notice consent check
|
||||
|
||||
## Description
|
||||
|
||||
Business Central ships a built-in Privacy Notice framework that the admin uses to grant or withhold per-integration consent for sending data to external services. The relevant API surface is `Codeunit "Privacy Notice"` (consent checks via `GetPrivacyNoticeApprovalState()`), `Codeunit "Privacy Notice Registrations"` (well-known notice IDs for integrations such as Exchange, OneDrive, Teams), and the `Enum "Privacy Notice Approval State"` with values `Agreed`, `Disagreed`, and `Not Set`. The admin UI is the **Privacy Notices Status** page. The compliance concern in code review is therefore not that personal data is included in an outgoing HTTP body — that is normal business functionality — but that the code path issuing the request contains no `PrivacyNotice.GetPrivacyNoticeApprovalState(...)` check.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Before issuing an outgoing HTTP request to an external service, verify `PrivacyNotice.GetPrivacyNoticeApprovalState(<integration id>) = "Privacy Notice Approval State"::Agreed`. The check does not have to live next to the `HttpClient.Post` call — it can sit anywhere upstream in the same code path (for example in the page's `OnOpenPage`, in a wizard step, or in a setup action) as long as no execution path reaches the request without passing through it.
|
||||
|
||||
See sample: `privacy-notice-consent-for-external-data-transfer.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A `procedure SendDataToExternalService(...)` that posts customer data to an external endpoint with no `PrivacyNotice.GetPrivacyNoticeApprovalState` anywhere upstream. The same anti-pattern applies in reverse: removing an existing privacy-notice check from code that still issues the external call.
|
||||
|
||||
See sample: `privacy-notice-consent-for-external-data-transfer.bad.al`.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
codeunit 50218 "Privacy Sample Register Integration"
|
||||
{
|
||||
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Privacy Notice Registrations", 'OnRegisterPrivacyNotices', '', false, false)]
|
||||
local procedure OnRegisterPrivacyNotices(var TempPrivacyNotice: Record "Privacy Notice" temporary)
|
||||
var
|
||||
PrivacyNotice: Codeunit "Privacy Notice";
|
||||
begin
|
||||
PrivacyNotice.CreatePrivacyNoticeForIntegration(
|
||||
'My External Sync', 'External Customer Sync Service');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [privacy-notice-registrations, integration, register, exchange, onedrive, teams, notice-id]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Register every new external integration with `Privacy Notice Registrations`
|
||||
|
||||
## Description
|
||||
|
||||
`Codeunit "Privacy Notice Registrations"` is the registry of integrations whose consent state the platform tracks. Built-in integrations such as Exchange, OneDrive and Teams already have notice IDs exposed via accessor methods on this codeunit (`GetExchangePrivacyNoticeId`, etc.); a new integration introduced by an extension must add itself to the registry so that the admin can grant or withhold consent on the **Privacy Notices Status** page. Without registration, there is nothing for `Codeunit "Privacy Notice"` to return an approval state for — the call cannot meaningfully gate the outbound request.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When introducing a new outbound integration: pick a stable notice ID, register it via `Privacy Notice Registrations`, and then gate every outbound call with `PrivacyNotice.GetPrivacyNoticeApprovalState(<that id>)` as described in `privacy-notice-consent-for-external-data-transfer.md`.
|
||||
|
||||
See sample: `register-integration-in-privacy-notice-registrations.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Shipping a new outbound integration without registering it. Even if the code calls `GetPrivacyNoticeApprovalState`, the admin has no surface to express consent — the integration is effectively unmanaged from a privacy-notice standpoint.
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
codeunit 50905 "Privacy Sample Consent Bad"
|
||||
{
|
||||
procedure SyncToPartner(var Customer: Record Customer)
|
||||
var
|
||||
Client: HttpClient;
|
||||
Content: HttpContent;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
// Customer email and name sent externally with no Privacy Notice check
|
||||
// anywhere in the reachable code path.
|
||||
Content.WriteFrom(Customer."E-Mail");
|
||||
Client.Post('https://partner.example.com/sync', Content, Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
codeunit 50904 "Privacy Sample Consent Good"
|
||||
{
|
||||
procedure SyncToPartner(var Customer: Record Customer)
|
||||
var
|
||||
PrivacyNotice: Codeunit "Privacy Notice";
|
||||
Client: HttpClient;
|
||||
Content: HttpContent;
|
||||
Response: HttpResponseMessage;
|
||||
PartnerNoticeIdTok: Label 'Contoso-PartnerSync', Locked = true;
|
||||
ConsentRequiredErr: Label 'Consent is required before syncing to the external partner.';
|
||||
begin
|
||||
if PrivacyNotice.GetPrivacyNoticeApprovalState(PartnerNoticeIdTok, false) <>
|
||||
"Privacy Notice Approval State"::Agreed
|
||||
then
|
||||
Error(ConsentRequiredErr);
|
||||
|
||||
Content.WriteFrom(Customer."No.");
|
||||
Client.Post('https://partner.example.com/sync', Content, Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [privacy-notice, consent, gdpr, httpclient, outgoing-request]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Check Privacy Notice consent before outgoing requests with customer data
|
||||
|
||||
## Description
|
||||
|
||||
Business Central ships a Privacy Notice framework for user consent to third-party integrations. When code sends personal data (emails, names, addresses) to an external service, the concern is not whether the data itself is compliant — the product handles that — but whether the code path has verified the user has agreed to the integration. Missing consent checks on new or modified outgoing paths is the privacy issue to flag; the presence of PII in the payload is not.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Before an outgoing HttpClient call that carries customer data, verify consent via `Codeunit "Privacy Notice".GetPrivacyNoticeApprovalState()` for the integration's registered notice id. The check may live upstream (page OnOpenPage, wizard step) as long as every path that reaches the external call passes through it. Register new integrations via `Codeunit "Privacy Notice Registrations"`.
|
||||
|
||||
See sample: `require-privacy-notice-consent-before-outgoing-requests.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Adding or modifying an outgoing integration and sending customer data without any `Privacy Notice` check in the reachable code path. Removing an existing consent check from an integration that still sends data externally falls in the same category.
|
||||
|
||||
See sample: `require-privacy-notice-consent-before-outgoing-requests.bad.al`.
|
||||
|
|
@ -1,22 +1,22 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [tobeclassified, dataclassification, release, gdpr, placeholder]
|
||||
keywords: [tobeclassified, data-classification, release, appsource, development]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Resolve ToBeClassified before release
|
||||
# Resolve every `ToBeClassified` before release
|
||||
|
||||
## Description
|
||||
|
||||
`DataClassification = ToBeClassified` is a development marker, not a releasable privacy state. It tells reviewers and tooling that the field still needs classification work. Shipping it prevents data-subject, retention, and telemetry tooling from making a correct decision about the field.
|
||||
`DataClassification = ToBeClassified` is the sentinel value the AL compiler accepts while a developer has not yet decided what a new field actually stores. It exists for the development phase only and must be resolved to a real classification (`CustomerContent`, `EndUserIdentifiableInformation`, `EndUserPseudonymousIdentifiers`, `AccountData`, `OrganizationIdentifiableInformation` or `SystemMetadata`) before the code ships. A released field left at `ToBeClassified` tells the platform "we have not classified this data" — which means GDPR data-subject requests, telemetry and audit reports cannot reason about it.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Replace every `ToBeClassified` value with the narrowest accurate classification before the PR ships to customers. If the field inherits a correct table-level DataClassification, remove the placeholder rather than leaving a field-level `ToBeClassified` override.
|
||||
Treat `ToBeClassified` as a TODO marker that fails release readiness. Sweep new table objects and table extensions for it before submitting a build for publication. If the right classification is genuinely unclear, decide between `CustomerContent` and `EndUserIdentifiableInformation` from the data's content, not from convenience.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Treating ToBeClassified as a safe default because the field is new or because the final classification is uncertain. Uncertainty should bias toward a stronger classification, not toward an unresolved placeholder.
|
||||
Leaving `ToBeClassified` in a shipped extension. Reviewers who treat the value as "I'll figure it out later" ship a field whose privacy posture is undefined for every customer that installs the app.
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
codeunit 50907 "Privacy Sample LastErr Bad"
|
||||
{
|
||||
procedure LogFailure()
|
||||
var
|
||||
CategoryTok: Label 'Sync', Locked = true;
|
||||
FailureTxt: Label 'Operation failed: %1', Comment = '%1 = last error text';
|
||||
begin
|
||||
// GetLastErrorText(true) carries the call stack and field values from
|
||||
// the failing context. Declared as SystemMetadata but the payload is CustomerContent.
|
||||
Session.LogMessage(
|
||||
'0000ABC', StrSubstNo(FailureTxt, GetLastErrorText(true)),
|
||||
Verbosity::Error,
|
||||
DataClassification::SystemMetadata,
|
||||
TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
codeunit 50906 "Privacy Sample LastErr Good"
|
||||
{
|
||||
procedure LogFailure()
|
||||
var
|
||||
CategoryTok: Label 'Sync', Locked = true;
|
||||
GenericMsgTxt: Label 'Sync operation failed. See extended log for details.';
|
||||
begin
|
||||
// Generic message, no GetLastErrorText. Detail goes to an internal log
|
||||
// the telemetry pipeline does not receive.
|
||||
Session.LogMessage(
|
||||
'0000ABC', GenericMsgTxt, Verbosity::Error,
|
||||
DataClassification::SystemMetadata,
|
||||
TelemetryScope::ExtensionPublisher, 'Category', CategoryTok);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [getlasterrortext, telemetry, callstack, dataclassification, pii]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Sanitize GetLastErrorText before sending to telemetry
|
||||
|
||||
## Description
|
||||
|
||||
`GetLastErrorText` and `GetLastErrorCallStack` return strings built from the failing call site's data — field values, record keys, customer names, filenames. Logging either to telemetry with `DataClassification::SystemMetadata` misstates the content: the actual values are CustomerContent or worse. The true classification is not always SystemMetadata, and silently mislabelling a CustomerContent payload as system data is the specific privacy regression to avoid.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Log a generic error message and either omit GetLastErrorText entirely or classify the telemetry call as `DataClassification::CustomerContent`. Prefer `GetLastErrorText(false)` to exclude the call stack when the text is needed but the stack is not. When in doubt, log a generic summary and persist the detailed error separately in a restricted-access log the telemetry pipeline does not receive.
|
||||
|
||||
See sample: `sanitize-getlasterrortext-before-telemetry.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`Session.LogMessage(..., StrSubstNo('Operation failed: %1', GetLastErrorText(true)), ..., DataClassification::SystemMetadata, ...)` — the classification is wrong for the payload, and the call stack typically carries customer data from the failing operation into the telemetry stream.
|
||||
|
||||
See sample: `sanitize-getlasterrortext-before-telemetry.bad.al`.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
codeunit 50211 "Privacy Sample LogMessage Bad"
|
||||
{
|
||||
procedure LogCompleted()
|
||||
begin
|
||||
Session.LogMessage('0003', 'Operation completed', Verbosity::Normal);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
codeunit 50210 "Privacy Sample LogMessage Good"
|
||||
{
|
||||
procedure LogCompleted()
|
||||
begin
|
||||
Session.LogMessage('0003', 'Operation completed', Verbosity::Normal,
|
||||
DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [session-logmessage, telemetry, data-classification, verbosity, telemetry-scope]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Every `Session.LogMessage` call must specify `DataClassification`
|
||||
|
||||
## Description
|
||||
|
||||
`Session.LogMessage` writes a record to the telemetry pipeline. The platform requires the call to carry an explicit `DataClassification` argument so that the entry can be routed and retained correctly downstream — telemetry consumers, GDPR exports, and Application Insights dashboards all rely on it. The compiler accepts overloads without the parameter (the two-argument and three-argument shapes that omit it), but for any telemetry that ships to customers, the `DataClassification`-bearing overload is the correct one.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use the overload that takes `Verbosity`, `DataClassification`, and `TelemetryScope`. For payload-free operational telemetry that does not embed customer data, `DataClassification::SystemMetadata` is the right value. Choose `TelemetryScope::ExtensionPublisher` for telemetry meant for the publishing partner only; `TelemetryScope::All` also forwards to the customer's tenant telemetry.
|
||||
|
||||
See sample: `session-logmessage-requires-dataclassification.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Calling `Session.LogMessage('0003', 'Operation completed', Verbosity::Normal)` — the overload omits `DataClassification` and leaves the platform without the information needed to classify the entry. Detection signal: a `Session.LogMessage` call whose argument list ends at `Verbosity`.
|
||||
|
||||
See sample: `session-logmessage-requires-dataclassification.bad.al`.
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
codeunit 50913 "Privacy Sample Telemetry Bad"
|
||||
{
|
||||
procedure LogProcessed(var Customer: Record Customer)
|
||||
var
|
||||
CategoryTok: Label 'CustomerProcessing', Locked = true;
|
||||
MsgTemplateTxt: Label 'Processed customer %1', Comment = '%1 = customer name';
|
||||
begin
|
||||
// Declared SystemMetadata; payload is CustomerContent. The message is
|
||||
// opaque text once built; the pipeline cannot redact.
|
||||
Session.LogMessage(
|
||||
'0000001', StrSubstNo(MsgTemplateTxt, Customer.Name),
|
||||
Verbosity::Normal,
|
||||
DataClassification::SystemMetadata,
|
||||
TelemetryScope::All, 'Category', CategoryTok);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
codeunit 50912 "Privacy Sample Telemetry Good"
|
||||
{
|
||||
procedure LogProcessed(var Customer: Record Customer)
|
||||
var
|
||||
CategoryTok: Label 'CustomerProcessing', Locked = true;
|
||||
ProcessedMsgTxt: Label 'Customer record processed.';
|
||||
begin
|
||||
// Generic message. Business identifier in a custom dimension,
|
||||
// never a free-text personal name.
|
||||
Session.LogMessage(
|
||||
'0000001', ProcessedMsgTxt, Verbosity::Normal,
|
||||
DataClassification::SystemMetadata,
|
||||
TelemetryScope::ExtensionPublisher,
|
||||
'Category', CategoryTok,
|
||||
'CustomerNo', Customer."No.");
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [telemetry, session-logmessage, dataclassification, dimensions, pii]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Specify DataClassification on every telemetry call and keep PII out of the message
|
||||
|
||||
## Description
|
||||
|
||||
`Session.LogMessage` accepts a DataClassification parameter that governs how the platform handles the logged content in the telemetry pipeline. Omitting it is a schema violation the platform cannot repair later. Embedding personal data — emails, names, phone numbers, addresses, filenames of user uploads — in the message string also defeats classification, because the pipeline sees opaque text and cannot selectively redact. The same privacy boundary applies to other telemetry surfaces such as `Codeunit "Feature Telemetry"` custom dimensions.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Pass DataClassification explicitly on every Session.LogMessage call. Keep the message a generic, non-identifying sentence and place structured values in custom dimensions where the classification applies per key. Business identifiers (Customer No., Document No., Vendor No.) are acceptable as dimensions; free-text personal data is not.
|
||||
|
||||
See sample: `specify-dataclassification-on-every-telemetry-call.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`Session.LogMessage('0001', StrSubstNo('Customer %1 processed', Customer.Name), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All)` — the declared classification is SystemMetadata but the message carries CustomerContent. The payload is logged with the wrong tag; downstream consumers treat it as safe when it is not.
|
||||
|
||||
See sample: `specify-dataclassification-on-every-telemetry-call.bad.al`.
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
codeunit 50901 "Privacy Sample StrSubstNo Bad"
|
||||
{
|
||||
procedure FailCustomer(var Customer: Record Customer)
|
||||
var
|
||||
ErrorMsg: Text;
|
||||
begin
|
||||
// Platform receives a plain Text string. It cannot inspect fields,
|
||||
// cannot classify, cannot strip. The email and address reach telemetry.
|
||||
ErrorMsg := StrSubstNo(
|
||||
'Customer %1 (%2) at %3 has invalid data',
|
||||
Customer.Name, Customer."E-Mail", Customer.Address);
|
||||
Error(ErrorMsg);
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
codeunit 50900 "Privacy Sample StrSubstNo Good"
|
||||
{
|
||||
procedure FailCustomer(var Customer: Record Customer)
|
||||
var
|
||||
CustomerDataInvalidErr: Label 'Customer %1 has invalid data.', Comment = '%1 = Customer No.';
|
||||
begin
|
||||
// Platform sees the Label and the field reference. It inspects the
|
||||
// field's DataClassification and handles telemetry correctly.
|
||||
Error(CustomerDataInvalidErr, Customer."No.");
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [strsubstno, error, telemetry, dataclassification, pii]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Pre-building Error text with StrSubstNo defeats platform PII stripping
|
||||
|
||||
## Description
|
||||
|
||||
Error messages are captured by platform telemetry. When Error receives a format template and substitution arguments directly (`Error('... %1 ...', Value)`), the platform can classify and strip sensitive values before telemetry is written. This is true whether the arguments are record fields, local variables, function results, or other expressions. When the caller pre-builds the message with StrSubstNo and then passes the resulting Text to Error, the platform sees a plain string with no argument context and logs the whole thing verbatim — any PII already baked in is exported to telemetry.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Pass the template and substitution arguments directly to Error. Declare the template as a Label with a Comment describing each placeholder. Do not flag direct Error substitution merely because an argument may contain a customer name, email address, or phone number; the platform intercepts those arguments before telemetry.
|
||||
|
||||
See sample: `strsubstno-prebuild-breaks-error-telemetry-classification.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Assigning the output of StrSubstNo to a Text variable and passing that variable to Error. Every substituted value is now part of an opaque string; the platform cannot classify it and logs everything.
|
||||
|
||||
See sample: `strsubstno-prebuild-breaks-error-telemetry-classification.bad.al`.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
table 50202 "System Configuration Log"
|
||||
{
|
||||
DataClassification = SystemMetadata;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { }
|
||||
field(2; "Changed By"; Code[50]) { }
|
||||
field(3; "Change Description"; Text[250]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: privacy
|
||||
keywords: [data-classification, table-level, inheritance, override, cascading]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Table-level DataClassification cascades to every field unless overridden
|
||||
|
||||
## Description
|
||||
|
||||
`DataClassification` may be set at the table level. When it is, every field in the table inherits that classification and individual fields do not need their own `DataClassification` property. The cascade is the platform's intended way of classifying tables whose fields are homogeneous — for example, a system configuration log whose every column is `SystemMetadata`. A field only needs its own classification when its content genuinely differs from the table's default and the inherited value would be wrong.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Set `DataClassification` once at the table level whenever every field in the table shares the same classification. Omit field-level `DataClassification` properties in that case. Override only on the specific fields whose data class differs from the table's — for example, a `SystemMetadata` audit table that nonetheless captures a `CustomerContent` value somewhere.
|
||||
|
||||
See sample: `table-level-data-classification-cascades.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Flagging individual fields for "missing `DataClassification`" when the table declares one — the inheritance is the correct, intentional pattern. The mirror anti-pattern is repeating the same `DataClassification` on every field of a table that already declares it at the table level; the property is redundant and adds nothing the platform did not already know.
|
||||
Loading…
Add table
Add a link
Reference in a new issue