Field-Level Security in SAP CAP
Introduction
Here we look at SAP CAP service-level authentication and controlling which fields different users can see.
Project Setup
cds init authorization-demo
cd authorization-demo
npm installData Model
Create db/schema.cds as follows:
namespace com.test;
using { managed } from ‘@sap/cds/common’;
entity Data: managed {
key ID : UUID;
x: Integer;
y: Integer;
}Edit package.json to use Sqlite as follows:
{
...
“cds”: {
“requires”: {
“db”: {
“kind”: “sqlite”,
“credentials”: {
“database”: “db.sqlite”
}
}
}
}
}Service
Create srv/test-service.cds:
using { com.test as db } from ‘../db/schema’;
service TestService @(requires: ‘authenticated-user’) {
entity Data @(restrict: [
{ grant: ‘*’, to: ‘admin’ },
{ grant: ‘READ’, to: ‘authenticated-user’ }
]) as projection on db.Data {
ID,
x,
y
};
}Users
Create .cdsrc.json and add some test users:
{
“requires”: {
“auth”: {
“kind”: “mocked”,
“users”: {
“alice”: {
“password”: “123”,
“roles”: [”admin”]
},
“bob”: {
“password”: “123”,
“roles”: [”authenticated-user”]
}
}
}
}
}Deploy
You can now deploy and run the application as follows:
cds deploy
cds watchTesting Basic Service-Level Authentication
You can now test reading as admin and authenticated-user as follows:
curl -u “alice:123” “http://localhost:4004/odata/v4/test/Data”
curl -u “bob:123” “http://localhost:4004/odata/v4/test/Data”Both should work. However, updating or creating an entity as anything other than admin will not work, for example:
#!/bin/bash
curl -u “bob:123” -X POST http://localhost:4004/odata/v4/test/Data\
-H “Content-Type: application/json” \
-d ‘{
“x”:’$RANDOM’,
“y”:’$RANDOM’
}’This will result in the following error response:
{
“error”: {
“message”: “Forbidden”,
“code”: “403”,
“@Common.numericSeverity”: 4
}
}For reading, notice that the entity will return both the x and y fields. You can deal with leaving out certain fields in different ways.
Separate Entities per Role
The declarative approach can be used to expose two different projections of the same underlying database entity, each restricted to a different role:
service TestService @(requires: ‘authenticated-user’) {
entity DataAdmin @(restrict: [
{ grant: ‘*’, to: ‘admin’ }
]) as projection on db.Data {
ID, x, y
};
entity Data @(restrict: [
{ grant: ‘READ’, to: ‘authenticated-user’ }
]) as projection on db.Data {
ID, x
};
}CAP will enforce access at the framework level automatically for you. The downside is that you now have two OData endpoints (/Data and /DataAdmin), which means your frontend or API clients need to know which one to call based on the logged-in user’s role.
This is the cleaner and more auditable approach, and is recommended when separate endpoints are fine.
Handler-Based Field Stripping
If you want a single /Data endpoint and hide fields for different roles, you can handle that part the handler code.
The service definition is back to the way it was before:
service TestService @(requires: ‘authenticated-user’) {
entity Data @(restrict: [
{ grant: ‘*’, to: ‘admin’ },
{ grant: ‘READ’, to: ‘authenticated-user’ }
]) as projection on db.Data {
ID, x, y
};
}Implement srv/test-service.js as follows
const cds = require(’@sap/cds’);
const LOG = cds.log(”test”);
module.exports = async function() {
const { Data } = this.entities;
const { AdminData } = this.entities;
this.before(’READ’, ‘Data’, (req) => {
if (!req.user.is(’admin’)) {
const cols = req.query.SELECT.columns
if (cols && cols.length === 1 && cols[0] === ‘*’) {
req.query.SELECT.columns = [
{ ref: [’ID’] },
{ ref: [’x’] }
]
} else {
req.query.SELECT.columns = cols.filter(c => c.ref?.[0] !== ‘y’)
}
}
})
}By intercepting in the before hook, y is excluded from the database query entirely.
Here the request user is checked, if it is not admin , for scenarios where all the fields are selected the y column is removed, and for scenarios where specific fields are selected, this is also done.

