Authorization: System vs User

Recently I've been working on authorization systems for some projects. At some point, I ran into some design mistakes on one of them, and it hit me that there's a little unspoken secret about authorization. There are two types of authorization: user and system. When doing authorization, we need to check both, but we also need to keep both separate. This problem becomes most apparent when working on software serving two completely assymetric user types.
For instance, let's say you're working on lending software. Lending software has two types of users: lenders and borrowers. Lenders can log in, view loans, enter payments manually (e.g. for mailed in payments), update automatic rules (e.g. recategorize loans based on payment history, like number of days late), and enter notes. Lenders are not allowed to see online payment information, such as bank account and credit card info. But, they are able to see autopay schedule information.
Borrowers, meanwhile, get a much smaller amount of functionality. They can view payment history, see the outstanding balance, next payment due, and any outstanding fees. As for actions, they can only make online payments and setup (or cancel) autopays. They can't manually enter a payment like a lender can without an account actually being charged. However, borrowers can see their redacted bank account information and credit card info.
To help illustrate the point, we'll examine implementing lending software using only system authorization, only user authorization, and then finally having two separate fields.
All System Authorization
A "system" is any piece of software which is asked to do a task by an authenticated user. System authorization is granting read and write access to that system for performing that task.
For instance, to make an online payment the system needs to read account information (credit card or bank account), make a request to a payment processor, check the transaction worked, and record the payment to the database. This also applies to autopays. Autopays need to be able to access bank account and credit card information in a non-redacted form.
Per our requirements lenders have access to the autopay system. If the autopay system can read bank account information, and all we have is system authorization, then lenders can access bank account information through the authorization system. Sure this may require manually sending an HTTP request or opening up the developer console, but they have access.A common name for this is "broken access controls" - which is a very common type of security vulnerability. If we don't add in proper filtering and safeguards for what the user can do, then our system authorization could end up leaking access to the user.
So, let's go to the other extreme. If users shouldn't be able to see sensitive data or perform sensitive actions, then let's restrict the systems down to the user level.
All User Authentication
Let's update our model to have all access be controlled by what the user can access, that way we never leak information that they can't access. Sounds good right?
Well, now we have a set of different problems. First, our autopay system. Lenders can view autopay schedules, but not bank account info. Autopays depend on bank account info, so depending on how we set up our code, we may stop lenders from viewing autopays. I've seen this on endpoints with way too much data (e.g. GraphQL) and overly restrictive access controls.
Second, some operations need legitimate access to more than what the user can see or change. Per our spec, lenders can define automatic rules that run when things like payment history change. Borrowers don't have access to these rules, but if they make a payment then they've changed payment history, and therefore those rules need to run. If we don't allow the payments system to access those rules, then the best case is our rules are no longer automatic. Worst case is that borrowers can't make payments because of exceptions (and actual worst case is we take their money without recording a payment).
This applies to more than just automatic rules. Account settings is a big one.
Account settings would apply to any settings the lender can change, such as default timezones for record keeping, number formatting guidelines, payment processor API keys and credentials (e.g. Square account IDs), document templates, etc. Borrowers shouldn't be able to see these settings, but they're impacted by these settings.
For instance, a borrower cannot see a lender's payment processor API keys. But, when they make an online payment the system needs to use those API keys to make the payment. If the system is restricted to user-level access, it won't have access to read them and can't actually make the request. I've seen these types of systems crop up with microservice architectures. Often, well meaning developers will advocate for user-level checks in all systems while another group advocates for everything to be a service.
Inevitably, the api keys are stored in one service and the integration happens in another. When this collides with user-level checks, the integration service will use user tokens to access the api key service, and the api key service will deny the request because borrowers can't read API keys. So, the payment fails.
The end result is either a system that doesn't work, granting users access that they shouldn't have to work around limitations, or realizing that some form of system-level authorization is needed.
Two worlds
The reality is that authorization has two worlds. System-level authorization and user-level authorization. We have to acknowledge both exist and plan for both, especially in microservice architectures (which is one big reasons to go more large-service and monolith architectures).
We need to have systems be able to access the data they need to perform a task, and we need to sanitize output and restrict available tasks based on user permissions. If we forget one, we end up with broken access controls. Forget the other, and our system doesn't work. We need both.