Row-Level Security (RLS for short in this article) is an Acumatica feature that allows users to configure visibility of information on a row-to-row level for each login (according to their role).
Row visibility is configured using *restriction groups*. There are different types of restriction groups, which also differ in their way of handling cross-group relations.
To understand how this works, I suggest you read the official Row-Level Security manual (first 6 chapters are general use; the rest are explaining individual module configurations, which might not be needed in your task).
Types of restriction groups and rules on how they interact with each other are explained in the Types of Restriction Groups chapter. It also has some examples with useful diagrams, which make it easier to understand.
Here’s the checklist of what you need to enable RLS for your DAC:
PX.SM.IRestricted and
PX.SM.IIncludable in your DAC;
PX.SM.BaseAccess;
I’m certain you can handle the last one on your own, so this article will only show an example for the first 3 steps.
> ❗ Don’t forget to change:
> Namespace;
> YourMainDac to your DAC name in method names, comments and queries;
> YourChildDac to your DAC name in method names, comments and queries;
> KeyID to your key field name in generic type declaration, comments and queries;
> ChildKeyID to your child key field name in generic type declaration, comments and queries;
> Screen ID and title.
Change class declaration:
public class YourMainDac : IBqlTable, IRestricted, IIncludable
Add these fields to the DAC:
For IRestricted:
#region GroupMask
///
/// Holds visibility per user role.
/// Used to identify whether logged in user can see the record.
/// Visibility configured at EC102000 YourMainDac Access screen .
///
[PXDBGroupMask]
public virtual byte[] GroupMask { get; set; }
public abstract class groupMask : BqlByteArray.Field { }
#endregion GroupMask
For IIncludable:
#region Included
///
/// An unbound Boolean field that is provided for implementation of the
/// interface, which is a part of the row-level security mechanism of Acumatica.
///
[PXBool]
[PXUIField(DisplayName = "Included")]
[PXUnboundDefault(false, PersistingCheck = PXPersistingCheck.Nothing)]
public virtual bool? Included { get; set; }
public abstract class included : BqlType.Field { }
#endregion Included
Acumatica didn’t provide guideline to setup custom RLS setup, so I used `PX.Objects.AP.APAccess` as an example. I tried to keep the result as clean and minimalistic as possible:
using System;
using System.Collections;
using PX.Data;
using PX.Objects.AR;
using PX.SM;
namespace YourProjectNamespace.RowLevelSecurity
{
///
/// EC102000 YourMainDac Access
///
public class YourMainDacAccess : BaseAccess
{
#region Views
#region YourMainDacView
public PXSelect YourMainDacView;
protected virtual IEnumerable yourMainDacView()
{
if (Group.Current == null || string.IsNullOrEmpty(Group.Current.GroupName))
yield break;
var yourMainDacs = PXSelect>,
Or2>,
Or>>>
.Select(this, new object[] { new byte[0] });
bool inserted = Group.Cache.GetStatus(Group.Current) == PXEntryStatus.Inserted;
if (!inserted)
{
foreach (var item in yourMainDacs)
{
YourMainDacView.Current = item;
yield return item;
}
yield break;
}
foreach (YourMainDac item in yourMainDacs)
{
if (item.GroupMask == null)
continue;
RelationGroup group = Group.Current;
bool anyGroup = false;
for (int i = 0; i < item.GroupMask.Length && i < group.GroupMask.Length; i++)
{
if (group.GroupMask[i] != 0x00 && (item.GroupMask[i] & group.GroupMask[i]) == group.GroupMask[i])
{
YourMainDac.Current = item;
yield return item;
}
anyGroup |= item.GroupMask[i] != 0x00;
}
if (!anyGroup)
{
YourMainDac.Current = item;
yield return item;
}
}
}
#endregion YourMainDacView
#region Groups
protected virtual IEnumerable group()
{
return GroupDelegate(this, true);
}
#endregion Groups
#endregion Views
#region ctor
public YourMainDacAccess()
{
YourMainDacView.Cache.AllowDelete = false;
PXUIFieldAttribute.SetEnabled(YourMainDacView.Cache, null, false);
PXUIFieldAttribute.SetEnabled(YourMainDacView.Cache, null);
PXUIFieldAttribute.SetEnabled(YourMainDacView.Cache, null);
}
#endregion ctor
#region Cache Attached
[PXMergeAttributes(Method = MergeMethod.Merge)]
[RelationGroups(typeof(RelationGroup.groupName), Filterable = true)]
protected virtual void _(Events.CacheAttached e) { }
[PXMergeAttributes(Method = MergeMethod.Append)]
[PXSelector(typeof(Search),
typeof(YourMainDac.recordCD),
typeof(YourMainDac.description))]
protected virtual void _(Events.CacheAttached e) { }
#endregion Cache Attached
#region Events Handlers
protected void _(Events.RowInserted e, PXRowInserted baseMethod)
{
if (!(e.Row is RelationGroup group))
return;
baseMethod?.Invoke(e.Cache, e.Args);
group.SpecificModule = typeof(YourMainDac).Namespace;
}
protected void _(Events.RowSelected e)
{
if (!(e.Row is RelationGroup group))
return;
var hasGroupName = !string.IsNullOrEmpty(group.GroupName);
Save.SetEnabled(hasGroupName);
YourMainDacView.Cache.AllowInsert = hasGroupName;
}
protected virtual void _(Events.FieldDefaulting e)
{
e.Cancel = true;
}
protected virtual void _(Events.FieldUpdated e)
{
if (!(e.Row is YourMainDac row) || e.NewValue is null)
return;
if (!(PXSelectorAttribute.Select(e.Cache, row, e.NewValue) is YourMainDac record))
throw new PXSetPropertyException(ErrorMessages.ElementDoesntExist, e.NewValue);
record.Included = true;
PXCache.RestoreCopy(row, record);
e.Cache.SetStatus(row, PXEntryStatus.Updated);
}
protected virtual void _(Events.RowSelected e)
{
if (!(e.Row is YourMainDac record) || e.Cache.GetStatus(record) != PXEntryStatus.Notchanged)
return;
if (record.GroupMask == null)
{
record.Included = true;
return;
}
foreach (var maskByte in record.GroupMask)
{
if (maskByte == 0x00)
continue;
record.Included = true;
return;
}
}
protected virtual void _(Events.RowPersisting e)
{
if (!(e.Row is YourMainDac record))
return;
if (record.RecordCD is null)
{
e.Cache.Delete(record);
e.Cancel = true;
return;
}
if (e.Operation == PXDBOperation.Insert)
{
e.Cancel = true;
return;
}
RelationGroup group = Group.Current;
if (record.GroupMask == null || group == null || group.GroupMask == null)
return;
if (record.GroupMask.Length < group.GroupMask.Length)
{
byte[] mask = record.GroupMask;
Array.Resize(ref mask, group.GroupMask.Length);
record.GroupMask = mask;
}
for (int i = 0; i < group.GroupMask.Length; i++)
{
if (group.GroupMask[i] == 0x00)
continue;
record.GroupMask[i] = record.Included == true
? (byte)(record.GroupMask[i] | group.GroupMask[i])
: (byte)(record.GroupMask[i] & ~group.GroupMask[i]);
}
}
#endregion Events Handlers
#region Persist
public override void Persist()
{
populateNeighbours(Users);
populateNeighbours(YourMainDac);
populateNeighbours(Users);
base.Persist();
PXSelectorAttribute.ClearGlobalCache();
PXSelectorAttribute.ClearGlobalCache();
}
#endregion Persist
#region Helper methods
public static IEnumerable GroupDelegate(PXGraph graph, bool includeInsertedRecords)
{
PXResultset set = PXSelectGroupBy>,
Aggregate>>>>>.Select(graph);
foreach (RelationGroup group in PXSelect.Select(graph))
{
bool visible = !string.IsNullOrEmpty(group.GroupName) || includeInsertedRecords;
bool relatedToTheModule = group.SpecificModule == null || group.SpecificModule == typeof(YourMainDac).Namespace;
// Each group is assigned to a specific type. If this group's type is a part of the module configured on this screen - show it
bool relatedToModuleTypes = UserAccess.InNeighbours(set, group);
if ((visible && relatedToTheModule) || relatedToModuleTypes)
{
yield return group;
}
}
}
#endregion Helper methods
}
}
RelationGroupsAttribute.cs:
using System;
using System.Collections;
using PX.Data;
namespace YourProjectNamespace.RowLevelSecurity
{
public class RelationGroupsAttribute : PXCustomSelectorAttribute
{
public RelationGroupsAttribute(Type type) : base(type) { }
public virtual IEnumerable GetRecords()
{
return YourMainDacAccess.GroupDelegate(_Graph, false);
}
}
}
using System;
using System.Collections;
using PX.Data;
namespace YourProjectNamespace.RowLevelSecurity
{
public class RelationGroupsAttribute : PXCustomSelectorAttribute
{
public RelationGroupsAttribute(Type type) : base(type) { }
public virtual IEnumerable GetRecords()
{
return YourMainDacAccess.GroupDelegate(_Graph, false);
}
}
}
Self-explanatory, nothing uncommon:
<%@ Page Language="C#" MasterPageFile="~/MasterPages/FormDetail.master" AutoEventWireup="true"
ValidateRequest="false" CodeFile="EC102000.aspx.cs" Inherits="Page_EC102000"
Title="YourMainDac Access" %>
<%@ MasterType VirtualPath="~/MasterPages/FormDetail.master" %>
Acumatica created a
Match
type to apply RLS to the BQL query.
If you only need to filter target DAC (one that has RLS setup), you need to add this type to your query:
Match>
If you need to check the child DAC, you can specify the table to which RLS should be applied:
Where>>
Both versions of
Match<>
are unary operators, so it might be hard to append this operator to the existing
Where
clause.
Easiest trick for that – add it in
And<>
at the end of the
Where<>
clause.
If
Where
is complicated and the first option doesn’t help, try to use
Where2<UnaryOperator, NextOperator>.
In the simplest version, this step doesn’t differ from the “Apply to the view” section.
However, this path will lead you to lots of duplicated code.
Solution – extract join/where adding logic into the separate method. I prefer to use extensions for that:
using PX.Data;
namespace YourProjectNamespace.RowLevelSecurity
{
public static class QueryExtensions
{
///
/// Join to the target query and add filter to WHERE clause to hide records logged in user isn't allowed to see
///
///
/// Set of visible s is determined by the row-level security configuration at EC102000 YourMainDac Access screen.
///
/// Main DAC type in the query
/// Foreign key field from one of the tables in the query, containing key ID value to join by
/// Target query
/// Modified query
public static PXSelectBase HideInvisibleYourMainDacs(this PXSelectBase query)
where Table : class, IBqlTable, new()
where KeyID : class, IBqlField
{
query.Join>>>();
query.WhereAnd>>>();
return query;
}
///
/// Join and to the target query
/// and add filter to WHERE clause to hide records which logged in user isn't allowed to see
///
///
/// Set of visible s is determined by the row-level security configuration at EC102000 YourMainDac Access screen.
/// This extension created for cases when you don't have key ID to join to, but have a DAC with foreign key to the .
/// serves only as a link to the .
/// If you have a key that contains key id, consider using .
///
/// Main DAC type in the query
/// Foreign key field from one of the tables in the query, containing child key ID value to join by
/// Target query
/// Modified query
public static PXSelectBase HideInvisibleYourChildDacs(this PXSelectBase query)
where Table : class, IBqlTable, new()
where ChildKeyID : class, IBqlField
{
query.Join<
InnerJoin>,
InnerJoin>>>>();
query.WhereAnd>>>();
return query;
}
}
}
With these extensions, all you need is to add
.HideInvisibleYourMainDacs<T, T.KeyID>
before
.Select()
to apply RLS.
If necessary, you can also create additional extension methods with more complicated logic.
> For instance, in our project, we needed to make a separate service to identify employees logged in users can see – based on the row-level security configuration for the main DAC, but with consideration of the main DAC properties as well.
> To cover that case, I had to grab an instance of the service via the
Autofac,
retrieve a list of accessible employees from it, and then add
WhereAnd<Where<TBAccountID, In<Required<TBAccountID>>>>().
Dynamic join limitations
If you start a base query without a
Where
clause, any
.Join<T>()
call will be ignored.
Any .Join<T>()
will be ignored completely if the query is built using one of these classes:
-
Any variation of
PXSelectGroupBy;
-
Any variation of
PXSelectGroupByOrderBy;
-
PXSelectJoinGroupBy<Table, Join, Aggregate>
That is, any
PXSelect
type, which is missing either
Join
or
Where
generic type.
Because of that, there’s a chance you try to apply
.WhereAnd<T>()
to a field from a table that should’ve been joined before that via
.Join<T>().
But this join didn’t apply, thus making an invalid SQL query, which will result in the error:
Unknown column 'Table.Field' in 'where clause'
This is related to the implementation of the
Select3 /
Select5 /
Select6
classes that are responsible for internal query configuration.
You can’t fix them, because they are sealed – thus you can’t inherit and override methods.
The option to create a new
PXSelect
with a custom
Select
is dangerous – this code isn’t provided in the
App_Code
folder of the Acumatica instance.
Because of this, you will have to resort to disassembly, which might produce a crooked version of the code.
That might cause you to spend too much time ironing out issues because you will have to reinvent the wheel.
To fix this, you can:
-
Skip dynamically adding joins to a query via
.Join<T>()
completely;
-
Use
PXSelect
types that have both
Where
and
Join;
-
Even if you don’t have a
Where
type you need to use, you can set a placeholder
Where<True, Equal<True>>.
Performance overhead of this additional “filter” will be negligible, and you will have a properly implemented underlying query builder object.
Andrii Tkachenko, Acumatica MVP.
Subscribe To Our Newsletter
Get the latest insights on exponential technologies delivered straight to you
Cookie Consent
We use cookies to improve your experience on our site. By using our site, you consent to cookies.
Cookie Preferences
Manage your cookie preferences below:
Essential cookies enable basic functions and are necessary for the proper function of the website.
NameDescriptionDurationCookie PreferencesThis cookie is used to store the user's cookie consent preferences.30 daysGoogle reCAPTCHA helps protect websites from spam and abuse by verifying user interactions through challenges.
NameDescriptionDuration_GRECAPTCHAGoogle reCAPTCHA sets a necessary cookie (_GRECAPTCHA) when executed for the purpose of providing its risk analysis.179 daysGoogle Tag Manager simplifies the management of marketing tags on your website without code changes.
NameDescriptionDurationcookiePreferencesRegisters cookie preferences of a user2 yearstdRegisters statistical data on users' behaviour on the website. Used for internal analytics by the website operator.sessionStatistics cookies collect information anonymously. This information helps us understand how visitors use our website.
Google Analytics is a powerful tool that tracks and analyzes website traffic for informed marketing decisions.
Service URL: policies.google.com (opens in a new window)
NameDescriptionDuration_gaID used to identify users2 years_gatUsed to monitor number of Google Analytics server requests when using Google Tag Manager1 minute_gidID used to identify users for 24 hours after last activity24 hours_ga_ID used to identify users2 years_galiUsed by Google Analytics to determine which links on a page are being clicked30 seconds__utmxUsed to determine whether a user is included in an A / B or Multivariate test.18 months__utmvContains custom information set by the web developer via the _setCustomVar method in Google Analytics. This cookie is updated every time new data is sent to the Google Analytics server.2 years after last activity__utmzContains information about the traffic source or campaign that directed user to the website. The cookie is set when the GA.js javascript is loaded and updated when data is sent to the Google Anaytics server6 months after last activity__utmcUsed only with old Urchin versions of Google Analytics and not with GA.js. Was used to distinguish between new sessions and visits at the end of a session.End of session (browser)__utmbUsed to distinguish new sessions and visits. This cookie is set when the GA.js javascript library is loaded and there is no existing __utmb cookie. The cookie is updated every time data is sent to the Google Analytics server.30 minutes after last activity_gac_Contains information related to marketing campaigns of the user. These are shared with Google AdWords / Google Ads when the Google Ads and Google Analytics accounts are linked together.90 days__utmtUsed to monitor number of Google Analytics server requests10 minutes__utmaID used to identify users and sessions2 years after last activityClarity is a web analytics service that tracks and reports website traffic.
Service URL: clarity.microsoft.com (opens in a new window)
NameDescriptionDuration_clckPersists the Clarity User ID and preferences, unique to that site is attributed to the same user ID.12 months_clskConnects multiple page views by a user into a single Clarity session recording.12 monthsCLIDIdentifies the first-time Clarity saw this user on any site using Clarity.12 monthsANONCHKIndicates whether MUID is transferred to ANID, a cookie used for advertising. Clarity doesn't use ANID and so this is always set to 0.SessionMarketing cookies are used to follow visitors to websites. The intention is to show ads that are relevant and engaging to the individual user.
LinkedIn Insight is a web analytics service that tracks and reports website traffic.
Service URL: www.linkedin.com (opens in a new window)
NameDescriptionDurationlangUsed to remember language settings.SessionlidcUsed for routing and session management.24 hoursliapUsed for authentication of LinkedIn members.90 daysli_oatmlUsed for identifying LinkedIn Members for advertising and analytics.30 daysli_sugrUsed to make a probabilistic match of a user's identity.90 daysli_fat_idUsed for member indirect identifier for conversion tracking.30 daysYou can find more information in our Cookie Policy and Privacy Policy.