Where CAP's Authorization Annotations Stop Protecting You

CAP's @requires and @restrict annotations are genuinely good. Slap @restrict on an entity with a role based condition and the generated OData service enforces it on every standard CRUD operation without you writing a line of handler code. It's one of the things CAP gets right that takes considerably more plumbing to achieve in plenty of other frameworks. The part that catches people is assuming that protection automatically extends everywhere a request's data ends up, including inside your own custom handlers.

I ran into this building a custom action on a CAP service that needed to look up related records from a second service before deciding what the action was allowed to do. The straightforward way to write that lookup is cds.connect.to('OtherService') and query it. That works, returns data, looks completely normal in testing if you're testing as an admin user who's allowed to see everything anyway. The problem only shows up when a restricted user calls the same action, because that connection, on its own, doesn't automatically carry the calling user's restrictions into the second service. You can end up querying with effectively unrestricted access from inside a handler that's nominally protected at the entity level.

The transaction is where the user context actually lives

req.user exists on the inbound request. The @restrict checks the framework runs automatically are tied to that request's transaction. If your handler spins up a separate connection or transaction without explicitly threading that context through, you've stepped outside the boundary CAP was enforcing for you, and now you're responsible for re-implementing whatever check you needed by hand, or making sure the call inherits the right context.

this.on('approveRequest', async (req) => {
  const tx = srv.tx(req); // ties this transaction to req.user
  const related = await tx.run(
    SELECT.from('OtherEntity').where({ id: req.data.relatedId })
  );
  // this query now runs under the same authorization context
  // as the original request, not a fresh privileged one
});

The fix is one method call, srv.tx(req) instead of a bare connection, but you only think to write it if you already know the gap exists. Nothing about the unsafe version throws an error or even a warning. It just quietly runs with broader access than the calling user should have had.

Batch jobs and background processing make this worse

Anything triggered outside a live HTTP request, a scheduled job, a queue consumer, an event handler reacting to a message from another service, doesn't have a req.user at all unless you construct one deliberately. CAP will happily let that code run with whatever default privilege level your service connection has, which in most local and even some production setups defaults to something close to unrestricted. If a background process touches the same entities your @restrict annotations were meant to protect, those annotations are doing nothing for that code path, because there was never a restricted user context to enforce in the first place.

The practical habit that prevents this: every time you write a handler that calls another service, queries the database directly, or runs outside the standard generated CRUD flow, ask explicitly whose authorization context this is running under, and write that down in a comment if the answer isn't obvious from the code. @restrict annotations describe what's allowed for a request coming through the standard service surface. They say nothing about a transaction you started yourself three layers deep in a custom handler, and CAP isn't going to remind you of that at compile time.