Acumatica · Security

Row-Level Security with Acumatica Graph Extensions

Restriction groups cover the common cases well, but they only apply to the fixed set of base restrictable entities and whatever custom DACs you've wired up with PXRestrictor .

John Kihiu12 min read

Restriction groups cover the common cases well, but they only apply to the fixed set of base restrictable entities and whatever custom DACs you've wired up with PXRestrictor. Real projects regularly need row-level rules that don't map cleanly onto "which restriction group owns this row" - approval-based visibility, ownership-based visibility, or a rule that depends on a field value rather than a static group assignment. That's when the work moves from configuration into a graph extension, and it's worth understanding exactly where in the pipeline to hook it.

Filtering the data view itself, not the grid after the fact

The wrong place to enforce row visibility is a client-side or RowSelected-level trick that hides rows from the UI after they've already been loaded - the data is still in the response payload, still visible to anyone inspecting network traffic or hitting the same entity through a GI or the REST API. Real row-level security has to happen in the query, in the graph. On a project last year, a client needed project managers to see only projects where they were listed as the assigned PM, and no restriction group model fit because the "owner" was a field on the row itself, not a static group:

C# · View-level BQL restriction
public class ProjectEntry_Extension : PXGraphExtension<ProjectEntry>
{
    public static bool IsActive() => true;

    [PXOverride]
    public IEnumerable projects(PXAdapter adapter, Func<PXAdapter, IEnumerable> baseMethod)
    {
        if (IsUnrestrictedRole(Base.Accessinfo.UserName))
            return baseMethod(adapter);

        adapter.Filters.Add(new Filter
        {
            Field = typeof(PMProject.responsibleID),
            Op = PXCompareOp.EQ,
            Value = Base.Accessinfo.ContactID
        });
        return baseMethod(adapter);
    }

    private bool IsUnrestrictedRole(string userName) =>
        PXAccess.GetUserRoles(userName).Contains("ProjectAdministrators");
}

Filtering at the adapter level keeps the restriction inside the same query the base graph runs, rather than post-processing a result set that already contains rows the user shouldn't have received.

Don't confuse hiding fields with hiding rows

A pattern I still see on client instances: a RowSelected handler that blanks out sensitive field values for unauthorized users while leaving the row itself fully visible in the grid. That's a legitimate technique for field-level masking, but it is not row-level security, and treating it as equivalent is a real gap - the row's existence, its key fields, and anything not explicitly blanked are still exposed. If the requirement is "this user should not know this record exists," field masking in RowSelected cannot deliver that; only filtering the underlying query can.

Don't forget the API and GI surfaces

A graph-level override on the primary screen's data view does nothing for a Generic Inquiry built directly over the same DAC, or a REST API call against the same entity through a different endpoint. If the visibility rule is a genuine security boundary rather than a screen-specific convenience, it needs to live somewhere every access path respects - a PXRestrictor on the DAC itself, or a restriction group, both of which BQL honors regardless of entry point. Reserve the graph-extension approach for rules that are legitimately screen-specific.

Testing this properly means testing as the restricted user, twice

Test both directions explicitly: log in as a restricted user and confirm the rows they shouldn't see are absent from the grid, the selector, and a direct URL to the record's edit screen (a user navigating straight to a specific record's edit URL bypasses the list filter entirely if the restriction isn't also enforced on the single-record data view, not just the multi-row one). I've caught this exact gap during a security review: list view correctly filtered, direct-navigation to a specific known key was not, because only the grid-returning method had been overridden.

Wrapping up

Restriction groups handle the common ownership-by-group case well; anything based on a dynamic field value, an approval state, or per-record ownership usually needs a graph-level query override instead. Filter at the adapter or view level so the restriction lives inside the actual query, keep field masking and row hiding conceptually separate, and verify the rule holds on every access path - list, selector, direct navigation, GI, and API - not just the screen you built it for.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.