mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 17:36:53 +01:00
Co-locate AL samples next to their knowledge articles
The /samples/ top-level tree is replaced with sibling files in each
knowledge-layer folder. An article and its demonstrations now live
side-by-side:
microsoft/knowledge/<domain>/<slug>.md
microsoft/knowledge/<domain>/<slug>.good.al
microsoft/knowledge/<domain>/<slug>.bad.al
Rationale:
- Proximity. An article and its paired samples are one unit; the
filesystem now reflects that.
- Layer ownership. Samples inherit layer precedence for free -- a
/custom/ fork can override an article and its samples atomically,
which the shared /samples/ tree previously made awkward.
- Trivial migration path. Action-skill source globs
(*/knowledge/<domain>/**/*.md) are unchanged; sample discovery is a
sibling-filename lookup.
Changes:
- git mv of all 65 sample files from samples/<domain>/<slug>/{bad,good}.al
to microsoft/knowledge/<domain>/<slug>.{bad,good}.al (history preserved).
- Update See-sample references in all 37 articles that ship samples.
- skills/read.md: replace the no-code-blocks bullet with a pointer to a
new Sample files section that fully specifies the sibling convention,
the kinds (good/bad + forward-compatible), multi-technology rules,
demonstration-only status, and layer-precedence behaviour.
- skills/write.md: update the samples pointer to match.
- README.md: annotate the knowledge tree with the sample sibling shape.
- samples/README.md deleted; content lifted into skills/read.md.
- Both generators (C:\temp\gen_performance_knowledge.py,
C:\temp\gen_security_knowledge.py) updated to emit at the new paths
and to stop writing samples/README.md. Re-running them is idempotent
against the committed layout.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
0980397d27
commit
62dabf9a11
106 changed files with 89 additions and 98 deletions
|
|
@ -0,0 +1,14 @@
|
|||
codeunit 50225 "Sec Sample ErrorDisclosure Bad"
|
||||
{
|
||||
procedure Connect()
|
||||
begin
|
||||
if not TryConnect() then
|
||||
Error('Failed to connect to Server=PROD-SQL01;Database=NAV;User=svc_admin: %1', GetLastErrorText());
|
||||
end;
|
||||
|
||||
[TryFunction]
|
||||
local procedure TryConnect()
|
||||
begin
|
||||
// ...
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
codeunit 50224 "Sec Sample ErrorDisclosure Good"
|
||||
{
|
||||
var
|
||||
ConnectionFailedErr: Label 'Connection to the external service failed. Contact your administrator.';
|
||||
|
||||
procedure Connect()
|
||||
begin
|
||||
if not TryConnect() then begin
|
||||
LogConnectionFailure(GetLastErrorText());
|
||||
Error(ConnectionFailedErr);
|
||||
end;
|
||||
end;
|
||||
|
||||
[TryFunction]
|
||||
local procedure TryConnect()
|
||||
begin
|
||||
// ...
|
||||
end;
|
||||
|
||||
local procedure LogConnectionFailure(Detail: Text)
|
||||
begin
|
||||
// Route to controlled logging (Session.LogMessage, activity log, etc.).
|
||||
end;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ Errors surfaced to end users are routinely forwarded to support systems, capture
|
|||
|
||||
Raise end-user errors using localized Labels that describe the condition without naming infrastructure. Emit the actual detail (exception text, endpoint, correlation id) through the application's internal logging channel, where audience and retention are controlled.
|
||||
|
||||
See sample: `samples/security/avoid-sensitive-data-in-error-messages/good.al`.
|
||||
See sample: `avoid-sensitive-data-in-error-messages.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Error('Failed to connect to Server=PROD-SQL01;Database=NAV;User=admin: %1', Ex.Message); — every support ticket now carries the server name, database name, and service account.
|
||||
|
||||
See sample: `samples/security/avoid-sensitive-data-in-error-messages/bad.al`.
|
||||
See sample: `avoid-sensitive-data-in-error-messages.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
codeunit 50215 "Sec Sample SecretCompose Bad"
|
||||
{
|
||||
procedure BuildAuthHeader(Token: Text) AuthHeader: Text
|
||||
begin
|
||||
// Token is Text, so the combined value is plaintext.
|
||||
// The whole shape should have used SecretText + SecretStrSubstNo.
|
||||
AuthHeader := StrSubstNo('Bearer %1', Token);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
codeunit 50214 "Sec Sample SecretCompose Good"
|
||||
{
|
||||
procedure BuildAuthHeader(Token: SecretText) AuthHeader: SecretText
|
||||
begin
|
||||
AuthHeader := SecretStrSubstNo('Bearer %1', Token);
|
||||
end;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ SecretStrSubstNo is the SecretText analogue of StrSubstNo. The template is a reg
|
|||
|
||||
Format SecretText templates with SecretStrSubstNo. This is the correct primitive for building authorization headers, secret URIs, and any other formatted string that embeds a SecretText. Provide the static parts of the template as a regular string literal; only the substitutions carry the secret value.
|
||||
|
||||
See sample: `samples/security/compose-secrets-with-secretstrsubstno/good.al`.
|
||||
See sample: `compose-secrets-with-secretstrsubstno.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Using StrSubstNo (or plain string concatenation) on a plain-Text token to build an authorization header. The result is a Text containing the secret in plaintext, visible in the debugger, inspectable in snapshot debug sessions, and captured by any logging the caller does not control. SecretText should have been used end-to-end.
|
||||
|
||||
See sample: `samples/security/compose-secrets-with-secretstrsubstno/bad.al`.
|
||||
See sample: `compose-secrets-with-secretstrsubstno.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
codeunit 50229 "Sec Sample EventPublisher Bad"
|
||||
{
|
||||
[IntegrationEvent(false, false)]
|
||||
local procedure OnBeforeExportCustomer(CustomerNo: Code[20]; ExportCredentials: SecretText; var AllowExport: Boolean)
|
||||
begin
|
||||
end;
|
||||
|
||||
procedure ExportCustomer(CustomerNo: Code[20]; Credentials: SecretText)
|
||||
var
|
||||
AllowExport: Boolean;
|
||||
begin
|
||||
// Any subscriber on the tenant receives the credentials and
|
||||
// can flip AllowExport := true to bypass the publisher's check.
|
||||
OnBeforeExportCustomer(CustomerNo, Credentials, AllowExport);
|
||||
if not AllowExport then
|
||||
exit;
|
||||
// ... perform export
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
codeunit 50228 "Sec Sample EventPublisher Good"
|
||||
{
|
||||
[IntegrationEvent(false, false)]
|
||||
local procedure OnBeforeExportCustomer(CustomerNo: Code[20])
|
||||
begin
|
||||
end;
|
||||
|
||||
procedure ExportCustomer(CustomerNo: Code[20])
|
||||
begin
|
||||
if not CallerIsAuthorizedToExport(CustomerNo) then
|
||||
Error('You are not authorized to export this customer.');
|
||||
|
||||
OnBeforeExportCustomer(CustomerNo);
|
||||
// ... perform export using credentials owned by this codeunit
|
||||
end;
|
||||
|
||||
local procedure CallerIsAuthorizedToExport(CustomerNo: Code[20]): Boolean
|
||||
begin
|
||||
// Authorization decision stays inside the publisher. Subscribers
|
||||
// receive only the customer number and cannot influence the
|
||||
// decision.
|
||||
end;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ Events in AL are extensibility contracts. Every subscriber — third-party, inte
|
|||
|
||||
Design event signatures to carry only the data a subscriber legitimately needs. Do not pass SecretText, credential material, or flags the publisher depends on for access control. If a subscriber needs to veto an action, model it as a separate OnBefore event whose Handled pattern is documented — not as a general-purpose var Boolean callers can flip.
|
||||
|
||||
See sample: `samples/security/do-not-expose-sensitive-data-in-event-publishers/good.al`.
|
||||
See sample: `do-not-expose-sensitive-data-in-event-publishers.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
An OnBeforeElevateAccess publisher that exposes `var CanAccess: Boolean` — any subscriber installed on the tenant can flip it to true and escalate. Or a publisher that passes a SecretText parameter it obtained internally, handing it to every subscriber.
|
||||
|
||||
See sample: `samples/security/do-not-expose-sensitive-data-in-event-publishers/bad.al`.
|
||||
See sample: `do-not-expose-sensitive-data-in-event-publishers.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
codeunit 50223 "Sec Sample UrlCreds Bad"
|
||||
{
|
||||
procedure Call(ApiKey: Text)
|
||||
var
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
Client.Get('https://api.example.com/v1/items?api_key=' + ApiKey, Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
codeunit 50222 "Sec Sample UrlCreds Good"
|
||||
{
|
||||
procedure Call(ApiKey: SecretText)
|
||||
var
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
AuthHeader: SecretText;
|
||||
begin
|
||||
AuthHeader := SecretStrSubstNo('Bearer %1', ApiKey);
|
||||
Client.DefaultRequestHeaders.Add('Authorization', AuthHeader);
|
||||
Client.Get('https://api.example.com/v1/items', Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ URL query strings and path segments are routinely captured in web-server access
|
|||
|
||||
Transport credentials in Authorization headers, carried as SecretText end-to-end (see use-secrettext-with-httpclient). Where the URI itself must carry a secret (for example, a pre-signed URL), build it with SecretStrSubstNo and pass it via SetSecretRequestUri so it is never materialized as Text.
|
||||
|
||||
See sample: `samples/security/do-not-put-credentials-in-urls/good.al`.
|
||||
See sample: `do-not-put-credentials-in-urls.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Appending '?api_key=' + Key to a request URL, or embedding a token in a path segment, then calling HttpClient.Get with the resulting Text URL.
|
||||
|
||||
See sample: `samples/security/do-not-put-credentials-in-urls/bad.al`.
|
||||
See sample: `do-not-put-credentials-in-urls.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
codeunit 50227 "Sec Sample SwallowErr Bad"
|
||||
{
|
||||
procedure Authenticate(): Boolean
|
||||
begin
|
||||
if not TryAuthenticate() then
|
||||
exit(false);
|
||||
exit(true);
|
||||
end;
|
||||
|
||||
[TryFunction]
|
||||
local procedure TryAuthenticate()
|
||||
begin
|
||||
// ...
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
codeunit 50226 "Sec Sample SwallowErr Good"
|
||||
{
|
||||
procedure Authenticate(): Boolean
|
||||
begin
|
||||
if TryAuthenticate() then
|
||||
exit(true);
|
||||
|
||||
LogAuthFailure(GetLastErrorText());
|
||||
exit(false);
|
||||
end;
|
||||
|
||||
[TryFunction]
|
||||
local procedure TryAuthenticate()
|
||||
begin
|
||||
// ...
|
||||
end;
|
||||
|
||||
local procedure LogAuthFailure(Detail: Text)
|
||||
begin
|
||||
Session.LogMessage('SEC0001', 'Authentication failed', Verbosity::Warning,
|
||||
DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher,
|
||||
'Detail', Detail);
|
||||
end;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ Authentication failures, permission denials, and unexpected error paths in secur
|
|||
|
||||
Use TryFunctions to contain errors around security-relevant work, but always log the failure (category, GetLastErrorText, and enough context to identify the operation) before deciding whether to surface a user-facing error. Never discard a caught security error without a trace.
|
||||
|
||||
See sample: `samples/security/do-not-swallow-security-errors-silently/good.al`.
|
||||
See sample: `do-not-swallow-security-errors-silently.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
`if not TryAuthenticate() then exit;` with no logging and no user-facing error. An authentication-bypass attempt, a revoked credential, and a transient network glitch are now indistinguishable.
|
||||
|
||||
See sample: `samples/security/do-not-swallow-security-errors-silently/bad.al`.
|
||||
See sample: `do-not-swallow-security-errors-silently.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
permissionset 50201 "Sec Sample Full Access"
|
||||
{
|
||||
Assignable = true;
|
||||
Caption = 'Full Access (sample anti-pattern)';
|
||||
Permissions = tabledata * = RIMD;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
permissionset 50200 "Sec Sample Sales Order Entry"
|
||||
{
|
||||
Assignable = true;
|
||||
Caption = 'Sales Order Entry (sample)';
|
||||
Permissions =
|
||||
tabledata "Sales Header" = RIM,
|
||||
tabledata "Sales Line" = RIMD,
|
||||
tabledata Customer = R;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ Permission sets define the tabledata and object rights granted to every user or
|
|||
|
||||
Enumerate the specific tabledata objects a role needs and grant only the letters (R, I, M, D) that role genuinely uses. A sales order-entry role typically needs RIM on Sales Header, RIMD on Sales Line, and R on Customer — not blanket RIMD. Permission sets SHOULD be granular and role-shaped; a single permission set that covers every role in an extension is a design smell.
|
||||
|
||||
See sample: `samples/security/follow-least-privilege-in-permission-sets/good.al`.
|
||||
See sample: `follow-least-privilege-in-permission-sets.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Granting `tabledata * = RIMD` (or any wildcard with I, M, or D) in a permission set. This bypasses any meaningful separation of duties the extension could enforce and gives unreviewed code paths the ability to insert, modify, and delete on any table.
|
||||
|
||||
See sample: `samples/security/follow-least-privilege-in-permission-sets/bad.al`.
|
||||
See sample: `follow-least-privilege-in-permission-sets.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
codeunit 50207 "Sec Sample HardcodedSecret Bad"
|
||||
{
|
||||
var
|
||||
HardcodedApiKeyLbl: Label 'sk-live-1234567890abcdef', Locked = true;
|
||||
|
||||
procedure GetApiKey(): Text
|
||||
begin
|
||||
exit(HardcodedApiKeyLbl);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
codeunit 50206 "Sec Sample HardcodedSecret Good"
|
||||
{
|
||||
procedure GetApiKey() ApiKey: SecretText
|
||||
var
|
||||
StoredValue: SecretText;
|
||||
begin
|
||||
if IsolatedStorage.Contains('ApiKey', DataScope::Module) then
|
||||
if IsolatedStorage.Get('ApiKey', DataScope::Module, StoredValue) then
|
||||
exit(StoredValue);
|
||||
Error('API key is not configured.');
|
||||
end;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ A secret embedded in AL source — API key, password, connection string, token
|
|||
|
||||
Retrieve secrets at runtime from a protected store: Azure Key Vault for production workloads (see prefer-azure-key-vault-for-production-secrets) or IsolatedStorage for tenant-local encrypted values (see use-isolated-storage-for-module-and-company-secrets). Carry the retrieved value in a SecretText variable end-to-end (see use-secrettext-for-credentials).
|
||||
|
||||
See sample: `samples/security/never-hardcode-secrets-in-al/good.al`.
|
||||
See sample: `never-hardcode-secrets-in-al.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Assigning a secret literal to a Text, Code, or Label variable (including labels marked as constants). The secret is now part of the compiled app and indistinguishable from non-sensitive content to callers and tools.
|
||||
|
||||
See sample: `samples/security/never-hardcode-secrets-in-al/bad.al`.
|
||||
See sample: `never-hardcode-secrets-in-al.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
codeunit 50219 "Sec Sample Https Bad"
|
||||
{
|
||||
procedure CallExternal()
|
||||
var
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
Client.Get('http://api.example.com/data', Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
codeunit 50218 "Sec Sample Https Good"
|
||||
{
|
||||
procedure CallExternal(Endpoint: Text)
|
||||
var
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
if not Endpoint.StartsWith('https://') then
|
||||
Error('Only HTTPS endpoints are allowed.');
|
||||
Client.Get(Endpoint, Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ HttpClient can issue requests over plaintext HTTP as easily as over HTTPS. A req
|
|||
|
||||
Call external services exclusively over https://. When the destination is configurable, validate at runtime that the scheme is https before issuing the request, and fail closed with a clear (non-disclosing) error otherwise.
|
||||
|
||||
See sample: `samples/security/require-https-for-external-calls/good.al`.
|
||||
See sample: `require-https-for-external-calls.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Issuing HttpClient.Get('http://...'), or accepting an arbitrary user-supplied URL and passing it straight to HttpClient without scheme validation.
|
||||
|
||||
See sample: `samples/security/require-https-for-external-calls/bad.al`.
|
||||
See sample: `require-https-for-external-calls.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
codeunit 50221 "Sec Sample Timeout Bad"
|
||||
{
|
||||
procedure CallExternal()
|
||||
var
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
// No Timeout set; a hung endpoint stalls the caller.
|
||||
Client.Get('https://api.example.com/data', Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
codeunit 50220 "Sec Sample Timeout Good"
|
||||
{
|
||||
procedure CallExternal()
|
||||
var
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
Client.Timeout := 10000; // 10 seconds
|
||||
if not Client.Get('https://api.example.com/data', Response) then
|
||||
Error('External service is unavailable.');
|
||||
end;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ An HttpClient with no explicit timeout relies on defaults that may be long enoug
|
|||
|
||||
Set HttpClient.Timeout to a bounded value (seconds, not minutes) that reflects the SLA of the dependency. Handle the timeout error without leaking endpoint details to end users (see avoid-sensitive-data-in-error-messages).
|
||||
|
||||
See sample: `samples/security/set-timeouts-for-external-calls/good.al`.
|
||||
See sample: `set-timeouts-for-external-calls.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Issuing HttpClient requests without setting Timeout and without a timeout-handling branch. A slow dependency now has an unbounded blast radius inside the extension.
|
||||
|
||||
See sample: `samples/security/set-timeouts-for-external-calls/bad.al`.
|
||||
See sample: `set-timeouts-for-external-calls.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
permissionset 50203 "Sec Sample Direct Write"
|
||||
{
|
||||
Assignable = true;
|
||||
Caption = 'Direct write granted to every caller (sample anti-pattern)';
|
||||
Permissions =
|
||||
tabledata "Sales Header" = RM;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
permissionset 50202 "Sec Sample Elevated Write"
|
||||
{
|
||||
Assignable = false;
|
||||
Caption = 'Elevated write via helper (sample)';
|
||||
// Callers hold R directly; the helper codeunit assumes this set and
|
||||
// performs the Modify via indirect permission.
|
||||
Permissions =
|
||||
tabledata "Sales Header" = Rmi;
|
||||
}
|
||||
|
||||
codeunit 50231 "Sec Sample Elevated Helper"
|
||||
{
|
||||
Access = Public;
|
||||
Permissions = tabledata "Sales Header" = Rmi;
|
||||
|
||||
procedure SetExternalDocumentNo(SalesDocType: Enum "Sales Document Type"; SalesDocNo: Code[20]; NewExternalDocNo: Code[35])
|
||||
var
|
||||
SalesHeader: Record "Sales Header";
|
||||
begin
|
||||
ValidateCaller();
|
||||
if NewExternalDocNo = '' then
|
||||
Error('External document number must be provided.');
|
||||
if not SalesHeader.Get(SalesDocType, SalesDocNo) then
|
||||
Error('Sales document not found.');
|
||||
SalesHeader."External Document No." := NewExternalDocNo;
|
||||
SalesHeader.Modify(true);
|
||||
end;
|
||||
|
||||
local procedure ValidateCaller()
|
||||
begin
|
||||
// Verify the caller is permitted to perform this elevated write
|
||||
// (role check, setup flag, approvals, etc.).
|
||||
end;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ Indirect permissions (ri, ii, mi, di) let a procedure perform an operation again
|
|||
|
||||
Where a module exposes a controlled write or delete against a sensitive table, grant the codeunit (or the helper permission set it assumes) the indirect permission (mi, di) it requires, keep direct permissions minimal, and document why the elevation is justified. The helper MUST validate its inputs and the caller's identity before performing the elevated work.
|
||||
|
||||
See sample: `samples/security/use-indirect-permissions-for-elevated-access/good.al`.
|
||||
See sample: `use-indirect-permissions-for-elevated-access.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Granting direct M or D on a sensitive tabledata to every role that might invoke a helper, because authoring an indirect-permission codeunit was inconvenient. Every caller now has the elevated right for every code path, not just the one the helper implements.
|
||||
|
||||
See sample: `samples/security/use-indirect-permissions-for-elevated-access/bad.al`.
|
||||
See sample: `use-indirect-permissions-for-elevated-access.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
codeunit 50205 "Sec Sample Inherent Bad"
|
||||
{
|
||||
// No InherentPermissions attribute: every caller must hold
|
||||
// tabledata "Sec Sample Lookup" = R just to look up a name.
|
||||
procedure GetLookupName(LookupCode: Code[20]): Text[100]
|
||||
var
|
||||
Lookup: Record "Sec Sample Lookup";
|
||||
begin
|
||||
if Lookup.Get(LookupCode) then
|
||||
exit(Lookup.Name);
|
||||
exit('');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
table 50230 "Sec Sample Lookup"
|
||||
{
|
||||
DataClassification = SystemMetadata;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Code"; Code[20]) { }
|
||||
field(2; "Name"; Text[100]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Code") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50204 "Sec Sample Inherent Good"
|
||||
{
|
||||
[InherentPermissions(PermissionObjectType::TableData, Database::"Sec Sample Lookup", 'r')]
|
||||
procedure GetLookupName(LookupCode: Code[20]): Text[100]
|
||||
var
|
||||
Lookup: Record "Sec Sample Lookup";
|
||||
begin
|
||||
if Lookup.Get(LookupCode) then
|
||||
exit(Lookup.Name);
|
||||
exit('');
|
||||
end;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ The InherentPermissions attribute attaches a minimum access grant to a procedure
|
|||
|
||||
Annotate read-only helper procedures with InherentPermissions specifying only the tables and access letters the body uses (typically 'r'). Callers do not need direct read rights on the underlying extension-owned table, so the calling role can be narrower. This is the narrowest of the elevation options and is appropriate for read-only lookup helpers.
|
||||
|
||||
See sample: `samples/security/use-inherent-permissions-to-grant-minimal-access/good.al`.
|
||||
See sample: `use-inherent-permissions-to-grant-minimal-access.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A helper that reads a single lookup value but forces every calling role to hold tabledata read rights, because the helper does not declare its own inherent permissions. The broad read right then applies to every other code path that role can reach, not just the helper.
|
||||
|
||||
See sample: `samples/security/use-inherent-permissions-to-grant-minimal-access/bad.al`.
|
||||
See sample: `use-inherent-permissions-to-grant-minimal-access.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
codeunit 50209 "Sec Sample IsolatedStorage Bad"
|
||||
{
|
||||
procedure StoreApiKey(NewKey: Text)
|
||||
begin
|
||||
// Plaintext write to IsolatedStorage is not encrypted at rest.
|
||||
IsolatedStorage.Set('ApiKey', NewKey, DataScope::Module);
|
||||
end;
|
||||
|
||||
procedure GetApiKey(): Text
|
||||
var
|
||||
ApiKey: Text;
|
||||
begin
|
||||
if IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey) then
|
||||
exit(ApiKey);
|
||||
exit('');
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
codeunit 50208 "Sec Sample IsolatedStorage Good"
|
||||
{
|
||||
procedure StoreApiKey(NewKey: SecretText)
|
||||
begin
|
||||
IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module);
|
||||
end;
|
||||
|
||||
procedure TryGetApiKey(var ApiKey: SecretText): Boolean
|
||||
begin
|
||||
if IsolatedStorage.Contains('ApiKey', DataScope::Module) then
|
||||
exit(IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey));
|
||||
exit(false);
|
||||
end;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ IsolatedStorage is a per-extension, per-tenant key-value store. DataScope::Modul
|
|||
|
||||
Use IsolatedStorage.SetEncrypted to write secrets, IsolatedStorage.Contains to probe, and IsolatedStorage.Get into a SecretText destination to read. Choose DataScope::Company for per-company credentials (for example, a tenant-per-company service account) and DataScope::Module for extension-wide configuration.
|
||||
|
||||
See sample: `samples/security/use-isolated-storage-for-module-and-company-secrets/good.al`.
|
||||
See sample: `use-isolated-storage-for-module-and-company-secrets.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Storing secrets in a Setup table column as plain Text, or using IsolatedStorage.Set (unencrypted) for values that authenticate the extension to an external service. Both shapes leave the secret readable by anyone with read rights on the underlying storage.
|
||||
|
||||
See sample: `samples/security/use-isolated-storage-for-module-and-company-secrets/bad.al`.
|
||||
See sample: `use-isolated-storage-for-module-and-company-secrets.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
codeunit 50217 "Sec Sample NonDebuggable Bad"
|
||||
{
|
||||
// Missing [NonDebuggable]: ResponseText and the extracted token are
|
||||
// inspectable in the debugger and in snapshot debug sessions.
|
||||
procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText)
|
||||
var
|
||||
ResponseText: Text;
|
||||
JObject: JsonObject;
|
||||
JToken: JsonToken;
|
||||
begin
|
||||
Response.Content.ReadAs(ResponseText);
|
||||
JObject.ReadFrom(ResponseText);
|
||||
JObject.Get('access_token', JToken);
|
||||
SessionToken := JToken.AsValue().AsText();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
codeunit 50216 "Sec Sample NonDebuggable Good"
|
||||
{
|
||||
[NonDebuggable]
|
||||
procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText)
|
||||
var
|
||||
ResponseText: Text;
|
||||
JObject: JsonObject;
|
||||
JToken: JsonToken;
|
||||
begin
|
||||
Response.Content.ReadAs(ResponseText);
|
||||
JObject.ReadFrom(ResponseText);
|
||||
JObject.Get('access_token', JToken);
|
||||
SessionToken := JToken.AsValue().AsText();
|
||||
end;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ SecretText transit (assignment between SecretText variables, parameters, and ret
|
|||
|
||||
Apply [NonDebuggable] to any procedure that reads a response body, parses it, and assigns the extracted secret to a SecretText out-parameter or return. Keep the procedure narrow: it SHOULD do the minimum work required to obtain the SecretText, and nothing else.
|
||||
|
||||
See sample: `samples/security/use-nondebuggable-when-parsing-secrets/good.al`.
|
||||
See sample: `use-nondebuggable-when-parsing-secrets.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Parsing a token response in a normal (debuggable) procedure. The plaintext token is visible in debug sessions and snapshots taken during the parse.
|
||||
|
||||
See sample: `samples/security/use-nondebuggable-when-parsing-secrets/bad.al`.
|
||||
See sample: `use-nondebuggable-when-parsing-secrets.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
codeunit 50211 "Sec Sample SecretText Bad"
|
||||
{
|
||||
procedure SendAuthenticatedRequest(BearerToken: Text)
|
||||
var
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
AuthValue: Text;
|
||||
begin
|
||||
AuthValue := 'Bearer ' + BearerToken;
|
||||
Client.DefaultRequestHeaders.Add('Authorization', AuthValue);
|
||||
Client.Get('https://api.example.com/data', Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
codeunit 50210 "Sec Sample SecretText Good"
|
||||
{
|
||||
procedure SendAuthenticatedRequest(BearerToken: SecretText)
|
||||
var
|
||||
Client: HttpClient;
|
||||
Headers: HttpHeaders;
|
||||
Response: HttpResponseMessage;
|
||||
AuthValue: SecretText;
|
||||
begin
|
||||
AuthValue := SecretStrSubstNo('Bearer %1', BearerToken);
|
||||
Client.DefaultRequestHeaders.Add('Authorization', AuthValue);
|
||||
Client.Get('https://api.example.com/data', Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ SecretText is a compile-time-checked AL type for credentials, API keys, tokens,
|
|||
|
||||
Type every credential-carrying variable, procedure parameter, and return as SecretText. Compose values with SecretStrSubstNo (see compose-secrets-with-secretstrsubstno). For HttpClient integration, see use-secrettext-with-httpclient. When a secret must be extracted from a Text source, contain that conversion in a NonDebuggable procedure (see use-nondebuggable-when-parsing-secrets).
|
||||
|
||||
See sample: `samples/security/use-secrettext-for-credentials/good.al`.
|
||||
See sample: `use-secrettext-for-credentials.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Passing credentials around as Text or Code parameters. Every such variable is visible in the debugger and may be captured by error handlers, logs, and telemetry that treat Text as non-sensitive.
|
||||
|
||||
See sample: `samples/security/use-secrettext-for-credentials/bad.al`.
|
||||
See sample: `use-secrettext-for-credentials.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
codeunit 50213 "Sec Sample SecretHttpClient Bad"
|
||||
{
|
||||
procedure Call(ApiKey: Text)
|
||||
var
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
FullUrl: Text;
|
||||
begin
|
||||
FullUrl := 'https://api.example.com/v1?key=' + ApiKey;
|
||||
Client.Get(FullUrl, Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
codeunit 50212 "Sec Sample SecretHttpClient Good"
|
||||
{
|
||||
procedure Call(ApiKey: SecretText)
|
||||
var
|
||||
Client: HttpClient;
|
||||
Request: HttpRequestMessage;
|
||||
Response: HttpResponseMessage;
|
||||
SecretUri: SecretText;
|
||||
begin
|
||||
SecretUri := SecretStrSubstNo('https://api.example.com/v1?key=%1', ApiKey);
|
||||
Request.SetSecretRequestUri(SecretUri);
|
||||
Request.Method('GET');
|
||||
Client.Send(Request, Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -19,11 +19,11 @@ HttpRequestMessage, HttpHeaders, and HttpContent expose SecretText overloads so
|
|||
|
||||
Use HttpRequestMessage.SetSecretRequestUri when any URI component is sensitive (for example, a per-call API key in the path or query), and send the request with HttpClient.Send. Add Authorization headers as SecretText. Check for the presence of a secret header with ContainsSecret, not Contains.
|
||||
|
||||
See sample: `samples/security/use-secrettext-with-httpclient/good.al`.
|
||||
See sample: `use-secrettext-with-httpclient.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Materializing a URI or header value as Text to 'just get it to compile' — for example, StrSubstNo into a Text and then HttpClient.Get(FullUrl, Response). The resulting Text is visible in debuggers, and the URL is typically captured by platform-level logging the extension does not control.
|
||||
|
||||
See sample: `samples/security/use-secrettext-with-httpclient/bad.al`.
|
||||
See sample: `use-secrettext-with-httpclient.bad.al`.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue