Our current authorization strategy on our site is very tightly coupled to our RDB's schema - which in some ways is a good thing, since it means the permissions available to a user exactly match what he should have, assuming a correct interpretation of the data. So when we query for authorization, we're asking about foreign key/m2m relationships.
I see two main problems with this: first, interpreting relational data is much more difficult to get right than reading explicit permissions from a separate table. The bigger problem I see is that this does not scale. As the app has grown, our permissions checks have gone from a single query across three tables to multiple queries across ten or more tables.
The strategy I've seen in a lot of places that solves this problem is explicit authorization (roles- or claims-based for example). Because this kind of thing is simple enough to just stick in a single table, it seems simpler, faster, and more scalable. The thing that bothers me about it is this: how do you avoid duplication of data?
For example, I have a User that owns a Design. That's currently accomplished with a foreign key. In switching to explicit authorization, I would add the user's id to a table containing the design's id and type. Should I remove the foreign key as well? Should permissions-relevant relationships always be mediated by the permissions table, or should I duplicate the data between the relational representation and the permissions table?
It seems like one the downsides of moving to explicit authorization could include performance, especially if a service call or something was required to fully discover permissions.
I think you're on the right track with roles.
When logging in, you should request your roles as scopes. It's not a terrible idea to namespace them by API and maybe group ids, if they apply.
scopes: ['forum:admin:somegroupid']
The login service could then validate that role by asking the service associated with the namespace.
GET https://forum-api.company.tld/grant_scope/[user_id]?scope=forum:admin:somegroupid
Which then returns whether that user can have that scope and the description of thats scope. Then the login service may include that scope in the generated jwt for that request.
Non subselected scopes should be validated at the route level (say if it were a role that wasn't specific to any group or item).
Otherwise, you'll have to do a query to see if that service says that the userid or one of the included scopes is allowed to do the thing. Redis SETs might be nice for this if you didn't want it to be a foreign table.
I think for object-level permissions, joining to a an access table makes sense, but use it as sparingly as possible. For example, in a forum-setting, object-level permissions should be on the forum, not at the threads or post level (except for owners, that are just included in row with the data). Then you can join from post->thread->forum_access to see whether they can write a new object.
TLDR; Use roles when possible and include as scopes that are validated at a route level. Use group level roles as scopes if necessary. Minimally use object-level permissions only when necessary, only on the objects absolutely necessary.
Edit: This is just one of many possible ways to solve your problem.