From 2574944946722ea186de0c333d2d1af9434debbe Mon Sep 17 00:00:00 2001 From: Manuel Fink Date: Thu, 15 Jan 2026 12:17:12 +0100 Subject: [PATCH 1/3] refactor authorization --- .tours/ams-cap-nodejs-bookshop.tour | 104 ------------------ ams-cap-nodejs-bookshop/.cdsrc.json | 24 ++-- .../ams/dcl/cap/basePolicies.dcl | 21 ++-- .../ams/dcl/internal/apiPolicies.dcl | 12 +- .../ams/dcl/local/adminPolicies.dcl | 19 +--- ams-cap-nodejs-bookshop/ams/dcl/schema.dcl | 9 +- ams-cap-nodejs-bookshop/db/aspects.cds | 6 + ams-cap-nodejs-bookshop/db/schema.cds | 5 +- ams-cap-nodejs-bookshop/package.json | 2 +- ams-cap-nodejs-bookshop/srv/admin-service.cds | 15 ++- ams-cap-nodejs-bookshop/srv/aspects.cds | 10 -- ams-cap-nodejs-bookshop/srv/authorization.cds | 5 + .../srv/authorizations.cds | 9 -- ams-cap-nodejs-bookshop/srv/cat-service.cds | 9 +- ams-cap-nodejs-bookshop/srv/vh-service.cds | 2 +- .../test/cat-service.test.js | 4 +- 16 files changed, 64 insertions(+), 192 deletions(-) delete mode 100644 .tours/ams-cap-nodejs-bookshop.tour create mode 100644 ams-cap-nodejs-bookshop/db/aspects.cds delete mode 100644 ams-cap-nodejs-bookshop/srv/aspects.cds create mode 100644 ams-cap-nodejs-bookshop/srv/authorization.cds delete mode 100644 ams-cap-nodejs-bookshop/srv/authorizations.cds diff --git a/.tours/ams-cap-nodejs-bookshop.tour b/.tours/ams-cap-nodejs-bookshop.tour deleted file mode 100644 index 882711d..0000000 --- a/.tours/ams-cap-nodejs-bookshop.tour +++ /dev/null @@ -1,104 +0,0 @@ -{ - "$schema": "https://aka.ms/codetour-schema", - "title": "AMS CAP Node.js Bookshop Tour", - "steps": [ - { - "file": "ams-cap-nodejs-bookshop/package.json", - "description": "This code tour explains the ams-specific parts of this CAP project which is a plain old bookshop sample.", - "line": 2 - }, - { - "file": "ams-cap-nodejs-bookshop/package.json", - "description": "By running `cds add ams`, the @sap/ams runtime plugin has already been installed.", - "line": 9 - }, - { - "file": "ams-cap-nodejs-bookshop/package.json", - "description": "... and also the `@sap/ams-dev` dev-time plugin which is required to use policies without deploying them to SAP Identity service first, e.g. for unit testing.", - "line": 16 - }, - { - "file": "ams-cap-nodejs-bookshop/package.json", - "description": "As AMS is typically used with SAP Identity Service authentication, `cds add ams` will run `cds add ias` as a pre-step. This adds `@sap/xssec` which is used to validate IAS tokens by the built-in authentication middleware of cds.", - "line": 11 - }, - { - "file": "ams-cap-nodejs-bookshop/srv/admin-service.cds", - "description": "Let's see first how AMS can be used for role-based authorization.\nFor example, the AdminService requires the *admin* role to access any of its entities.", - "line": 2 - }, - { - "file": "ams-cap-nodejs-bookshop/ams/dcl/cap/basePolicies.dcl", - "description": "In AMS, you use authorization policies to assign roles to users.\nFor example, the *admin* policy is a policy that grants this role to any user to which the policy is assigned.\n\nWhen using AMS, the application is deployed together with a set of base policies.\nThe file you see is the default location for the base policies.\nThe AMS plugin generates it automatically, with one base policy for each role required in the cds model", - "line": 11 - }, - { - "file": "ams-cap-nodejs-bookshop/.cdsrc.json", - "description": "The sample uses *mocked* authentication during development and for tests.", - "line": 5 - }, - { - "file": "ams-cap-nodejs-bookshop/.cdsrc.json", - "description": "You can use the *policies* array of each mocked user to assign AMS policies to it.\nMake sure to use fully-qualified policy names, i.e. names that include the DCL package in which the policy is located.\nThe default package for the base policies is *cap* because, by default, they are defined in the file `ams/dcl/cap/basePolicies.dcl`.", - "line": 19 - }, - { - "file": "ams-cap-nodejs-bookshop/test/admin-service.test.js", - "description": "This is an example how to write a test against the server to validate that *dave* has indeed access to the AdminService thanks to the policy assignment.", - "line": 31 - }, - { - "file": "ams-cap-nodejs-bookshop/test/admin-service.test.js", - "description": "On the other hand, *fred* should have no access as the policy is not assigned to him.", - "line": 50 - }, - { - "file": "ams-cap-nodejs-bookshop/ams/dcl/schema.dcl", - "description": "Finally, we will take a look at instance-based authorization via AMS which is the feature where it really shines.\nInstance-based authorization means that access to resources can be controlled in a non-binary way. It means access can be granted but is restricted to *some* entities of a resource, e.g. those created in 2023.\n\nFirst, the authorization model needs to be defined with a set of attributes that can be used to filter the set of entities for which access is granted.\n\nIn AMS, the `schema.dcl` holds this information.\nIt defines a list of attributes and their data type.\nIt lies in the DCL root folder which, by default, is `ams/dcl`." - }, - { - "file": "ams-cap-nodejs-bookshop/test/admin-service.test.js", - "description": "We expect *alice* to also have access even though the *admin* policy is not assigned to her. This is because she is a default mocked user to which CAP already assigns the *admin* role via the *roles* array. You can validate this via `cds env requires.auth.users.alice`.\n\nAs a rule of thumb, while AMS policies can grant roles, roles can still come from other source, too, such as custom authentication middlewares.", - "line": 13 - }, - { - "file": "ams-cap-nodejs-bookshop/ams/dcl/schema.dcl", - "description": "It is possible to nest schema attributes.\n\nThis is useful to group attributes. For example, if it is necessary to filter on the name and description of both books and authors, one could go with four attributes called `authorName`, `authorDescription`, `bookName`, `bookDescription` *or*: one can use nesting to express them as `author.name`, `author.description`, `book.name`, `book.description` which might give a more structured feel.", - "line": 12 - }, - { - "file": "ams-cap-nodejs-bookshop/srv/authorizations.cds", - "description": "Next, we need to map the AMS attributes to elements in the cds model. If a user has been granted only instance-based access to resources, the AMS plugin will generate ad-hoc CQL conditions for those elements that are used by cds to filter the result set.\n\nThe mapping is implemented with the `ams.attributes` annotation which is a key : value map in which the keys are AMS attributes and the values are cds expressions for the target element. An expression is used to denote the cds elements instead of strings to have language server support and prevent typos.", - "line": 6 - }, - { - "file": "ams-cap-nodejs-bookshop/srv/authorizations.cds", - "description": "For example, restrictions on the AMS attribute *description* from the AMS schema should be used to filter the *descr* element of the cds model.\n\nWhile the annotation can also be used on entity level, in this example, we showcase annotating cds elements of a *media* aspect. Theoretically, such an aspect could be shared by additional kinds of media besides books, so that the filters would also be applied when accessing that kind of media and not just books. Aspects are an elegant mechanism to spread AMS annotations to many entities for cross-cutting attributes such as org unit or country code. Typically, common attributes like that are the primary use case for instance-based authorization.", - "line": 4 - }, - { - "file": "ams-cap-nodejs-bookshop/srv/authorizations.cds", - "description": "It is possible to reference cds elements on association paths, too.\n\nFor example, if AMS adds restrictions based on the genre, it can be mapped to the *name* element of the *genre* association, even though it is not a direct element of the entity which is accessed.", - "line": 5 - }, - { - "file": "ams-cap-nodejs-bookshop/ams/dcl/cap/basePolicies.dcl", - "description": "To give users fine-grained access via AMS, the role assignments of the base policies are extended with a `WHERE` condition that contains attributes from the AMS schema.\n\nBy specifying that an attribute `IS NOT RESTRICTED`, the base policy itself still grants access that is not restricted by this attribute. However, administrators of the customer can use it as a base on which they can create additional admin policies at runtime. In such policies, they can freely choose the exact condition over the attribute. We will see an example in the next step.", - "line": 7 - }, - { - "file": "ams-cap-nodejs-bookshop/ams/dcl/local/adminPolicies.dcl", - "description": "This *Zealot* policy is an example admin policy that uses the base policy from the previous step to define a new policy with a custom condition on the *description* attribute.\n\nNote how this file is located in `ams/dcl/local`. The *local* package in DCL has a special semantic. It is meant for DCL files with policies that are only relevant for testing but not for production. Its policies are ignored during the base policy upload, even if they are contained during the upload.\n\nHere, we have created fictitious admin policies inside this package to test whether extensions of base policies work as expected.", - "line": 13 - }, - { - "file": "ams-cap-nodejs-bookshop/test/cat-service.test.js", - "description": "This unit test asserts that user *carol* who has that admin policy assigned, can read the books from the *CatalogService*.\n\nBut in the response, she must see only the one book whose *description* fulfills the attribute filter.", - "line": 69 - }, - { - "title": "Deploying Policies", - "description": "Let's conclude this tour with a discussion about deployment.\n\nSince `@sap/cds-dk 8.6.0`, the availability of `cds add ams` and `cds add ias` has eliminated practically all manual configuration effort for deploying projects that use AMS.\n\nIn particular, an *ams policy deployer application* will be created automatically by the AMS plugin during `cds build` in `gen/policies`. The deployment artefacts created via `cds add mta`, `cds add helm` or `cds add cf-manifest` will automatically contain an entry for it to make sure it gets deployed alongside the application. This way, the authorization policies will be available in the SAP Identity Service administration of your application after deployment." - } - ] -} \ No newline at end of file diff --git a/ams-cap-nodejs-bookshop/.cdsrc.json b/ams-cap-nodejs-bookshop/.cdsrc.json index 6028a79..bb5d2e2 100644 --- a/ams-cap-nodejs-bookshop/.cdsrc.json +++ b/ams-cap-nodejs-bookshop/.cdsrc.json @@ -4,31 +4,29 @@ "[development]": { "kind": "mocked", "users": { - "bob": { + "content-manager": { "policies": [ - "cap.admin", - "cap.Reader" + "cap.ContentManager" ] }, - "carol": { + "stock-manager": { "policies": [ - "local.Zealot" + "cap.StockManager" ] }, - "dave": { + "reader": { "policies": [ - "local.JuniorReader" + "cap.Reader" ] }, - "erin": { + "stock-manager-fiction": { "policies": [ - "local.BestsellerReader" + "local.StockManagerFiction" ] }, - "fred": { + "juniorReader": { "policies": [ - "local.Zealot", - "local.BestsellerReader" + "local.JuniorReader" ] }, "technicalUser": { @@ -49,7 +47,7 @@ }, "amsValueHelp": { "policies": [ - "cap.admin" + "cap.ContentManager" ], "ias_apis": [ "AMS_ValueHelp" diff --git a/ams-cap-nodejs-bookshop/ams/dcl/cap/basePolicies.dcl b/ams-cap-nodejs-bookshop/ams/dcl/cap/basePolicies.dcl index bf6ac81..1cc05b5 100644 --- a/ams-cap-nodejs-bookshop/ams/dcl/cap/basePolicies.dcl +++ b/ams-cap-nodejs-bookshop/ams/dcl/cap/basePolicies.dcl @@ -1,16 +1,17 @@ -POLICY "Reader" { - ASSIGN ROLE "Reader" WHERE - genre IS NOT RESTRICTED AND - description IS NOT RESTRICTED AND - stock IS NOT RESTRICTED; +POLICY StockManager { + ASSIGN ROLE ManageBooks WHERE Genre IS NOT RESTRICTED; } -POLICY Inquisitor { - ASSIGN ROLE "Inquisitor" WHERE - description IS NOT RESTRICTED; +POLICY ContentManager { + ASSIGN ROLE ManageAuthors; + ASSIGN ROLE ManageBooks; + ASSIGN ROLE ValueHelpUser; } -POLICY "admin" { - ASSIGN ROLE "admin"; +POLICY Reader { + ASSIGN ROLE ReadBooks WHERE Genre IS NOT RESTRICTED; } +POLICY "ValueHelpUser" { + ASSIGN ROLE "ValueHelpUser"; +} \ No newline at end of file diff --git a/ams-cap-nodejs-bookshop/ams/dcl/internal/apiPolicies.dcl b/ams-cap-nodejs-bookshop/ams/dcl/internal/apiPolicies.dcl index d323bb2..0b5d42a 100644 --- a/ams-cap-nodejs-bookshop/ams/dcl/internal/apiPolicies.dcl +++ b/ams-cap-nodejs-bookshop/ams/dcl/internal/apiPolicies.dcl @@ -1,10 +1,6 @@ -INTERNAL Policy AMS_ValueHelp { - USE cap.admin; +INTERNAL POLICY AMS_ValueHelp { + ASSIGN ROLE ValueHelpUser; } - -INTERNAL Policy ReadCatalog { - USE cap.Reader RESTRICT - genre IS NOT RESTRICTED, - description IS NOT RESTRICTED, - stock < 30; +INTERNAL POLICY ReadCatalog { + USE cap.Reader RESTRICT Genre NOT IN ('Mystery', 'Romance', 'Thriller', 'Dystopia'); } \ No newline at end of file diff --git a/ams-cap-nodejs-bookshop/ams/dcl/local/adminPolicies.dcl b/ams-cap-nodejs-bookshop/ams/dcl/local/adminPolicies.dcl index 6b66a53..d87331c 100644 --- a/ams-cap-nodejs-bookshop/ams/dcl/local/adminPolicies.dcl +++ b/ams-cap-nodejs-bookshop/ams/dcl/local/adminPolicies.dcl @@ -1,18 +1,7 @@ -POLICY JuniorReader { - USE cap.Reader RESTRICT - genre IN ('Fantasy', 'Fairy Tale', 'Mystery'), - description IS NOT RESTRICTED, - stock IS NOT RESTRICTED; -} - -POLICY BestsellerReader { - USE cap.Reader RESTRICT - genre IS NOT RESTRICTED, - description IS NOT RESTRICTED, - stock < 20; +POLICY StockManagerFiction { + USE cap.StockManager RESTRICT Genre IN ('Mystery', 'Fantasy'); } -POLICY Zealot { - USE cap.Inquisitor RESTRICT - description LIKE '%religious%references%'; +POLICY JuniorReader { + USE cap.Reader RESTRICT Genre IN ('Fairy Tale', 'Fantasy', 'Mystery'); } \ No newline at end of file diff --git a/ams-cap-nodejs-bookshop/ams/dcl/schema.dcl b/ams-cap-nodejs-bookshop/ams/dcl/schema.dcl index b7452d5..83a17a7 100644 --- a/ams-cap-nodejs-bookshop/ams/dcl/schema.dcl +++ b/ams-cap-nodejs-bookshop/ams/dcl/schema.dcl @@ -1,15 +1,8 @@ -// ---------------------------------HEADER_START----------------------------------------------- -// Generated from a CAP model by the SAP AMS Plugin (@sap/ams) 3.3.0 -// hash of generated content: 760f88aa8521b7b516c368a517af58f0a55f37dfd0be60eb07c5d94cc2ea3efe -// ----------------------------------HEADER_END------------------------------------------------ - SCHEMA { @valueHelp: { path: 'Genres', valueField: 'name', labelField: 'name' } - genre: String, - description: String, - stock: Number + Genre: String } \ No newline at end of file diff --git a/ams-cap-nodejs-bookshop/db/aspects.cds b/ams-cap-nodejs-bookshop/db/aspects.cds new file mode 100644 index 0000000..0dc44af --- /dev/null +++ b/ams-cap-nodejs-bookshop/db/aspects.cds @@ -0,0 +1,6 @@ +using { sap.capire.bookshop.Genres } from './schema'; + +aspect media { + genre: Association to Genres; + stock: Integer; +} \ No newline at end of file diff --git a/ams-cap-nodejs-bookshop/db/schema.cds b/ams-cap-nodejs-bookshop/db/schema.cds index ebb04d9..8098d58 100644 --- a/ams-cap-nodejs-bookshop/db/schema.cds +++ b/ams-cap-nodejs-bookshop/db/schema.cds @@ -1,12 +1,13 @@ using { Currency, managed, sap } from '@sap/cds/common'; -using { stocked, media } from '../srv/aspects'; +using { media } from './aspects'; namespace sap.capire.bookshop; -entity Books : managed, media, stocked { +entity Books : managed, media { key ID : Integer; @mandatory title : localized String(111); @mandatory author : Association to Authors; + descr: localized String(1111); price : Decimal; currency : Currency; image : LargeBinary @Core.MediaType : 'image/png'; diff --git a/ams-cap-nodejs-bookshop/package.json b/ams-cap-nodejs-bookshop/package.json index 1c83244..b1d6545 100644 --- a/ams-cap-nodejs-bookshop/package.json +++ b/ams-cap-nodejs-bookshop/package.json @@ -1,6 +1,6 @@ { "name": "ams-cap-nodejs-bookshop", - "version": "2.2.0", + "version": "3.0.0", "description": "A simple CAP project demonstrating the integration of AMS into CAP.", "license": "UNLICENSED", "private": true, diff --git a/ams-cap-nodejs-bookshop/srv/admin-service.cds b/ams-cap-nodejs-bookshop/srv/admin-service.cds index 770cee2..5605361 100644 --- a/ams-cap-nodejs-bookshop/srv/admin-service.cds +++ b/ams-cap-nodejs-bookshop/srv/admin-service.cds @@ -1,5 +1,14 @@ -using { sap.capire.bookshop as my } from '../db/schema'; -service AdminService @(requires:'admin', path: '/admin') { +using {sap.capire.bookshop as my} from '../db/schema'; + +service AdminService @(path: '/admin', requires: ['ManageAuthors', 'ManageBooks']) { + + @(restrict: [ + { grant: ['READ'], to: 'ManageAuthors' }, + { grant: ['READ', 'WRITE'], to: 'ManageBooks' } ]) entity Books as projection on my.Books; + + @(restrict: [ + { grant: ['READ', 'WRITE'], to: 'ManageAuthors' }, + { grant: ['READ'], to: 'ManageBooks' } ]) entity Authors as projection on my.Authors; -} +} \ No newline at end of file diff --git a/ams-cap-nodejs-bookshop/srv/aspects.cds b/ams-cap-nodejs-bookshop/srv/aspects.cds deleted file mode 100644 index 20d64bf..0000000 --- a/ams-cap-nodejs-bookshop/srv/aspects.cds +++ /dev/null @@ -1,10 +0,0 @@ -using { sap.capire.bookshop.Genres } from '../db/schema'; - -aspect stocked { - stock: Integer; -} - -aspect media { - genre: Association to Genres; - descr: localized String(1111); -} \ No newline at end of file diff --git a/ams-cap-nodejs-bookshop/srv/authorization.cds b/ams-cap-nodejs-bookshop/srv/authorization.cds new file mode 100644 index 0000000..ce1f487 --- /dev/null +++ b/ams-cap-nodejs-bookshop/srv/authorization.cds @@ -0,0 +1,5 @@ +using { media } from '../db/aspects'; + +annotate media with @ams.attributes: { + Genre: (genre.name) +}; \ No newline at end of file diff --git a/ams-cap-nodejs-bookshop/srv/authorizations.cds b/ams-cap-nodejs-bookshop/srv/authorizations.cds deleted file mode 100644 index 13a26ba..0000000 --- a/ams-cap-nodejs-bookshop/srv/authorizations.cds +++ /dev/null @@ -1,9 +0,0 @@ -using { stocked, media } from './aspects'; - -annotate media with @ams.attributes: { - description: (descr), - genre: (genre.name) -}; -annotate stocked with @ams.attributes: { - stock: (stock) -}; \ No newline at end of file diff --git a/ams-cap-nodejs-bookshop/srv/cat-service.cds b/ams-cap-nodejs-bookshop/srv/cat-service.cds index 97eb9d9..f86c953 100644 --- a/ams-cap-nodejs-bookshop/srv/cat-service.cds +++ b/ams-cap-nodejs-bookshop/srv/cat-service.cds @@ -3,21 +3,18 @@ using { sap.capire.bookshop as my } from '../db/schema'; service CatalogService { /** For displaying lists of Books */ - @ams.attributes: { - description: null - } @readonly entity ListOfBooks as projection on Books excluding { descr }; /** For display in details pages */ - @restrict: [{ grant:['READ'], to: ['Reader', 'Inquisitor'], where: 'stock > 0' }] + @restrict: [{ grant:['READ'], to: ['ReadBooks'], where: 'stock > 0' }] entity Books as projection on my.Books { *, author.name as author } excluding { createdBy, modifiedBy } actions { - @restrict: [{ to: ['Reader'] }] + @restrict: [{ to: ['ReadBooks'] }] function getStockedValue(book: $self) returns Decimal; - @restrict: [{ to: ['Reader'] }] + @restrict: [{ to: ['ReadBooks'] }] function getTotalStockedValue(books: many $self) returns Decimal; }; diff --git a/ams-cap-nodejs-bookshop/srv/vh-service.cds b/ams-cap-nodejs-bookshop/srv/vh-service.cds index ba4db76..814cb01 100644 --- a/ams-cap-nodejs-bookshop/srv/vh-service.cds +++ b/ams-cap-nodejs-bookshop/srv/vh-service.cds @@ -1,6 +1,6 @@ using { sap.capire.bookshop as my } from '../db/schema'; -service AmsValueHelpService @(requires: 'admin') { +service AmsValueHelpService @(requires: 'ValueHelpUser') { @cds.localized: false entity Genres as projection on my.Genres; } \ No newline at end of file diff --git a/ams-cap-nodejs-bookshop/test/cat-service.test.js b/ams-cap-nodejs-bookshop/test/cat-service.test.js index 0588d48..62f7238 100644 --- a/ams-cap-nodejs-bookshop/test/cat-service.test.js +++ b/ams-cap-nodejs-bookshop/test/cat-service.test.js @@ -3,7 +3,7 @@ const cds = require('@sap/cds') describe('CatalogService', () => { const { GET, axios } = cds.test() - describe('called by alice (no Reader)', () => { + describe('called by alice (no ReadBooks)', () => { beforeAll(() => { axios.defaults.auth = { username: 'alice', password: '' } }) @@ -23,7 +23,7 @@ describe('CatalogService', () => { }) }) - describe('called by bob (cap.Reader policy assigned)', () => { + describe('called by bob (cap.ReadBooks policy assigned)', () => { beforeAll(() => { axios.defaults.auth = { username: 'bob', password: '' } }) From fbcde6f9d62ab3e53f3f38866f4e7cec26febc8e Mon Sep 17 00:00:00 2001 From: Manuel Fink Date: Thu, 15 Jan 2026 16:50:14 +0100 Subject: [PATCH 2/3] adapt tests --- .../test/admin-service.test.js | 39 +++-- .../test/cat-service.test.js | 145 +++--------------- .../test/vh-service.test.js | 18 ++- 3 files changed, 67 insertions(+), 135 deletions(-) diff --git a/ams-cap-nodejs-bookshop/test/admin-service.test.js b/ams-cap-nodejs-bookshop/test/admin-service.test.js index 38732fb..a1bd53d 100644 --- a/ams-cap-nodejs-bookshop/test/admin-service.test.js +++ b/ams-cap-nodejs-bookshop/test/admin-service.test.js @@ -3,12 +3,12 @@ const cds = require('@sap/cds') describe('AdminService', () => { const { GET, axios } = cds.test() - describe('called by alice (admin role mocked directly but not via policy)', () => { + describe('called by content-manager (cap.ContentManager policy assigned)', () => { beforeAll(() => { - axios.defaults.auth = { username: 'alice', password: '' } + axios.defaults.auth = { username: 'content-manager', password: '' } }) - it('/Books should return all Books because the admin role is not supplied via AMS, so the AMS attribute filters do not apply.', async () => { + it('/Books should return all Books', async () => { const { status, data } = await GET `/admin/Books` expect(status).toBe(200) expect(data.value?.length).toBe(5) @@ -21,9 +21,9 @@ describe('AdminService', () => { }) }) - describe('called by bob (cap.admin policy assigned)', () => { + describe('called by stock-manager (cap.StockManager policy assigned)', () => { beforeAll(() => { - axios.defaults.auth = { username: 'bob', password: '' } + axios.defaults.auth = { username: 'stock-manager', password: '' } }) it('/Books should return all Books', async () => { @@ -37,11 +37,32 @@ describe('AdminService', () => { expect(status).toBe(200) expect(data.value?.length).toBe(4) }) - }) + }) + + describe('called by stock-manager-fiction', () => { + beforeAll(() => { + axios.defaults.auth = { username: 'stock-manager-fiction', password: '' } + }) + + it('/Books should return 3 Books filtered by Mystery and Fantasy genres', async () => { + const { status, data } = await GET `/admin/Books` + expect(status).toBe(200) + expect(data.value?.length).toBe(3) + expect(data.value).toContainEqual(expect.objectContaining({ title: 'Catweazle' })) + expect(data.value).toContainEqual(expect.objectContaining({ title: 'The Raven' })) + expect(data.value).toContainEqual(expect.objectContaining({ title: 'Eleonora' })) + }) + + it('/Authors should return all Authors', async () => { + const { status, data } = await GET `/admin/Authors` + expect(status).toBe(200) + expect(data.value?.length).toBe(4) + }) + }) - describe('called by fred (no admin)', () => { + describe('called by reader (no ManageAuthors or ManageBooks)', () => { beforeAll(() => { - axios.defaults.auth = { username: 'fred', password: '' } + axios.defaults.auth = { username: 'reader', password: '' } }) it('/Books should return status 403', async () => { @@ -59,4 +80,4 @@ describe('AdminService', () => { }) }) -}) \ No newline at end of file +}) diff --git a/ams-cap-nodejs-bookshop/test/cat-service.test.js b/ams-cap-nodejs-bookshop/test/cat-service.test.js index 62f7238..fe693ae 100644 --- a/ams-cap-nodejs-bookshop/test/cat-service.test.js +++ b/ams-cap-nodejs-bookshop/test/cat-service.test.js @@ -3,29 +3,9 @@ const cds = require('@sap/cds') describe('CatalogService', () => { const { GET, axios } = cds.test() - describe('called by alice (no ReadBooks)', () => { + describe('called by reader (cap.Reader policy assigned)', () => { beforeAll(() => { - axios.defaults.auth = { username: 'alice', password: '' } - }) - - it('/Books should return status 403', async () => { - expect.assertions(1); - return (GET`/odata/v4/catalog/Books`).catch(error => { - expect(error.response.status).toBe(403) - }) - }) - - it('/ListOfBooks should return status 403', async () => { - expect.assertions(1); - return (GET`/odata/v4/catalog/ListOfBooks`).catch(error => { - expect(error.response.status).toBe(403) - }) - }) - }) - - describe('called by bob (cap.ReadBooks policy assigned)', () => { - beforeAll(() => { - axios.defaults.auth = { username: 'bob', password: '' } + axios.defaults.auth = { username: 'reader', password: '' } }) it('/Books should return all Books', async () => { @@ -53,41 +33,16 @@ describe('CatalogService', () => { }) }) - describe('called by carol (cap.Zealot policy assigned)', () => { + describe('called by juniorReader (local.JuniorReader policy assigned)', () => { beforeAll(() => { - axios.defaults.auth = { username: 'carol', password: '' } + axios.defaults.auth = { username: 'juniorReader', password: '' } }) /** - * - access to The Raven is granted as its description contains religious references - */ - it('/Books should return 1 Book (The Raven)', async () => { - const { status, data } = await GET`/odata/v4/catalog/Books` - expect(status).toBe(200) - expect(data.value?.length).toBe(1) - expect(data.value[0].title).toEqual('The Raven') - }) - - /** - * On /ListOfBooks, the AMS description attribute is not mapped to a cds element, so access to The Raven is forbidden - */ - it('/ListOfBooks should return 403 because the AMS description attribute is not mapped to a cds element', async () => { - expect.hasAssertions(); - return (GET`/odata/v4/catalog/ListOfBooks`).catch(error => { - expect(error.response.status).toBe(403) - }); - }) - }) - - describe('called by dave (cap.JuniorReader policy assigned)', () => { - beforeAll(() => { - axios.defaults.auth = { username: 'dave', password: '' } - }) - - /** - * The JuniorReader policy adds attribute filters for genre to the query: + * The JuniorReader policy restricts to Fairy Tale, Fantasy, and Mystery genres: * - access to Catweazle is granted as its genre is Fantasy - * - access to The Raven, Eleonora is granted as their genre is Mystery + * - access to The Raven and Eleonora is granted as their genre is Mystery + * - Fairy Tale books would also be included but there are none in the data */ it('/Books should return 3 Books (Catweazle, The Raven, Eleonora)', async () => { const { status, data } = await GET`/odata/v4/catalog/Books` @@ -98,7 +53,7 @@ describe('CatalogService', () => { expect(data.value).toContainEqual(expect.objectContaining({ title: 'Eleonora' })) }) - // Book 201 = Wuthering Heights + // Book 201 = Wuthering Heights (Drama genre - not allowed) it('/Books/201/getStockedValue() should be forbidden', async () => { expect.hasAssertions(); return (GET`/odata/v4/catalog/Books/201/getStockedValue()`).catch(error => { @@ -106,7 +61,7 @@ describe('CatalogService', () => { }) }) - // Book 271 = Catweazle + // Book 271 = Catweazle (Fantasy genre - allowed) it('/Books/271/getStockedValue() should return 3300', async () => { const { status, data } = await GET`/odata/v4/catalog/Books/271/getStockedValue()` expect(status).toBe(200) @@ -122,72 +77,16 @@ describe('CatalogService', () => { }) }) - describe('called by erin (BestsellerReader)', () => { - beforeAll(() => { - axios.defaults.auth = { username: 'erin', password: '' } - }) - - it('/Books should return 2 Books (Wuthering Heights, Jane Eyre)', async () => { - const { status, data } = await GET`/odata/v4/catalog/Books` - expect(status).toBe(200) - expect(data.value?.length).toBe(2) - const bookTitles = data.value.map(book => book.title) - expect(bookTitles).toContain('Wuthering Heights') - expect(bookTitles).toContain('Jane Eyre') - }) - - it('/ListOfBooks should return 2 Books (Wuthering Heights, Jane Eyre)', async () => { - const { status, data } = await GET`/odata/v4/catalog/ListOfBooks` - expect(status).toBe(200) - expect(data.value?.length).toBe(2) - const bookTitles = data.value.map(book => book.title) - expect(bookTitles).toContain('Wuthering Heights') - expect(bookTitles).toContain('Jane Eyre') - }) - }) - - describe('called by fred (Zealot, BestsellerReader)', () => { - beforeAll(() => { - axios.defaults.auth = { username: 'fred', password: '' } - }) - - /** - * Combination of two policies with different attribute filters should yield union of both result sets: - * - via Zealot policy access to The Raven is granted as its description contains religious references - * - via BestsellerReader policy access to Wuthering Heights and Jane Eyre is granted as they are low on stock - */ - it('/Books should return 3 Books (Wuthering Heights, Jane Eyre, The Raven)', async () => { - const { status, data } = await GET`/odata/v4/catalog/Books` - expect(status).toBe(200) - expect(data.value?.length).toBe(3) - const bookTitles = data.value.map(book => book.title) - expect(bookTitles).toContain('Wuthering Heights') - expect(bookTitles).toContain('Jane Eyre') - expect(bookTitles).toContain('The Raven') - }) - - /** - * On /ListOfBooks, the AMS description attribute is not mapped to a cds element, so access via the Zealot policy is forbidden - */ - it('/ListOfBooks should return 2 Books (Wuthering Heights, Jane Eyre)', async () => { - const { status, data } = await GET`/odata/v4/catalog/ListOfBooks` - expect(status).toBe(200) - expect(data.value?.length).toBe(2) - const bookTitles = data.value.map(book => book.title) - expect(bookTitles).toContain('Wuthering Heights') - expect(bookTitles).toContain('Jane Eyre') - }) - }) - describe('called by technicalUser (calling ReadCatalog API policy)', () => { beforeAll(() => { axios.defaults.auth = { username: 'technicalUser', password: '' } }) /** - * The ReadCatalog API policy grants access to books with stock < 30 + * The ReadCatalog API policy restricts to genres NOT IN (Mystery, Romance, Thriller, Dystopia) + * The remaining list includes Drama (Wuthering Heights, Jane Eyre) and Fantasy (Catweazle) */ - it('/Books should return 3 Books with stock < 30 (Catweazle, Wuthering Heights, Jane Eyre)', async () => { + it('/Books should return 3 Books (Catweazle, Wuthering Heights, Jane Eyre)', async () => { const { status, data } = await GET`/odata/v4/catalog/Books` expect(status).toBe(200) expect(data.value?.length).toBe(3) @@ -196,7 +95,7 @@ describe('CatalogService', () => { expect(data.value).toContainEqual(expect.objectContaining({ title: 'Jane Eyre' })) }) - // Book 252 = Eleonora + // Book 252 = Eleonora (Mystery genre - forbidden by ReadCatalog policy) it('/Books/252/getStockedValue() should be forbidden', async () => { expect.hasAssertions(); return (GET`/odata/v4/catalog/Books/252/getStockedValue()`).catch(error => { @@ -204,23 +103,23 @@ describe('CatalogService', () => { }) }) - // Book 271 = Catweazle - it('/Books/271/getStockedValue() should return 7770', async () => { + // Book 271 = Catweazle (Fantasy genre - allowed by ReadCatalog policy) + it('/Books/271/getStockedValue() should return 3300', async () => { const { status, data } = await GET`/odata/v4/catalog/Books/271/getStockedValue()` expect(status).toBe(200) expect(data.value).toBe(3300) }) }) - describe('called by principalPropagation (cap.JuniorReader policy limited to ReadCatalog API policy)', () => { + describe('called by principalPropagation (local.JuniorReader policy limited to ReadCatalog API policy)', () => { beforeAll(() => { axios.defaults.auth = { username: 'principalPropagation', password: '' } }) /** - * The JuniorReader policy adds attribute filters for genre to the query: - * - access to Catweazle is granted as its genre is Fantasy - * - access to The Raven and Eleonora is granted as their genre is Mystery but filtered out by stock < 30 from ReadCatalog policy + * The JuniorReader policy restricts to Fairy Tale, Fantasy, and Mystery genres + * The ReadCatalog API policy restricts to genres NOT IN (Mystery, Romance, Thriller, Dystopia) + * The intersection yields only Fantasy genre books (Mystery is excluded by ReadCatalog), which is Catweazle */ it('/Books should return 1 Book (Catweazle)', async () => { const { status, data } = await GET`/odata/v4/catalog/Books` @@ -229,7 +128,7 @@ describe('CatalogService', () => { expect(data.value).toContainEqual(expect.objectContaining({ title: 'Catweazle' })) }) - // Book 252 = Eleonora + // Book 252 = Eleonora (Mystery genre - allowed by JuniorReader but forbidden by ReadCatalog API policy) it('/Books/252/getStockedValue() should be forbidden', async () => { expect.hasAssertions(); return (GET`/odata/v4/catalog/Books/252/getStockedValue()`).catch(error => { @@ -237,8 +136,8 @@ describe('CatalogService', () => { }) }) - // Book 271 = Catweazle - it('/Books/271/getStockedValue() should return 7770', async () => { + // Book 271 = Catweazle (Fantasy genre - allowed by both policies) + it('/Books/271/getStockedValue() should return 3300', async () => { const { status, data } = await GET`/odata/v4/catalog/Books/271/getStockedValue()` expect(status).toBe(200) expect(data.value).toBe(3300) diff --git a/ams-cap-nodejs-bookshop/test/vh-service.test.js b/ams-cap-nodejs-bookshop/test/vh-service.test.js index 494d883..7f30d46 100644 --- a/ams-cap-nodejs-bookshop/test/vh-service.test.js +++ b/ams-cap-nodejs-bookshop/test/vh-service.test.js @@ -3,9 +3,9 @@ const cds = require('@sap/cds') describe('AmsValueHelpService', () => { const { GET, axios } = cds.test() - describe('called by fred (no admin)', () => { + describe('called by reader (no ValueHelpUser)', () => { beforeAll(() => { - axios.defaults.auth = { username: 'fred', password: '' } + axios.defaults.auth = { username: 'reader', password: '' } }) it('/Genres should return status 403', async () => { @@ -16,7 +16,19 @@ describe('AmsValueHelpService', () => { }) }) - describe('called by amsValueHelp (admin)', () => { + describe('called by content-manager (cap.ContentManager policy grants ValueHelpUser)', () => { + beforeAll(() => { + axios.defaults.auth = { username: 'content-manager', password: '' } + }) + + it('/Genres should return all Genres', async () => { + const { status, data } = await GET`/odata/v4/ams-value-help/Genres` + expect(status).toBe(200) + expect(data.value?.length).toBe(15) + }) + }) + + describe('called by amsValueHelp (internal AMS_ValueHelp API policy grants ValueHelpUser)', () => { beforeAll(() => { axios.defaults.auth = { username: 'amsValueHelp', password: '' } }) From c4cb78f101d98954e8cd375969a5b84b974191ef Mon Sep 17 00:00:00 2001 From: Manuel Fink Date: Thu, 15 Jan 2026 16:58:29 +0100 Subject: [PATCH 3/3] update authorization model --- ams-cap-nodejs-bookshop/README.md | 38 +++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/ams-cap-nodejs-bookshop/README.md b/ams-cap-nodejs-bookshop/README.md index d4e6e47..cab0793 100644 --- a/ams-cap-nodejs-bookshop/README.md +++ b/ams-cap-nodejs-bookshop/README.md @@ -18,7 +18,9 @@ Refer to the [@sap/ams CAP integration guide](https://www.npmjs.com/package/@sap - Authentication via SCI (`auth.kind = "ias"`) - Authorization via AMS - [Role](https://cap.cloud.sap/docs/guides/security/authorization#roles) Assignments + - Instance-based authorization with genre-based attribute filters - Advanced filter conditions that exceed the capabilities of the standard [instance-based cds conditions](https://cap.cloud.sap/docs/guides/security/authorization#instance-based-auth) + - Principal propagation with API policies that further restrict access - [Hybrid testing](https://cap.cloud.sap/docs/advanced/hybrid-testing) via `cds bind` - Auto-Configuration for deployment via [cds add](https://cap.cloud.sap/docs/tools/cds-cli#cds-add) @@ -43,24 +45,36 @@ DEBUG=ams cds watch # requires @sap/cds-dk installed globally ## Testing Locally ### Integration tests -You can look at the integration tests for the [`CatalogService`](./test/cat-service.test.js) and the [`AdminService`](./test/admin-service.test.js). They demonstrate the expected behavior of the application for mocked users with different combinations of authorization policies. +You can look at the integration tests for the [`CatalogService`](./test/cat-service.test.js), [`AdminService`](./test/admin-service.test.js), and [`AmsValueHelpService`](./test/vh-service.test.js). They demonstrate the expected behavior of the application for mocked users with different combinations of authorization policies. + +Advanced AMS capabilities includes in the tests: +- **Filtered access**: The `juniorReader` can only access books in specific genres (Fairy Tale, Fantasy, Mystery) +- **Principal propagation**: The `principalPropagation` user demonstrates how API policies can further restrict access for user requests from external applications - combining JuniorReader policy (allows Fairy Tale, Fantasy, Mystery) of user with ReadCatalog API policy (excludes Mystery, Romance, Thriller, Dystopia) results in access to only Fantasy books ### Manual tests -To test the effect of changes, you can make manual requests against the server by authenticating via *Basic Auth* as one of the mocked users (and an empty password). In the [cds env configuration](./.cdsrc.json#L4), you can see and change the list of policies that is assigned to the mocked users. +To test the effect of changes, you can make manual requests against the server by authenticating via *Basic Auth* as one of the mocked users (and an empty password). In the [cds env configuration](./.cdsrc.json), you can see and change the list of policies that is assigned to the mocked users. If the application was started in watch mode, get creative by making changes and observe the effects. Here's some ideas: #### Create your own admin policy -- create a new admin policy in [adminPolicies.dcl](./ams/dcl/local/adminPolicies.dcl) with a different filter condition -- assign the policy to a user via the [cds env configuration](./.cdsrc.json#L4) -- validate it works as intended - - make a request to an entity that is filtered base on the attributes of the policy - - extend the unit tests - -#### Extend the AMS annotations -- add more attribute filters via `ams.attributes` annotations -- extend the role policies in [basePolicies.dcl](./ams/dcl/cap/basePolicies.dcl) if the new AMS attribute is applicable for the existing roles -- [create an admin policy](#create-your-own-admin-policy) that filters a role based on this attribute +- Create a new policy in [adminPolicies.dcl](./ams/dcl/local/adminPolicies.dcl) with a different genre filter condition +- Assign the policy to a user via the [cds env configuration](./.cdsrc.json) +- Validate it works as intended + - Make a request to `/odata/v4/catalog/Books` as that user + - Verify only books matching the genre filter are returned + - Extend the unit tests to cover the new scenario + +#### Extend the AMS attributes +- Add more attribute filters via `@ams.attributes` annotations in [authorization.cds](./srv/authorization.cds) + - Example: Add author-based filtering by mapping `Author: author.name` +- Extend the role policies in [basePolicies.dcl](./ams/dcl/cap/basePolicies.dcl) to additionally filter by the new attribute where it makes sense +- [Create a new admin policy](#create-your-own-policy) that filters a role based on this attribute +- Extend the [vh-service.js](./srv/vh-service.js) to return possible values for the new attribute + +#### Test principal propagation +- Observe how the `principalPropagation` user's access is restricted by both the JuniorReader policy AND the ReadCatalog API policy +- Try creating different combinations of user policies and API policies to see how they interact +- The intersection of filters determines the final access rights ## Hybrid Testing For [CAP Hybrid Testing](https://cap.cloud.sap/docs/advanced/hybrid-testing), you can `cds bind -2 ` and start the application via