I agree with @Laiv's answer, however I do see another way to look at the problem...
Authentication
I realize that they may be API's which can be accessed anonymously.
However for the purpose of this answer I am considering that anonymous access is out of scope. Hence my assumptions are that:
- The security system has a mechanism to authenticate the user/device.
- Most API's can quickly reject any request that is unauthenticated.
- That for the rest of this answer, we are basically only talking about authorization checks for authenticated users.
Given that, I see three major objectives of the security infrastructure:
- The domain model is authoritative.
- Provide defense in depth (and some protection from DOS)
- Improve end user experience and improve threat detection.
Note: I using domain model here in the context of DDD.
Authoritative Domain Model
If you need more than simple RBAC (Role based access control), it's likely that the line between business logic and security will become blurred for example:
Managers can only approve PO's with an amount less than $100K,
Directors can approve up to $500K
Therefore you are forced to code some of your security into the domain layer, once you start down that path it makes sense to try to keep all security logic together in the domain model - so you don't have to make changes in multiple locations.
There should be appropriate methods in the domain layer (either on the domain objects or repository methods) which can be secured for all mutating operations.
I am generally a fan of CQRS for handling read operations, which usually results in some degree of duplicated security logic. A pattern you may chose to implement is to return the user_id with every record (i.e. you do enough joins in the DB to get back to a user_id **) in a lot of calls the user_id of the record will match the user_id from the security context - i.e. you can add logic to fail/alert if it doesn't.
Note: This is an explicit check vs passing the user_id into the query (sure you can screw up the query either way), however by explicitly returning the user_id it's less likely you will leave a hole by omission.
** - That or you have a user_id field in every table.
Defense in Depth
It could be expensive to load the entire domain model simply to do a permissions check. As already stated unauthenticated requests are dropped early, this can be combined with rate limiting (keyed on the authenticated user) to provide some protection against DOS attacks (although you may still want the protections provided by a CDN).
You can also use your infrastructure to prevent certain types of attack, for example if you use the BFF (Backend for frontend) model. If a BFF is designed specifically for end users, the end user BFF never accepts the user_id
as a parameter, instead all calls to the domain layer extract the user_id from the security context (JWT or similar). You would provide a different BFF for administration functions that can modify other users.
You can also use the BFF to filter information returned back to the client - say you have a policy that no data received from an internal API is "passed-though" to the client - it must be deserialized mapped to a BFF object and re-serialized - you can now audit the types of data returned by the BFF simply by looking at the transfer objects.
End User Experience
Assume you have a policy:
Clients/End users should not make requests that result in a permission denied.
Consider the previous case of a manager trying to approve a PO > $100K the UI should be told in advance that the approval will be rejected, hence it can display a message to the user and disable the approve button.
If implemented correctly/broadly this also has the advantage that you can monitor the number of "Permission Denied"s that occur - since normal operation should not generate a significant number of them, any spikes in PDs can be used as an indicator of potential nefarious action.