Skip to content

CDS Access Control: Row-Level Authorization with DCL

ABAP SQL performs no authorization check in the database; who may see each row you read is your responsibility. CDS access control moves that check out of the program and next to the data model. How to set it up, where it leaks in layered views, and what it does not cover in RAP.

Mustafa Önder Mustafa Önder  ·  14 October 2026  ·  16 min read

Contents

  1. Why a Separate Authorization Layer?
  2. @AccessControl.authorizationCheck
  3. First Role: Company Code with pfcg_auth
  4. Condition Types and the ?= Operator
  5. Layered Views and Inheritance
  6. RAP: Read in DCL, Modify in the Behavior
  7. WITH PRIVILEGED ACCESS
  8. Where DCL Does Not Apply
  9. How Do I Test a Role?
  10. Common Mistakes
  11. Conclusion

In classic ABAP, authorization checks are a habit: an AUTHORITY-CHECK at the start of the program, and if needed another one for each row in a loop. That works, but the check lives inside the program. If a second program, an OData service or a report reading the same table forgets it, the data is exposed.

CDS access control (DCL, Data Control Language) moves authorization next to the data model: you define a role once for a CDS entity, and every time that entity is read with ABAP SQL, the result is filtered by the user's authorizations. This article covers the mechanism, how to use it in layered views and in RAP, and most importantly where it does not apply. Syntax and behavior are based on SAP's ABAP Keyword Documentation and the official ABAP cheat sheet repository; details can vary by release, so check your own system's documentation too.

Why a Separate Authorization Layer?

ABAP SQL statements trigger no authorization check in the database; a SELECT can reach every row of the table regardless of which user runs the program. Authorization is therefore checked in one of two ways:

AUTHORITY-CHECK compared with CDS access control
AspectAUTHORITY-CHECK (explicit)CDS access control (implicit)
Where is it written?Inside the program, at every place of useOnce per CDS entity, in a separate DCL object
When does it run?When the developer calls itEvery time the entity is read with ABAP SQL
Resultsy-subrc; the program decidesUnauthorized rows are missing from the result; no error is returned
ScopeAny operation (create, update, delete, action)Read only

The last row matters: DCL is a read filter. It says nothing about whether the user may change a record. The two methods do not replace each other; they are used together.

Explain the behavior to users: DCL silently filters unauthorized rows. A user with a missing authorization does not see "not authorized"; they see an empty or incomplete list. Your support team should know to check authorizations first when someone reports "the record isn't there".

@AccessControl.authorizationCheck

Each CDS entity states with this annotation whether it expects a role:

@AccessControl.authorizationCheck values and their meaning
ValueMeaning
#CHECKA role is expected; if none exists, activation shows a warning. If a role exists, it is applied.
#MANDATORYA role is mandatory; a DCL object must exist for the entity.
#NOT_REQUIREDStates that access control is not needed; no warning if no role exists.
#NOT_ALLOWEDNo role may be defined; if one exists, it is ignored.

My recommendation: use #CHECK (or #MANDATORY to make the rule stricter) on every entity exposed to users or external systems, and don't ignore the warning. Keep #NOT_REQUIRED for data that really is public, such as countries, units or status texts. Seeing #NOT_REQUIRED on an entity should prompt the question "why is this data open to everyone?" in code review.

First Role: Company Code with pfcg_auth

Take an expense report table as an example: each report has a company code and a cost center. Users should only see reports in company codes they are authorized for. Authorization lives in our own authorization object ZEXP_BUKRS, made up of the company code (BUKRS) and activity (ACTVT) fields.

Step 1 — A view that expects a role

@AccessControl.authorizationCheck: #CHECK
@EndUserText.label: 'Expense report'
define view entity ZI_ExpenseReport
  as select from zexp_report
{
  key report_uuid  as ReportUUID,
      report_id    as ReportID,
      company_code as CompanyCode,
      cost_center  as CostCenter,
      created_by   as CreatedBy,
      status       as Status,
      @Semantics.amount.currencyCode: 'Currency'
      total_amount as TotalAmount,
      currency     as Currency
}

When you activate it, you get a warning because there is no role yet. That warning is exactly what we want: the entity says it expects a role.

Step 2 — The DCL object (role)

@EndUserText.label: 'Expense report: company code authorization'
@MappingRole: true
define role ZI_ExpenseReport {
  grant select on ZI_ExpenseReport
    where ( CompanyCode ) = aspect pfcg_auth( ZEXP_BUKRS, BUKRS, ACTVT = '03' );
}

What this role says: "When ZI_ExpenseReport is read, return only the rows matching the BUKRS values in the user's ZEXP_BUKRS authorizations that contain ACTVT = '03' (display)." Once the role is active, the warning on the view disappears.

A few rules from the documentation are important for understanding how the role behaves:

  • Mapping is positional. The number of elements on the left must match the number of authorization fields on the right: ( CompanyCode, CostCenter ) = aspect pfcg_auth( ZEXP_AUTH, BUKRS, KOSTL, ACTVT = '03' ).
  • Fixed values are filters. With ACTVT = '03', only authorizations containing that value are considered.
  • Multiple authorizations are joined with "or". If the user has two separate authorizations (one for company code 1000, another for 2000), the two conditions are joined with OR.
  • Full authorization creates no condition. If a field has full authorization (*), no condition is created for it; null values are then accepted too.
  • No authorization means the condition fails. The user sees no rows.

In on-premise systems, the authorization object reaches the user through PFCG roles. In ABAP Cloud (BTP ABAP environment, S/4HANA Cloud), the same authorization object is attached to an IAM app, then to a business catalog and a business role. The DCL role is identical in both worlds; only the way the authorization reaches the user differs.

Condition Types and the ?= Operator

An access condition does not have to consist of pfcg_auth alone. Conditions can be combined with and / or:

  • Literal condition: compares an element with a fixed value (Status <> 'X').
  • PFCG condition: the aspect pfcg_auth above.
  • User condition: compares an element with the name of the logged-on user (CreatedBy = aspect user). Allowed operators are =, <> and ?=.
  • Inheritance condition: takes over another entity's conditions (see below).
  • true / false: a condition that is always or never met.

For example, the rule "everyone with company code authorization sees approved reports, and everyone always sees their own reports" can be written like this:

@EndUserText.label: 'Expense report: company code or own report'
@MappingRole: true
define role ZI_ExpenseReport {
  grant select on ZI_ExpenseReport
    where ( ( CompanyCode ) = aspect pfcg_auth( ZEXP_BUKRS, BUKRS, ACTVT = '03' )
            and Status = 'A' )
       or CreatedBy = aspect user;
}

The ?= operator: use it deliberately

With ?= instead of =, the condition is also met when all elements on the left are null, and according to the documentation this holds even if the user has no authorization at all. It exists so that rows don't vanish entirely when a field comes from an optional association (for example, a cost center not assigned yet). The side effect is obvious: rows with an empty field are visible to everyone. Use ?= not as a habit, but after deciding that "if this field is empty, the row may be public".

Layered Views and Inheritance

The rule newcomers to DCL most often miss: only the role of the entity read directly in ABAP SQL is evaluated. If a view reads from another view, the role of the underlying view is not applied automatically.

In a typical RAP or Fiori stack this means: you wrote a role for ZI_ExpenseReport, but the Fiori app reads the ZC_ExpenseReport projection. If the projection has no role of its own, the underlying role does not filter the data being read. Instead of copying the same condition to every layer, use inheritance:

@EndUserText.label: 'Expense report projection'
@MappingRole: true
define role ZC_ExpenseReport {
  grant select on ZC_ExpenseReport
    where inheriting conditions from entity ZI_ExpenseReport;
}

Inheritance applies the underlying entity's conditions to this entity; when the lower role changes, the upper layer changes with it. A few details from the documentation:

  • If the inherited entity has no conditions yet, you must add default true or default false; otherwise you get a syntax error.
  • With replacing { ... } you can modify the inherited condition. For example, pfcg_filter field ACTVT value '03' with '02' turns an inherited display condition into a change activity; if field names differ, use element ... with ... (transactional projection views only) or root with ... through an association.
  • Inheritance cannot be negated with not, and cyclic inheritance is a syntax error.
  • The @AccessControl.authorizationCheck annotation of the parent entity is ignored by inheritance; the conditions are inherited anyway.

Child entities: conditions via path expressions

The item table has no company code; an item should be visible according to its header's company code. The left side of a PFCG condition can be a path expression instead of an element:

@EndUserText.label: 'Expense item: company code of the header'
@MappingRole: true
define role ZI_ExpenseItem {
  grant select on ZI_ExpenseItem
    where ( _Report.CompanyCode ) = aspect pfcg_auth( ZEXP_BUKRS, BUKRS, ACTVT = '03' );
}

Binding the item to its header instead of building separate authorization logic for each prevents the two lists from showing different results.

RAP: Read in DCL, Modify in the Behavior

In RAP, read operations use CDS access control, which is applied automatically in managed scenarios. But DCL says nothing about create, update, delete or actions. Those are defined with authorization master in the behavior definition and implemented with AUTHORITY-CHECK in the behavior pool:

  • Global authorization (authorization master ( global )): a question independent of the record's state. "May this user delete reports at all?" Without authorization, the request never reaches the delete method.
  • Instance authorization (authorization master ( instance )): a question that depends on the record. "May this user change this report in company code 1000?"
  • Both can be defined together. In draft scenarios the Edit action is treated like an update; check it as well.
METHOD get_global_authorizations.

  IF requested_authorizations-%delete = if_abap_behv=>mk-on.
    AUTHORITY-CHECK OBJECT 'ZEXP_BUKRS'
      ID 'BUKRS' DUMMY
      ID 'ACTVT' FIELD '06'.

    result-%delete = COND #( WHEN sy-subrc = 0
                             THEN if_abap_behv=>auth-allowed
                             ELSE if_abap_behv=>auth-unauthorized ).
  ENDIF.

ENDMETHOD.

BUKRS DUMMY is deliberate here: global authorization doesn't know the record's company code; it only asks "does this user have delete authorization in any company code?". Which company code they may delete in is the job of instance authorization. For a complete draft and authorization example, see the SAP RAP guide and the RAP project example, which also covers child entity authorization.

Read and write should rest on the same authorization. If DCL filters company codes with ACTVT = '03' while instance authorization looks at a different authorization object, a user may change a record they cannot see, or hit a confusing error when opening one they can. Use the same authorization object with different activity values.

WITH PRIVILEGED ACCESS

Sometimes a program needs to read all data regardless of the user's authorization: a background job computing totals across all company codes, or a consistency check. ABAP SQL has an addition for this:

SELECT CompanyCode, SUM( TotalAmount ) AS Total, Currency
  FROM zi_expensereport WITH PRIVILEGED ACCESS
  GROUP BY CompanyCode, Currency
  INTO TABLE @DATA(totals).

According to the documentation, this addition switches CDS access control off for the entity it is written on and overrides all delivered and self-defined roles. Its limits:

  • It only affects the entity it is written on; it does not extend to entities reached through associations.
  • It cannot be combined with a path expression.
  • It has no effect on database tables and classic views; there is no DCL there anyway.

Every use of this addition is a place where responsibility for authorization moves from DCL to code. Searching for WITH PRIVILEGED ACCESS in code review and asking "who is the result shown to?" for each hit is a good habit. If privileged data ends up on a user's screen, you are most likely in the wrong place.

Where DCL Does Not Apply

DCL is bound to a CDS entity and runs when that entity is read with ABAP SQL. Every other path skips the role:

  • Reading the table directly. SELECT ... FROM zexp_report does not trigger DCL; the role is bound to the view, not the table. In application code, read authorization-relevant data from the view with the role, not from the table.
  • An upper layer without a role. The rule described above: the role of the underlying view is not applied when the upper view is read.
  • AMDP and Native SQL. DCL is not evaluated for tables and views read inside SQLScript. A CDS table function, however, can carry its own role when read with ABAP SQL. See the AMDP article for details.
  • Table display tools. Tools that show a table directly apply their own authorization checks, not the CDS role.

How Do I Test a Role?

  • ADT data preview: The preview opened with F8 on a view shows data filtered by the logged-on user's authorizations. Developer users usually have broad authorizations, so open it with a test user with a restricted role too.
  • Comparative counts: Running the same query normally and with WITH PRIVILEGED ACCESS in a test class and comparing row counts shows that the role really filters.
  • Edge cases: a user with no authorization, a user with full authorization (*), rows with an empty field (especially if you used ?=), and a user with two separate authorizations. Don't consider the role done before testing these four.
  • Every layer: Test the role not only on the interface view, but on the projection the app actually reads and through the service.

Common Mistakes

Writing the role only for the lower layer

The Fiori app reads the projection, the role is on the interface view; data reaches the screen unfiltered. Give every layer that is read either its own condition or inheriting conditions from entity.

Mistaking DCL for modify authorization

DCL only filters reads. For update, delete and actions, implement global and instance authorization separately in RAP.

Ignoring the #CHECK warning

The "no role" warning tells you the entity is open to everyone. Switching to #NOT_REQUIRED to silence it hides the problem.

Reading the table in application code

When a view with a role exists, a helper method that reads the same data from the table silently bypasses the role. Enter authorized data through a single door.

Making ?= the default

Every row with an empty field becomes public. Use it only on fields where an empty value really means "anyone may see this".

Showing data to users via WITH PRIVILEGED ACCESS

This addition is for technical processes. If its result goes to a user, you are leaking data unless you check authorization yourself.

Conclusion

CDS access control takes authorization out of the "every programmer must remember" category and makes it part of the data model. But it only works on the path it was built for: an entity with a role, read with ABAP SQL. A role on every layer that is read (usually by inheritance), RAP authorization for changes, and deliberate limits on direct table access and privileged reads are the three complements that make this model truly secure.

For modeling authorization this way under clean core and ABAP Cloud, see the ABAP Cloud and clean core article; for the development scope I offer, see the SAP development services page.

Get support reviewing the authorization model of your CDS views, setting up DCL roles, or aligning read and write authorization in your RAP application.

Request an Initial Consultation
← Back to Blog

Contact

Get in touch for your projects.