The goal is to have a simple UI share link for users that does the right thing:
Sharing resources between users seems like it should be simple, then why are there no obvious simple solutions? Maybe it isn't so simple after all. So let's at least review the bad options.
Background
In most software applications and platforms the ability for users to share resources is essential for collaboration and communication. With no shortage of data breaches and privacy concerns, developing a secure but user-friendly solution is crucial.
The most common pattern is that one user user A
creates a resource. Then later that user shares access to that resource to another user user B
. The permissions granted to user B
, maybe the same or different than user A
has, and the ability for user B to share access with another user user C
may or may not be restricted.
Another pattern with sharing resources is that users might be part of a shared customer account tenant. Multiple users might own and access multiple resources in the same account. There might be groups of users, and multiple resources or groups of resources might need to be shared at the same time.
The last scenario relevant for sharing is that users might be anonymous. That is, our user B
might not exist in our platform, yet. When user A
attempts to share a resource with them. That means we won't have a user identifier representing the user before staring the sharing process.
An additional complexity is that resources may or may not be owned by a single service. In other words, shared resources might be owned and operated through a third party plugin or extension. If you have a Platform Marketplace, then sharing might have further complex scenarios. However in most cases those scenarios just extend from the core ones listed above, and by using Authress Platform Extensions these are supported out of the box. See the platform extensions documentation for more information.
To demonstrate these usage patterns we'll be using the document repository implementation example which entails:
There are many documents. Each document can be access by any number of users and each of those users may have different permissions to that document. Users will share documents and change other users’ permissions as well as create, modify, and delete documents.
This article references the document repository implementation example to help explain the following concepts. It helps to take a quick review to better understand the core product concept.
Potential Solutions
There are actually multiple solutions to support resource sharing, and depending on your core software needs, any number of them might be right for your situation. For higher frequency of user usage, scale, or more complex user stories, more robust solutions will be necessary. More complex doesn't necessarily mean scale. You could have scalability concerns but only simple needs, or complex needs, but small scale. As we'll learn this doesn't change the feature set, however. This is important to keep in mind. And next, we'll iterate through some different options available.
Resource property or user list
The most common solution for storing resource access permissions is to push them onto the resource or list the resources a user has access to in the user record. We know from the academy article on Access Control Solutions that these are the Inline access policy
and the Access control list
strategies. And neither of them sufficiently support our needs. Additionally neither of these scale. Eventually users will have access to many resources (scale), and resources will to support multiple users that need access (many-to-many).
With a hierarchy of documents in many directories, permissions on the user or the resource would not be sufficient to allow users to fetch all the documents they have access to, to get all the users that have access to a resource.
The reason is in either of these cases, to support our scenarios our system would need to add additional users to the resource list, or add the resources to the user. At this point if we aren't including the permissions for the resource then there is a huge problem, because there will be no way to distinguish what actions a user could take for that resource. Many-to-many user to resource mapping doesn't include the permissions we have to have.
That means for each authorization check we need to verify three things:
- The User
- The Resource
- The Permission
Only with those three things can we actually verify that the user has the correct access to the correct resource.
Going forward we'll assume that there is a dedicated first-class system that supports the storage of authorization and permissions for each user. This system doesn't have to be full featured for each of these solutions, but without something in place, most of these are impossible to support.
The user attribute
Now that we have figured out that we'll need to store additional information regarding the permission that a user has and that different users will have different access, a followup might be Role-Based Access Control (RBAC) or Attribute-Based Access Control (ABAC). However both of these systems lack the support we need as well.
With RBAC, a set of strings is assigned directly to the user, such as Admin
or Viewer
. But it doesn't include the granular permissions nor the resources that a user should have access to. For instance, in the case of user A
sharing resources with user B
, they might want to only grant read-only permissions. With RBAC they can do that might assigning a ReadOnly
role to user B
. However, the problem here is that user B
will then have ReadOnly
access to all of the resources of user A
or the Account Tenant that user A
is a part of, which is not what we want.
Likewise with ABAC, a similar problem exists. There is no one attribute we could put on user B
to signify which resources they should have access to. Further, then we would be stuck will all the pitfalls of ABAC, further listed and discussed in the Access Control Solutions article, the problems with ABAC is so long that this is not the place to discuss them, but the most fundamental of which is the ability to list all the users that have access to resource R. Queries like that aren't possible with ABAC, and being able to query for resources or users is a critical component of an access control system.
The iterative approach
It's probably not a surprise that in today's software engineering of systems, many software applications, services, and platforms take an agile iterative approach to building up access control. They might start with one of the above solutions. That means our starting point is that access is granted directly to the user on the document:
Our access check is very simple, does the user have access to the specific document:
VIEW /documents/doc_001
Then later, we might realize that users are actually in accounts and that documents are actually owned by one account in particular, not by a specific user most of the time. We achieve this by storing which users are in which accounts, and including the account in the document resource uri. If the user has access to this new hierarchy then they have access to the specific document.
The access control check would be altered to to support the tenant.
VIEW tenants:tenant_001/document/doc_001
This is often an easy change for your system because at this point you likely haven't gotten that far. But if this sort of migration is a challenge, good luck for what comes next.
Eventually, inside of those accounts, naturally different groups of users will exist for each account and have access only to a subset of all the document in that account.
But here's when the iterative approach starts to break down. Eventually we'll want to identify that group of resources, so that we can define users or groups of users that should have access to it. This grouping is what causes a problem, and that's because we actually need a new element in our document hierarchy that wasn't there before. And we can't just add in a group segment to that hierarchy because the document might not exist in a document group from a user perspective. Is Document Group 001, a thing the user knows about? It is possible, but it is likely it is an arbitrary construct.
From a directory perspective a document only exists in one hierarchy, but from an access standpoint, a user might need access to same group of documents as another user, directory irrelevant. For instance two documents might be /root/path/1/doc_001
and root/path/2/doc_002
, and user B
needs access to only these two documents but isn't allowed to have access to the rest of /root/path/1
. That would require explicitly giving the user access to these two documents, which works. Now imagine that there are 1000 additional users that need the exact same access to these documents and all other documents that consider similar information. For instance /root/path/*/financial_report_year_end/*
. There also might be too many documents or users to explicitly give the users access to all the documents in that group.
That means that we can't just extend our document uris to be of this format:
VIEW tenants:tenant_001/document-groups/group_001/documents/doc_001
The documents already exist in a hierarchy which is a business logic requirement, we don't want to alter that to satisfy our access control needs.
Actually trying to do this and make the migration here is often an impossible burden. But assuming we attempt it, there are actually three problems with this approach:
- Documents can be in multiple groups, so if users have access through different groups, we actually need to check which group they are trying to access. We don't know up front which group will grant access. That would require first iterating all the groups the user has access to and then checking to see if the user has access to the document through that group. The one caveat here is that this is actually possible to do in Authress via the wildcard
tenants:tenant_001/*/documents/doc_001
, however since most solutions don't support this, this might not always be an available solution to you. - This doesn't scale across multiple services. In the case you have multiple resources and multiple services in a service mesh, microservice architecture, or a monolith, then users will need access to multiple resources of different types at the same time. Or at the very least, every service you have will end up needing to reimplement the same solution. If users care about sharing resources in one part of your system, they'll almost for sure need to share resources in both related in unrelated parts as well.
- I'm sure you are already thinking, what happens when I need sub-groups. And for sure that day is inevitable.
One mistake that is made far too often is attempting to solve this with any sort of attribute-based access control. While it seems like it could help to support this case, it breaks down cross service, or when any sort of auditing is necessary, and it fails to support all our other needs as use cases. So while it works here, it doesn't support the user experiences we need, see above for all the ABAC failure modes.
Resource groupings pitfalls
Above we mentioned creating document groups as part of the resource uri. This would give us a lot of control if it worked. If every document was part of only exactly one hierarchy, then adding the resource group to the our resource uri would work. For us this is actually the directory path, and we can see that this is implemented in almost every document/file system: /root/path/directory/file.ext
. However, this obviously won't work for us, when it comes to sharing subsets of documents, as these sets of documents exist outside of any defined hierarchy. In other words, Users must not be restricted how they share, based solely upon how they have structured their directories. And while you could add that restriction to your service, both your product managers and users won't be happy because for sure some user made a mistake somewhere in their resource management and rather than trying to "fix it", they'll just go to a different platform.
We know we need to support multiple groups, we could be tempted to make a document-group a first class notion in our document repository service API. There would be CREATE /group
and UPDATE /group
to manipulate the groups of documents. Users would be given full control over this, and when we want to check for access, we would check to see which groups a user has access to and which resources are in that group.
The code to perform that check looks like this in our service:
async function checkAccess(userId, resourceId) {
const groupsThatContainResource = await database.getResourceGroups(resourceId);
foreach (const groupId in groupsThatContainResource) {
const hasAccess = await authService.checkAccess(userId, groupId);
if (hasAccess) {
return true;
}
}
return false;
}
Not only is this code poorly written, there isn't really a great way to optimize it. So it creates a pit of failure for our services. It's a pit of failure, because It's easy for our services ho have it implemented wrong, and there aren't even any good solutions. And there are still more problems:
- Every service we have in our platform or monolith would need to create the exact same functionality to support resource groups. We literally have duplicate the same code in every service and resource we have. And we probably want all those things to be the same as well, since permissions management that is consistent provides emergent benefits for our users.
- We actually don't care about resource groups in our service. Resource groups are almost never a business concept. We only implemented resource groups because we needed a way to grant access. So this isn't even something we want in our service in the first place. Of course there are some exceptions where a resource group may make sense in your context, and then you want to go and implement it, but for our document repository it just adds confusion (and technically we already have groups in the form of a directory structure).
Pseudo-copy resource
Due to the nature of the problems created with the Resource groupings, many implementations opt for sharing only at the Account tenant level, and thus do not have groups. That means a user would have to be assigned access at the top level and therefor not have granular access to specific resources. However, as identified this isn't a full solution and often is insufficient for the user experience we as well the audit logs we need to keep. It also feels wrong.
The next alternative is to actually copy resources. At this maturity of software, you likely have a solution in place that could support groups, account tenants, and direct access for users at the resource level, but it misses one or more features to actually get all the way there. One trick is that when a document is shared with a user instead of giving that user ReadOnly access to the document, we could create a copy of that document and mark that document as ReadOnly. This avoids all the problems identified with groupings so it can seem like a great alternative to having an authorization and permissions management solution that supports first-class resource sharing.
This of course isn't without its own problems though:
- The primary problem is Which Users - If we don't know who the user is that we will be sharing with, then we won't know at sharing time where to duplicate the document. If that user is part of an Account Tenant, we also won't know which tenant to share with. Most systems will completely ignore this problem and tell you outright that: you can't share with an unknown user, which can tell you a lot about the state of their security implementation.
- All other services need to implement this as well - As with the resource grouping pitfalls, every service that wants to support resource sharing will fall into the problem of needing to implement the resource sharing code. They will all need to support an extra flag on every resource. Forcing every service and resource to add an extra flag tells us there is something wrong with this solution.
- Potential mess everywhere - There are ongoing questions about how to manage multiple read only resources for different users. For instance if the same resource is shared multiple times, how many readonly copies should we make. If we only make one ReadOnly in total, then we need to add some explicit code to check if we have already made it, for.every.single.resource. And if we make more than one, then...we'll get that to that.
There are actually some critical issues as well:
Users who are not part of the main resource owner group for the original tenant can only have ReadOnly access to the whole resource. It won't include answers to questions of the form:
- "Can they share this resources with others?"
- "How can we give them ReadOnly access to only part of the resource"
- "How does this work for some or all sub resources of the shared resource, do those need to be copied too?"
And if that wasn't bad enough, by far the worst problem of all is attempting to keep these copies in sync with the original resource. When the resource changes, that means now our service has to sync at least one version to another copy of the resource. Getting objects in a database in sync is an incredibly hard problem, as it is often unreliable and no perfect solution to guarantee consistency. And if there are multiple copies then at best we've just unnecessarily doubled our DB size, and it could be even worse.
All of that, and we still don't get much further than a very small edge case, because all of the more sophisticated access patterns still don't have a solution. So lots of questions, no answers.
The complete solution
So far it seems like every partial solution comes with major drawbacks that make iterating on it not possible without completely changing the strategy. So instead, let's just focus on the best way to achieve the Click here to generate a sharing link that works in every situation.
To do this we'll take the best parts of all the previous solutions and combine them to eliminate the problems and achieve our end goal. The full list of the things we want to achieve has become:
- Support grouping of users
- Support grouping of resources
- Support anonymous users that haven't logged in before or whom we don't know who the target user is
- Support revoking permissions
- Support every resource and service without having to duplicate the implementation in every service for every resource
- Avoid service changes necessary to support sharing, sharing should be completely independent of the service
- Multi-account and multitenant should be supported, and users in each should be able to get different access to different resources
- Cross account resource sharing, which allows delegating ownership of the resource to another account
This is very nearly a full list of everything that is necessary for resource sharing and we can't do without. To support it we'll introduce a solution called Resource Bundles.
Resource bundles
Resource bundling is a first-class strategy we'll need our authorization solution to support. Resource bundling is simply the ability to define a list of resources and a list of users we want to have access to that list of resources.
It would allow us the ability to group resources in our access control permissions system. This means that our standard access control solution needs to support it in order to get the benefit. However, given that everything discussed in this article is access control related, this is the right place to go. It eliminates most of the items from our list just by moving out the logic from the individual services.
To achieve resource bundling we'll need a place where we can define those lists. For the purposes of this article to give them a label that can be reused, let's call that an Access Record
. For bundling to work, it must at least support our required fields and properties: users
, resources
, and roles (or permissions)
:
If our authorization solution supports listing out all of the resources in a single place, then this is our bundle. For every bundle of resources, we can assign who should have access to that bundle. A strategy like this can also be extended to grant groups of users, organizations, or whole tenants access to the bundle, rather than just individual users. Some access control solutions support this. Most importantly though, we don't have to put the group in the resource hierarchy as the bundling of resources is authorization related not resource definition related.
It is worth calling out that, there are some cases that make sense for our solution and our users which require grouped resources, and in these cases the group should remain in the resource uri. Otherwise we now can take it out.
tenants:tenant_001/documents/doc_001
Our resources can remain permission group agnostic, unless we actually want them to have a grouping as a concept. And because the bundling can include arbitrary resources and resource uris from any service in our software, application, or platform, this also ensures there is a single location for access. Every service gets support by design for free.
Putting it altogether
Now that we have a strategy in place with Resource Bundling, we can actually go through the flow of how that works in practice.
Flipping back to that copy link button from earlier:
When the user is on this screen they have two options:
- Ask them which user should be assigned a new role or permissions and what that role is
- Or if they click the
Copy link
- Give them an invite code that they can send to their fellow peer to gain access to the resource.
Since sharing is really just assigning permissions to a user in disguise, in the first case, we would create (or update) an access record for the invitee:
await authressClient.accessRecords.createRecord({
// Select a predictable recordId so we can update this later
recordId: `rec_Document:${documentId}:User${inviteUserId}`,
// A helpful name for the record
name: `Document ${documentId} access`,
// Invitee UserId
users: [{ userId: inviteUserId }],
statements: [{
// Grant the Editor permissions
roles: ['Editor'],
// To the document in our tenant
resources: [{ resourceUri: `tenants:tenant_001/documents/${documentId}` }]
}]
});
In the case that we want to invite someone to edit all documents in a hierarchy:
await authressClient.accessRecords.createRecord({
// Select a predictable recordId so we can update this later
recordId: `rec_Document:all-documents:User${inviteUserId}`,
// A helpful name for the record
name: `All document access`,
// Invitee UserId
users: [{ userId: inviteUserId }],
statements: [{
// Grant the Editor permissions
roles: ['Editor'],
// To the document in our tenant
resources: [{ resourceUri: `tenants:tenant_001/documents/*` }]
}]
});
To update a resource bundle with all the new users it would look similar to this:
await authressClient.accessRecords.createRecord({
// Select a predictable recordId so we can update this later
recordId: `rec_Document-Bundle-001`,
// A helpful name for the record
name: `Access for document bundle 001`,
// Invitee UserId
users: [{ userId: 'user A' }, { userId: 'user B'}],
statements: [{
// Grant the Editor permissions
roles: ['Editor'],
// The relevant document bundle
resources: [
{ resourceUri: `tenants:tenant_001/documents/doc_001/sub-resources` },
{ resourceUri: `tenants:tenant_001/documents/doc_002/*` },
{ resourceUri: `tenants:tenant_002/documents/*` },
{ resourceUri: `tenants:*/documents/*/finance-docs/*` }
]
}]
});
And for the second case, when we don't know who the user is yet, we would instead want to generate an invite instead. There are many ways to do that. For completeness an example from the Authress Knowledge Base is included here:
import { AuthressClient } from '@authress/sdk';
// The Authress API Url: https://authress.io/app/#/api?route=overview
const authressClient = new AuthressClient({ authressApiUrl: 'https://auth.yourdomain.com' });
// First, generate the invite
const inviteResponse = await authressClient.invites.createInvite({
statements: [{
roles: ['Editor'],
resources: [{ resourceUri: `tenants:tenant_001/documents/${documentId}` }]
}]
});
const inviteId = inviteResponse.data.inviteId;
Once we have the inviteId
we just need to get it to the user. Realistically, you probably would want to host your own Invite Acceptance UI to help guide the new user. For instance they might want to register a new account, or use an anonymous user to authorize to the document they have just been given access to.
And we are able to do this only because:
- We have a hierarchy supported that allows us to qualify resources with wildcards
✶
- Our concept which maps groups to users to resources supports listing all the resources together in a single place (we called it an
access record
orresource bundle
). - The mapping accepts the triad of
user
,resource
, andpermission
. Which together are required to be supported at the same time to validate access and support resource groupings. - Anonymous users and ones that haven't logged in yet are supported with the invite flow.
Additional advantages
User agnostic invites
There are some additional edges cases from the above list which you might have to support. In order to generate a generic link for a user to click on, the invite might need to be user agnostic. That is because we don't know who the target user is yet. This means that our access control system must have a concept of access without a user that can be claimed by a user at invite acceptance time. This is important for two reasons:
- The inviter will not always know the identity of the invitee, and often they shouldn't be allowed to know. That user could be in a completely different tenant and the current user
user A
shouldn't know about the user identities in a different tenant until that user accepts the invite. - The invitee might not have a user ID in your software yet. This could be due to multiple reasons, such as they haven't signed up or you are using a different authentication, user login, or management system from your authorization one. If you aren't sure what the difference between these two are, check out the difference between AuthN and AuthZ.
Invites are a complete topic in themselves, and can be sophisticated depending on needs of your software. Check out the full guide on implementing invites yourself.
One time sharing
Invites also serve a secondary benefit as well. If we've implemented resource sharing using invites that means that one time resource share links can also be provided. A one time link grants a user access to those permissions only one time via single session. Instead of logging the user in and granting them permanent access, we can utilize the features in the authorization for access records to refine exactly how that access works. If that access should expire after 1 day or 1 month, then we can easily do that. With the other strategies, every individual resource and service would need to be extended with that functionality. Every single additional feature we need, would require every service in our platform to support it before we could offer a consistent solution.
Revoking access
Access records also extend to control over revoking of access as well. While the topic of revoking access is so complex that there is an entire academy article on invaliding user access, if an access control solution is utilized to grant cross tenant, cross user, or cross group access via resource bundles, then revoking access works the same as if the resource was only shared within an account tenant. Having a consistency in how to revoke access creates a powerful pit of success. If to revoke access to an in-account document, we need to update the permissions, but to revoke access to someone who we shared the resource with externally we would need to do something different such changing the explicit resource as well, then we've created a pit of failure--easy to get wrong and likely to write code which doesn't execute the right thing. By having the same flow irrespective of who the user is, where the resources are located, or how they received access, we can be sure that the right code and right flow will be executed every time.
Followup
There are many ideas expressed in this article which have corresponding additional articles, from the Security blog, the Auth Academy, or the Authress Knowledge Base, here are some of the listed for ease of access: