mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 09:26:52 +01:00
Correct security and privacy guidance
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9c2eebc4-dcd5-4b85-8113-90772d818900
This commit is contained in:
parent
7a678d1aff
commit
ec8f891954
34 changed files with 189 additions and 150 deletions
|
|
@ -8,7 +8,7 @@ codeunit 50215 "Sec Sample IsoStorage Good"
|
|||
exit(true);
|
||||
end;
|
||||
|
||||
internal procedure SetApiKey(NewKey: Text)
|
||||
internal procedure SetApiKey(NewKey: SecretText)
|
||||
begin
|
||||
IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module);
|
||||
end;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
bc-version: [24..]
|
||||
domain: security
|
||||
keywords: [isolatedstorage, local, internal, public, getter, setter, encapsulation]
|
||||
technologies: [al]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
codeunit 50220 "Sec Sample DataScope Bad"
|
||||
{
|
||||
internal procedure StoreCompanyWebhook(WebhookUrl: Text)
|
||||
internal procedure StoreCompanyWebhook(WebhookUrl: SecretText)
|
||||
begin
|
||||
IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Module);
|
||||
end;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
codeunit 50219 "Sec Sample DataScope Good"
|
||||
{
|
||||
internal procedure StoreTenantApiKey(ApiKey: Text)
|
||||
internal procedure StoreTenantApiKey(ApiKey: SecretText)
|
||||
begin
|
||||
IsolatedStorage.SetEncrypted('TenantApiKey', ApiKey, DataScope::Module);
|
||||
end;
|
||||
|
||||
internal procedure StoreCompanyWebhook(WebhookUrl: Text)
|
||||
internal procedure StoreCompanyWebhook(WebhookUrl: SecretText)
|
||||
begin
|
||||
IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Company);
|
||||
end;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
bc-version: [24..]
|
||||
domain: security
|
||||
keywords: [isolatedstorage, datascope, module, company, user, scope]
|
||||
technologies: [al]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
codeunit 50217 "Sec Sample SetEncrypted Good"
|
||||
{
|
||||
internal procedure StoreApiKey(ApiKeyValue: Text)
|
||||
internal procedure StoreApiKey(ApiKeyValue: SecretText)
|
||||
var
|
||||
StoreApiKeyFailedErr: Label 'The API key could not be stored.';
|
||||
begin
|
||||
if StrLen(ApiKeyValue) > 200 then
|
||||
Error('API key too long for encrypted storage');
|
||||
IsolatedStorage.SetEncrypted('ApiKey', ApiKeyValue, DataScope::Module);
|
||||
if not IsolatedStorage.SetEncrypted('ApiKey', ApiKeyValue, DataScope::Module) then
|
||||
Error(StoreApiKeyFailedErr);
|
||||
end;
|
||||
|
||||
local procedure ReadApiKey(var ApiKey: SecretText): Boolean
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
bc-version: [24..]
|
||||
domain: security
|
||||
keywords: [isolatedstorage, setencrypted, encryption, secret, storage]
|
||||
technologies: [al]
|
||||
|
|
@ -15,7 +15,7 @@ application-area: [all]
|
|||
|
||||
## Best Practice
|
||||
|
||||
Use `IsolatedStorage.SetEncrypted` for every value that meets the definition of a secret. Pair it with the matching retrieval pattern: `IsolatedStorage.Contains` to test for presence and `IsolatedStorage.Get` (preferably with a `SecretText` destination) to read. Constrain the input length before storing — long values can exceed the encrypted-storage size limit and the write will fail at runtime. See sample: `isolatedstorage-setencrypted-for-sensitive-values.good.al`.
|
||||
Use the `SecretText` overloads of `IsolatedStorage.SetEncrypted` and `IsolatedStorage.Get` for values that meet the definition of a secret. Check the optional Boolean result when storage failure needs a controlled error; encrypted values are subject to the documented storage-size limit. See sample: `isolatedstorage-setencrypted-for-sensitive-values.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,14 @@
|
|||
codeunit 50214 "Sec Sample NonDebug Bad"
|
||||
{
|
||||
procedure BuildConnectionString(ApiKey: SecretText): Text
|
||||
procedure CallLegacyOnPremisesConsumer(ApiKey: SecretText)
|
||||
var
|
||||
PlainApiKey: Text;
|
||||
begin
|
||||
exit('Server=db.example.com;Key=' + ApiKey.Unwrap());
|
||||
PlainApiKey := ApiKey.Unwrap();
|
||||
InvokeLegacyConsumer(PlainApiKey);
|
||||
end;
|
||||
|
||||
procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText)
|
||||
var
|
||||
ResponseText: Text;
|
||||
JsonObject: JsonObject;
|
||||
JsonToken: JsonToken;
|
||||
local procedure InvokeLegacyConsumer(ApiKey: Text)
|
||||
begin
|
||||
Response.Content.ReadAs(ResponseText);
|
||||
JsonObject.ReadFrom(ResponseText);
|
||||
JsonObject.Get('access_token', JsonToken);
|
||||
SessionToken := JsonToken.AsValue().AsText();
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,17 @@
|
|||
codeunit 50213 "Sec Sample NonDebug Good"
|
||||
{
|
||||
[NonDebuggable]
|
||||
procedure BuildConnectionString(ApiKey: SecretText): Text
|
||||
procedure CallLegacyOnPremisesConsumer(ApiKey: SecretText)
|
||||
var
|
||||
PlainApiKey: Text;
|
||||
begin
|
||||
exit('Server=db.example.com;Key=' + ApiKey.Unwrap());
|
||||
PlainApiKey := ApiKey.Unwrap();
|
||||
InvokeLegacyConsumer(PlainApiKey);
|
||||
end;
|
||||
|
||||
[NonDebuggable]
|
||||
procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText)
|
||||
var
|
||||
ResponseText: Text;
|
||||
JsonObject: JsonObject;
|
||||
JsonToken: JsonToken;
|
||||
local procedure InvokeLegacyConsumer(ApiKey: Text)
|
||||
begin
|
||||
Response.Content.ReadAs(ResponseText);
|
||||
JsonObject.ReadFrom(ResponseText);
|
||||
JsonObject.Get('access_token', JsonToken);
|
||||
SessionToken := JsonToken.AsValue().AsText();
|
||||
// The on-premises legacy consumer accepts only Text.
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
bc-version: [23..]
|
||||
domain: security
|
||||
keywords: [nondebuggable, attribute, secrettext, unwrap, debugger]
|
||||
technologies: [al]
|
||||
|
|
@ -7,16 +7,16 @@ countries: [w1]
|
|||
application-area: [all]
|
||||
---
|
||||
|
||||
# Mark procedures that call SecretText.Unwrap() as [NonDebuggable]
|
||||
# On-premises only: protect unavoidable SecretText.Unwrap calls
|
||||
|
||||
## Description
|
||||
|
||||
`SecretText` transit — assignment, parameter passing, and return values — is auto-protected: the debugger sees a redacted placeholder, not the value. The protection ends the moment code calls `.Unwrap()`, which converts the `SecretText` back to plain `Text`. From that point on, the local variable holding the result is visible in the debugger like any other `Text`. The `[NonDebuggable]` attribute marks a procedure so that none of its locals or parameters are visible to the debugger during execution, which is exactly what is needed for any procedure that performs an `Unwrap()` or that otherwise materializes a secret as `Text` (for example, while parsing a JSON response to extract an access token).
|
||||
`SecretText.Unwrap()` is supported only for Business Central on-premises and exists for compatibility. It converts a protected value to plain `Text`, where debugger redaction no longer applies. `[NonDebuggable]` prevents the debugger from inspecting a procedure's parameters and locals, but it does not make the resulting `Text` safe to return, log, or pass through debuggable code.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Apply `[NonDebuggable]` to any procedure whose body calls `.Unwrap()` on a `SecretText`, and to any procedure that constructs a `SecretText` from a `Text` source (such as a procedure that reads a JSON response body and converts the resulting `Text` into a `SecretText` for the caller). Keep the unwrap window as small as possible — ideally a single one-line helper that hands the unwrapped value straight to the consuming API. See sample: `nondebuggable-required-when-unwrapping-secrettext.good.al`.
|
||||
In SaaS, keep the value as `SecretText` and use secret-aware APIs instead of unwrapping. For an unavoidable on-premises legacy API that accepts only `Text`, keep the plain-text path as short as possible and mark every procedure in that path `[NonDebuggable]`. Do not return the unwrapped value. See sample: `nondebuggable-required-when-unwrapping-secrettext.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Calling `ApiKey.Unwrap()` inside a procedure that is not marked `[NonDebuggable]`. The unwrapped value is now an ordinary `Text` local and the debugger will display it, defeating the purpose of using `SecretText` in the first place. Reviewers should flag any `Unwrap()` call in a procedure that lacks the attribute, and any procedure that parses a credential out of a response (`access_token`, `id_token`, `client_secret`) without the attribute. See sample: `nondebuggable-required-when-unwrapping-secrettext.bad.al`.
|
||||
Calling `Unwrap()` in cloud-targeted code, or calling it in an on-premises procedure that is debuggable or returns the resulting `Text`. Both defeat the protection that `SecretText` provides. See sample: `nondebuggable-required-when-unwrapping-secrettext.bad.al`.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
codeunit 50212 "Sec Sample SecretSubst Bad"
|
||||
{
|
||||
procedure BuildAuthHeader(Token: SecretText): Text
|
||||
procedure BuildAuthHeader(Token: Text): Text
|
||||
begin
|
||||
exit(StrSubstNo('Bearer %1', Token.Unwrap()));
|
||||
exit(StrSubstNo('Bearer %1', Token));
|
||||
end;
|
||||
|
||||
procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): Text
|
||||
procedure BuildSecretUri(ApiKey: Text): Text
|
||||
begin
|
||||
exit(BaseUrl + '?key=' + ApiKey.Unwrap());
|
||||
exit(StrSubstNo('https://api.example.com/data?key=%1', ApiKey));
|
||||
end;
|
||||
|
||||
procedure BuildBrokenAuthHeader(Token: SecretText): SecretText
|
||||
begin
|
||||
exit(SecretStrSubstNo('Bearer', Token));
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ codeunit 50211 "Sec Sample SecretSubst Good"
|
|||
exit(SecretStrSubstNo('Bearer %1', Token));
|
||||
end;
|
||||
|
||||
procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): SecretText
|
||||
procedure BuildSecretUri(ApiKey: SecretText): SecretText
|
||||
begin
|
||||
exit(SecretStrSubstNo('%1?key=%2', BaseUrl, ApiKey));
|
||||
exit(SecretStrSubstNo('https://api.example.com/data?key=%1', ApiKey));
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
bc-version: [23..]
|
||||
domain: security
|
||||
keywords: [secretstrsubstno, secrettext, strsubstno, format, compose]
|
||||
technologies: [al]
|
||||
|
|
@ -11,12 +11,12 @@ application-area: [all]
|
|||
|
||||
## Description
|
||||
|
||||
`SecretStrSubstNo` is the secret-preserving counterpart of `StrSubstNo`. It accepts a format string and arguments (any of which may be `SecretText`) and returns a `SecretText` — the substitution happens without ever materializing the result as plain `Text`. It is the right tool whenever a secret needs to be embedded in a larger string: an `Authorization: Bearer <token>` header value, a URI that includes an API key as a query parameter, or any other interpolation that combines a `SecretText` with surrounding context.
|
||||
`SecretStrSubstNo` is the secret-preserving counterpart of `StrSubstNo`. It inserts `SecretText` arguments into `%1`, `%2`, and similar placeholders and returns `SecretText` without materializing the result as plain text. It is the right tool for values such as a `Bearer %1` authorization header or a URI with an API key placeholder.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Compose every secret-bearing string through `SecretStrSubstNo` and keep the result as `SecretText` end-to-end. Pass the result to the `SecretText` overload of the consumer — `HttpClient.SetSecretRequestUri`, `HttpHeaders.Add`, or `HttpContent.WriteFrom`. See sample: `secretstrsubstno-for-composing-secrets.good.al`.
|
||||
Compose every secret-bearing string through `SecretStrSubstNo`, ensure the format contains a placeholder for each secret, and keep the result as `SecretText`. Pass it to `HttpRequestMessage.SetSecretRequestUri`, `HttpHeaders.Add`, or `HttpContent.WriteFrom`. See sample: `secretstrsubstno-for-composing-secrets.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Calling `StrSubstNo('Bearer %1', Token.Unwrap())` to build the header value, or concatenating `'Bearer ' + Token.Unwrap()`. Both produce a plain `Text` containing the secret, which is then visible in the debugger and in any subsequent log or trace. Reviewers should flag any `Unwrap()` whose result is fed into `StrSubstNo` or used in `+` concatenation — `SecretStrSubstNo` removes the need for either. See sample: `secretstrsubstno-for-composing-secrets.bad.al`.
|
||||
Keeping a credential in `Text` and inserting it with `StrSubstNo`, or calling `SecretStrSubstNo` with a format that has no placeholder for the secret. The first exposes the value as plain text; the second silently omits it. See sample: `secretstrsubstno-for-composing-secrets.bad.al`.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
codeunit 50207 "Sec Sample SecretText Good"
|
||||
{
|
||||
procedure CallExternalApi()
|
||||
procedure CallExternalApi(ApiKey: SecretText)
|
||||
var
|
||||
ApiKey: SecretText;
|
||||
HttpClient: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
Headers: HttpHeaders;
|
||||
begin
|
||||
if IsolatedStorage.Contains('ApiKey', DataScope::Module) then
|
||||
IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey);
|
||||
Headers := HttpClient.DefaultRequestHeaders();
|
||||
Headers.Add('X-Api-Key', ApiKey);
|
||||
HttpClient.Get('https://api.example.com/data', Response);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
bc-version: [23..]
|
||||
domain: security
|
||||
keywords: [secrettext, credentials, api-key, token, debugger, unwrap]
|
||||
technologies: [al]
|
||||
|
|
@ -15,7 +15,7 @@ application-area: [all]
|
|||
|
||||
## Best Practice
|
||||
|
||||
Declare credential-carrying parameters and variables as `SecretText` from the call site that retrieves the secret all the way to the call site that consumes it (typically an `HttpClient` header or URI). Never round-trip through `Text` — every conversion is a potential exposure point. Retrieve secrets from `IsolatedStorage` with the `SecretText` overload of `Get` rather than the `Text` overload. See sample: `secrettext-for-credentials.good.al`.
|
||||
Declare credential-carrying parameters and variables as `SecretText` from the call site that retrieves the secret all the way to the call site that consumes it (typically an HTTP header or URI). Never round-trip through `Text`. On BC 24 and later, use the `SecretText` overload of `IsolatedStorage.Get` when retrieving stored secrets. See sample: `secrettext-for-credentials.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
codeunit 50210 "Sec Sample SecretHttp Bad"
|
||||
{
|
||||
procedure CallApiWithSecretInUri(ApiKey: SecretText)
|
||||
procedure CallApiWithSecretInUri(ApiKey: Text)
|
||||
var
|
||||
HttpClient: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
RequestUri: Text;
|
||||
begin
|
||||
RequestUri := 'https://api.example.com/data?key=' + ApiKey.Unwrap();
|
||||
RequestUri := StrSubstNo('https://api.example.com/data?key=%1', ApiKey);
|
||||
HttpClient.Get(RequestUri, Response);
|
||||
end;
|
||||
|
||||
procedure CallApiWithBearer(BearerToken: SecretText)
|
||||
procedure CallApiWithBearer(BearerToken: Text)
|
||||
var
|
||||
HttpClient: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
Headers: HttpHeaders;
|
||||
begin
|
||||
Headers := HttpClient.DefaultRequestHeaders();
|
||||
Headers.Add('Authorization', 'Bearer ' + BearerToken.Unwrap());
|
||||
Headers.Add('Authorization', StrSubstNo('Bearer %1', BearerToken));
|
||||
HttpClient.Get('https://api.example.com/data', Response);
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,26 +3,32 @@ codeunit 50209 "Sec Sample SecretHttp Good"
|
|||
procedure CallApiWithSecretUri(ApiKey: SecretText)
|
||||
var
|
||||
HttpClient: HttpClient;
|
||||
Request: HttpRequestMessage;
|
||||
Response: HttpResponseMessage;
|
||||
SecretUri: SecretText;
|
||||
begin
|
||||
SecretUri := SecretStrSubstNo('https://api.example.com/data?key=%1', ApiKey);
|
||||
HttpClient.SetSecretRequestUri(SecretUri);
|
||||
HttpClient.Get('', Response);
|
||||
Request.Method := 'GET';
|
||||
Request.SetSecretRequestUri(SecretUri);
|
||||
HttpClient.Send(Request, Response);
|
||||
end;
|
||||
|
||||
procedure CallApiWithBearer(BearerToken: SecretText)
|
||||
var
|
||||
HttpClient: HttpClient;
|
||||
Request: HttpRequestMessage;
|
||||
Response: HttpResponseMessage;
|
||||
Headers: HttpHeaders;
|
||||
AuthHeader: SecretText;
|
||||
AuthorizationHeaderMissingErr: Label 'Authorization header missing.';
|
||||
begin
|
||||
Request.Method := 'GET';
|
||||
Request.SetRequestUri('https://api.example.com/data');
|
||||
Request.GetHeaders(Headers);
|
||||
AuthHeader := SecretStrSubstNo('Bearer %1', BearerToken);
|
||||
Headers := HttpClient.DefaultRequestHeaders();
|
||||
Headers.Add('Authorization', AuthHeader);
|
||||
if not Headers.ContainsSecret('Authorization') then
|
||||
Error('Authorization header missing');
|
||||
HttpClient.Get('https://api.example.com/data', Response);
|
||||
Error(AuthorizationHeaderMissingErr);
|
||||
HttpClient.Send(Request, Response);
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
bc-version: [23..]
|
||||
domain: security
|
||||
keywords: [secrettext, httpclient, setsecretrequesturi, containssecret, headers, http]
|
||||
technologies: [al]
|
||||
|
|
@ -7,16 +7,16 @@ countries: [w1]
|
|||
application-area: [all]
|
||||
---
|
||||
|
||||
# Use the SecretText-aware HttpClient surface for secrets in requests
|
||||
# Set secret request URIs on HttpRequestMessage
|
||||
|
||||
## Description
|
||||
|
||||
`HttpClient` and its companion types expose a parallel surface that accepts `SecretText` instead of `Text`, so that secret URIs, secret headers, and secret request bodies never round-trip through plain text. The key entry points are: `HttpClient.SetSecretRequestUri()` for URIs that contain secrets (the subsequent `Get`/`Post` is then called with an empty string); `HttpHeaders.Add()` overload that accepts a `SecretText` value for authorization headers; `HttpHeaders.ContainsSecret()` to test whether a secret header is present (the plain `Contains()` returns false for secret headers); `HttpContent.WriteFrom()` and `HttpContent.ReadAs()` overloads that accept and produce `SecretText` for request and response bodies that carry credentials.
|
||||
The secret URI API belongs to `HttpRequestMessage`, not `HttpClient`. `HttpRequestMessage.SetSecretRequestUri(SecretText)` keeps a credential-bearing URI protected, and the prepared request is sent with `HttpClient.Send`. Companion APIs also accept `SecretText`, including `HttpHeaders.Add` for authorization headers and `HttpContent.WriteFrom` for secret request bodies.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When the URI contains a secret query parameter, compose it as `SecretText` (see `secretstrsubstno-for-composing-secrets.md`), pass it to `SetSecretRequestUri`, and call `Get('', Response)` with an empty string as the URI argument. When the credential is an authorization header, build the header value as `SecretText` and pass it to `Headers.Add`. Use `ContainsSecret` rather than `Contains` to check for the presence of a secret header. See sample: `secrettext-with-httpclient.good.al`.
|
||||
Compose a secret URI with `SecretStrSubstNo`, call `Request.SetSecretRequestUri(SecretUri)`, set the request method, and send the request with `HttpClient.Send(Request, Response)`. For authorization, get the request headers, add a `SecretText` value, and use `ContainsSecret` when checking for that header. See sample: `secrettext-with-httpclient.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Calling `ApiKey.Unwrap()` to build a URI or header string and passing the resulting `Text` to `HttpClient.Get` or `Headers.Add`. The unwrapped secret is now visible in the debugger, in any HTTP trace that captures the request URI, and in any error that includes the URI. Reviewers should flag any `Unwrap()` call whose result flows into an `HttpClient` argument; the `SecretText` overload exists precisely so the unwrap is not needed. See sample: `secrettext-with-httpclient.bad.al`.
|
||||
Holding a credential in `Text`, interpolating it with `StrSubstNo` or concatenation, and passing that plain text to `HttpClient.Get` or `HttpHeaders.Add`. The secret-aware request and header APIs remove the need to materialize the value as `Text`. See sample: `secrettext-with-httpclient.bad.al`.
|
||||
|
|
|
|||
|
|
@ -2,24 +2,21 @@ tableextension 50223 "Sec Sample VTR Good" extends Customer
|
|||
{
|
||||
fields
|
||||
{
|
||||
field(50223; "System Batch ID"; Code[20])
|
||||
{
|
||||
TableRelation = "Sales Header"."No.";
|
||||
ValidateTableRelation = false;
|
||||
Editable = false;
|
||||
}
|
||||
field(50224; "External Customer Ref"; Code[50])
|
||||
field(50223; "External Customer Ref"; Code[50])
|
||||
{
|
||||
TableRelation = Customer."No.";
|
||||
ValidateTableRelation = false;
|
||||
TestTableRelation = false;
|
||||
|
||||
trigger OnValidate()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
InvalidExternalReferenceErr: Label 'The external customer reference must not contain spaces.';
|
||||
begin
|
||||
if "External Customer Ref" = '' then
|
||||
exit;
|
||||
if not Customer.Get("External Customer Ref") then
|
||||
Error('External customer reference %1 does not exist.', "External Customer Ref");
|
||||
"External Customer Ref" := CopyStr(
|
||||
UpperCase(DelChr("External Customer Ref", '<>', ' ')),
|
||||
1, MaxStrLen("External Customer Ref"));
|
||||
if StrPos("External Customer Ref", ' ') > 0 then
|
||||
Error(InvalidExternalReferenceErr);
|
||||
end;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,16 +7,16 @@ countries: [w1]
|
|||
application-area: [all]
|
||||
---
|
||||
|
||||
# Do not set ValidateTableRelation = false on user-editable fields
|
||||
# Handle free-form input when ValidateTableRelation is false
|
||||
|
||||
## Description
|
||||
|
||||
`TableRelation` on a field declares that the field's value must exist in another table; the platform validates the value on entry and on `Validate`. Setting `ValidateTableRelation = false` keeps the relation as metadata (used by lookups, by Edit-in-Excel, by APIs) but turns off the runtime check. On a system-controlled, non-editable field that is populated only by the platform or by a posting routine, that is acceptable. On a user-editable field, it is dangerous: users can type any value, and downstream code that assumes the relation holds will read a `Customer` record that does not exist, post to an account that was deleted, or join against missing rows.
|
||||
`ValidateTableRelation = false` intentionally lets a user keep free-form input even when it does not match `TableRelation`. This is supported for scenarios such as accepting a new vendor name and handling it in `OnValidate`. The risk is not the property itself; it is leaving downstream code to assume that every value identifies an existing related record.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Leave `ValidateTableRelation` at its default (true) on any field a user can edit. If there is a legitimate reason to turn it off — typically because the relation is not on the primary key, or because the relation is computed — replace it with an `OnValidate` trigger that performs the equivalent check (`if FieldValue <> '' then VerifyExternalReferenceExists(FieldValue)`). Combine `ValidateTableRelation = false` with `Editable = false` for system-controlled fields, so the metadata is correct and the field is unreachable from the UI. See sample: `validatetablerelation-false-on-user-input.good.al`.
|
||||
Keep the default validation when values must exist in the related table. When free-form values are intentional, set both `ValidateTableRelation = false` and `TestTableRelation = false`, then add compensating `OnValidate` logic that normalizes, validates, creates, or otherwise handles unmatched input. Document that downstream code must not assume the relation exists. See sample: `validatetablerelation-false-on-user-input.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`ValidateTableRelation = false` on a user-facing input field (a `Customer No.` typed by a sales user) with no alternative validation. Reviewers should flag the combination of `ValidateTableRelation = false` and any of: `Editable = true` (the default), an `OnValidate` trigger that does not perform the relation check, or a page that surfaces the field as input. See sample: `validatetablerelation-false-on-user-input.bad.al`.
|
||||
`ValidateTableRelation = false` on a user-facing field with no intentional handling for unmatched values, or leaving `TestTableRelation = true` so database relation tests reject values the UI deliberately accepts. See sample: `validatetablerelation-false-on-user-input.bad.al`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue