Best practices for designing an authorization model - Amazon Verified Permissions

Best practices for designing an authorization model

As you prepare to use the Amazon Verified Permissions service within a software application, it can be challenging to leap immediately into writing policy statements as a first step. This would be similar to beginning development of other portions of an application by writing SQL statements or API specifications before fully deciding what the application should do. Instead, you should begin with a user experience, gathering a clear understanding of what end-users should see when managing permissions in the application UI. Then, work backwards from that experience to arrive at an implementation approach.

As you do this work, you’ll find yourself asking questions such as:

  • What are my resources? Do they have relationships to each other? For example, do files reside within a folder?

  • What actions can principals perform on each resource?

  • How do principals acquire those permissions?

  • Do you want your end-users to choose from predefined permissions such as “Admin”, “Operator”, or “ReadOnly”, or should they create ad-hoc policy statements? Or both?

  • Should permissions inherit across resources, such as files inheriting permissions from a parent folder?

  • What types of queries are necessary to render the user experience? For example, do you need to list all of the resources that a principal can access to render that user's home page?

  • Can users accidentally lock themselves out of their own resources? Does that need to be avoided?

The end result of this exercise is referred to as an authorization model; it defines the principals, resources, actions, and how they interrelate to each other. Producing this model doesn’t require unique knowledge of Cedar or the Verified Permissions service. Instead, it is first and foremost a user experience design exercise, much like any other, and can manifest in artifacts such as interface mockups, logical diagrams, and an overall description of how permissions influence what users see in the product. Cedar is designed to be flexible enough to meet customers at a model, rather than forcing the model to bend unnaturally to comply with a Cedar's implementation. As a result, gaining a crisp understanding of the desired user experience is the best way to arrive at an optimal model.

This section provides general guidance on how to approach the design exercise, things to watch out for, and a collection of best practices for using Verified Permissions successfully.

In addition to the guidelines presented here, remember to consider the best practices in the Cedar policy language reference guide.

There isn't a canonical “correct” model

When you design an authorization model, there is no single, uniquely correct answer. Different applications can effectively use different authorization models for similar concepts, and this is OK. For example, consider the representation of a computer's file system. When you create a file in a Unix-like operating system, it doesn't automatically inherit permissions from the parent folder. In contrast, in many other operating systems and most online file-sharing services, files do inherit permissions from its parent folder. Both choices are valid depending upon the circumstances the application is optimizing for.

The correctness of an authorization solution isn’t absolute, but should be viewed in terms of how it delivers the experience that your customers want, and whether it protects their resources in the way they expect. If your authorization model delivers on this, then it is successful.

This is why beginning your design with the desired user experience is the most helpful prerequisite to the creation of an effective authorization model.

Focus on your resources beyond API operations

In most consumer-facing applications, permissions are modeled around the resources supported by the application. For example, a file-sharing application might represent permissions as actions that can be performed on a file or a folder. This is a good, simple model that abstracts away the underlying implementation and the backend API operations.

In contrast, other types of applications, particularly web services, frequently design permissions around the API operations themselves. For example, if a web service provides an API named createThing(), the authorization model might define a corresponding permission, or an action in Cedar named createThing. This works in many situations and makes it easy to understand the permissions. To invoke the createThing operation, you need the createThing action permission. Seems simple, right?

You'll find that the getting started process in the Verified Permissions console includes the option to build your resources and actions directly from an API. This is a useful baseline: a direct mapping between your policy store and the API that it authorizes for.

However, this API-focused approach can be less than optimal, because APIs are merely a proxy for what your customers are truly trying to protect: the underlying data and resources. If multiple APIs control access to the same resources, it can be difficult for administrators to reason about the paths to those resources and manage access accordingly.

For example, consider a user directory that contains the members of an organization. Users can be organized into groups, and one of the security goals is to prohibit discovery of group memberships by unauthorized parties. The service managing this user directory provides two API operations:

  • listMembersOfGroup

  • listGroupMembershipsForUser

Customers can use either of these operations to discover group membership. Therefore, the permissions administrator must remember to coordinate access to both operations. This is complicated further if you later choose to add a new API operation to address additional use cases, such as the following.

  • isUserInGroups (a new API to quickly test if a user belongs in one or more groups)

From a security perspective, this API opens a third path for discovering group memberships, disrupting the carefully crafted permissions of the administrator.

We recommend that you ignore the API semantics and instead focus on the underlying data and resources and their association operations. Applying this approach to the group membership example would lead to an abstract permission, such as viewGroupMembership, which each of the three API operations must consult.

API Name Permissions
listMembersOfGroup requires viewGroupMembership permission on the group
listGroupMembershipsForUser requires viewGroupMembership permission on the user
isUserInGroups requires viewGroupMembership permission on the user

By defining this one permission, the administrator successfully controls access to discovering group memberships, now and forever. As a tradeoff, each API operation must now document the possibly several permissions that it requires, and the administrator must consult this documentation when crafting permissions. This can be a valid tradeoff when necessary to meet your security requirements.

Compound authorization is normal

Compound authorization occurs when a single user activity, such as clicking a button in your application's interface, requires multiple individual authorization queries to determine whether that activity is permitted. For example, moving a file to a new directory in a file system might require three different permissions: the ability to delete a file from the source directory, the ability to add a file to the destination directory, and possibly the ability to touch the file itself (depending on the application).

If you're new to designing an authorization model, you might think that every authorization decision must be resolvable in a single authorization query. But this can lead to overly complex models and convoluted policy statements. In practice, using compound authorizations can be useful in helping you to produce a simpler authorization model. One measure of a well-designed authorization model is that when you have sufficiently decomposed individual actions, your compound operations, such as moving a file, can be represented by an intuitive aggregation of primitives.

Another situation where compound authorization occurs is when multiple parties are involved in the process of granting a permission. Consider an organizational directory where users can be members of groups. A simple approach is to give the group owner permission to add anyone. However, what if you want your users to first consent to being added? This introduces a handshake agreement in which both the user and the group must consent to the membership. To accomplish this, you can introduce another permission that is bound to the user and specifies whether the user can be added to any group, or to a particular group. When a caller subsequently attempts to add members to a group, the application must enforce both sides of the permissions: that the caller has permission to add members to the specified group, and that the individual user being added has the permissions to be added. When N-way handshakes exist, it is common to observe N compound authorization queries to enforce each portion of the agreement.

If you find yourself with a design challenge where multiple resources are involved and it is unclear how to model the permissions, it can be a sign that you have a compound authorization scenario. In this case, a solution might be found by decomposing the operation into multiple, individual authorization checks.

Multi-tenancy considerations

You might want to develop applications for use by multiple customers - businesses that consume your application, or tenants - and integrate them with Amazon Verified Permissions. Before you develop your authorization model, develop a multi-tenant strategy. You can manage the policies of your customers in one shared policy store, or assign each a per-tenant policy store.

  1. One shared policy store

    All tenants share a single policy store. The application sends all authorization requests to the shared policy store.

  2. Per-tenant policy store

    Each tenant has a dedicated policy store. The application will query different policy stores for an authorization decision, depending on the tenant that makes the request.

Neither strategy creates a relatively-higher volume of authorization requests that might have an impact on your AWS bill. So how, then, should you design your approach? The following are common conditions that might contribute to your Verified Permissions multi-tenancy authorization strategy.

Tenant policies isolation

Isolation of the policies of each tenant from the others is important to protect tenant data. When each tenant has their own policy store, they each have their own isolated set of policies.

Authorization flow

You can identify a tenant making an authorization request with a policy store ID in the request, with per-tenant policy stores. With a shared policy store, all requests use the same policy store ID.

Templates and schema management

Your policy templates and a policy store schema add a level of design and maintenance overhead in each policy store.

Global policies management

You might want to apply some global policies to every tenant. The level of overhead for management of global policies varies between shared and per-tenant policy store models.

Tenant off-boarding

Some tenants will contribute elements to your schema and policies that are specific to their case. When a tenant is no longer active with your organization and you want to remove their data, the level of effort varies with their level of isolation from other tenants.

Service resource quotas

Verified Permissions has resource and request-rate quotas that might influence your multi-tenancy decision. For more information about quotas, see Quotas for resources.

Comparing shared policy stores and per-tenant policy stores

Each consideration requires its own level of time and resource commitment in shared and per-tenant policy store models.

Consideration Effort level in a shared policy store Effort level in per-tenant policy stores
Tenant policies isolation Medium. Must include tenant identifiers in policies and authorization requests. Low. Isolation is default behavior. Tenant-specific policies are inaccessible to other tenants.
Authorization flow Low. All queries target one policy store. Medium. Must maintain mappings between each tenant and their policy store ID.
Templates and schema management Low. Must make one schema work for all tenants. High. Schemas and templates might be less complex individually, but changes require more coordination and complexity.
Global policies management Low. All policies are global and can be centrally updated. High. You must add global policies to each policy store in onboarding. Replicate global policy updates between many policy stores.
Tenant off-boarding Medium. Must identify and delete only tenant-specific policies. Low. Delete the policy store.
Service resource quotas High. Tenants share resource quotas that affect policy stores like schema size, policy size per resource, and identity sources per policy store. Low. Each tenant has dedicated resource quotas.

How to choose

Each multi-tenant application is different. Carefully compare the two approaches and their considerations before making an architectural decision.

If your application doesn't require tenant-specific policies and uses a single identity source, one shared policy store for all tenants is likely to be the most effective solution. This results in a simpler authorization flow and global policy management. Off-boarding a tenant using one shared policy store requires less effort because the application does not need to delete tenant-specific policies.

But if your application requires many tenant-specific policies, or uses multiple identity sources, per-tenant policy stores are likely to be most effective. You can control access to tenant policies with IAM policies that grant per-tenant permissions to each policy store. Off-boarding a tenant involves deleting their policy store; in a shared-policy-store environment, you must find and delete tenant-specific policies.

When possible, populate the policy scope

The policy scope is the portion of a Cedar policy statement after the permit or forbid keywords and between the opening parenthesis.

Illustrates the structure of a Cedar policy, including the scope.

We recommend that you populate the values for principal and resource whenever possible. This lets Verified Permissions index the policies for more efficient retrieval and therefore improves performance. If you need to grant the same permissions to many different principals or resources, we recommend that you use a policy template and attach it to each principal and resource pair.

Avoid creating one large policy that contains lists of principals and resources in a when clause. Doing so will likely cause you to run into scalability limits or operational challenges. For example, in order to add or remove a single user from a large list within a policy, it is necessary to read the whole policy, edit the list, write the new policy in full, and handle concurrency errors if one administrator overwrites another’s changes. In contrast, by using many fine-grained permissions, adding or removing a user is as simple as adding or removing the single policy that applies to them.

Every resource lives in a container

When you design an authorization model, every action must be associated with a particular resource. With an action such as viewFile, the resource that you can apply it to is intuitive: an individual file, or perhaps a collection of files within a folder. However, an operation such as createFile is less intuitive. When modeling the capability to create a file, what resource does it apply to? It can't be the file itself, because the file doesn’t exist yet.

This is an example of the generalized problem of resource creation. Resource creation is a bootstrapping problem. There must be a way for something to have permission to create resources even when no resources exist yet. The solution is to recognize that every resource must exist within some container, and it is the container itself that acts as the anchor point for permissions. For example, if a folder already exists in the system, the ability to create a file can be modeled as a permission on that folder, since that is the location where permissions are necessary to instantiate the new resource.

permit ( principal == User::"6688f676-1aa9-456a-acf4-228340b54e9d", action == Action::"createFile", resource == Folder::"c863f89b-461f-4fc2-b638-e5fa5f79a48b" );

But what if no folder exists? Perhaps this is a brand new customer account in an application where no resources exist yet. In this situation, there is still a context that can be intuitively understood by asking: where can the customer create new files? You don't want them to be able to create files inside any random customer account. Rather, there is an implied context: the customer’s own account boundary. Therefore, the account itself represents the container for resource creation, and this can be explicitly modeled in a policy similar to the following example.

// Grants permission to create files within an account, // or within any sub-folder inside the account. permit ( principal == User::"6688f676-1aa9-456a-acf4-228340b54e9d", action == Action::"createFile", resource in Account::"c863f89b-461f-4fc2-b638-e5fa5f79a48b" );

Yet, what if no accounts exist either? You might choose to design the customer sign-up workflow so that the it creates new accounts in the system. If so, you’ll need a container to hold the outermost boundary in which the process can create the accounts. This root level container represents the system as a whole and might be named something like “system root”. However, the decision for whether this is needed, and what to name it is up to you, the application owner.

For this sample application, the resulting container hierarchy would therefore appears as follows:

A file hierarchy with a system root that contains accounts. The accounts each contain folders which can in turn contain files.

This is one sample hierarchy. Others are valid as well. The thing to remember is that resource creation always happens within the context of a resource container. These containers can be implicit, such as an account boundary, and it can be easy to overlook them. When designing your authorization model, be sure to note these implicit assumptions so they can be formally documented and represented in the authorization model.

Separate the principals from the resource containers

When you are designing a resource hierarchy, one of the common inclinations, especially for consumer-facing applications, is to use the customer's user identity as the container for resources within a customer account.

A file hierarchy where the user identities are the containers for all resources.

We recommend that you treat this strategy as an anti-pattern. This is because there is a natural tendency in richer applications to delegate access to additional users. For example, you might choose to introduce "family" accounts, where other users can share account resources. Similarly, enterprise customers sometimes want to designate multiple members of the workforce as operators for portions of the account. You might also need to transfer ownership of an account to a different user, or merge the resources of multiple accounts together.

When a user identity is used as the resource container for an account, the previous scenarios become more difficult to achieve. More alarming, if others are granted access to the account container in this approach, they might inadvertently be granted access to modify the user identity itself, such as changing Jane’s email or login credentials.

Therefore, when possible to do so, a more resilient approach is to separate the principals from the resource containers, and model the connection between them by using concepts such as "admin permissions" or "ownership".

Separated principals and resources with resource attributes that link the resource to the associated principals.

Where you have an existing application that is unable to pursue this decoupled model, we recommend that you consider mimicking it as much as possible when designing an authorization model. For example, an application that possesses only a single concept named Customer that encapsulates the user identity, login credentials, and resources that they own, could map this to an authorization model that contains one logical entity for Customer Identity (containing name, email, etc) and a separate logical entity for Customer Resources or Customer Account, acting as the parent node for all the resources they own. Both entities can share the same Id, but with a different Type.

Separated principals and resources with a more generalized resources container associated with principals.

Using attributes or templates to represent relationships

There are two main ways to express relationships between resources. When to use one or the other depends on whether or not the relation is already stored in your application database and used for other reasons such as compliance. If it is, take the attribute-based approach. If not, then take the template-based approach.

Attribute-based relationships

Attributes can be used as an input to the authorization decision to represent a relationship between a principal and one or more resources.

This pattern is appropriate where the relationship is tracked and managed for purposes beyond just permissions management. For example, recording the primary account holder is required for financial compliance with Know Your Customer rules. Permissions are derived from these relationships. The relationship data is managed outside of the authorization system, and fetched as an input when making an authorization decision.

The following example shows how a relationship between a user Alice and a number of accounts on which she is the primary account holder could be represented:

// Using a user attribute to represent the primary account holder relationship { "id": "df82e4ad-949e-44cb-8acf-2d1acda71798", "name": "alice", "email": "alice@example.com", "primaryOnAccounts": [ "Account::\"c943927f-d803-4f40-9a53-7740272cb969\"", "Account::\"b8ee140c-fa09-46c3-992e-099438930894\"" ] }

And, subsequently using the attribute within a policy:

// Derived relationship permissions permit ( principal, action in Action::"primaryAccountHolderActions", resource )when { resource in principal.primaryOnAccounts };

Conversely, the same relationship could be represented as an attribute on the resource called primaryAccountHolders that contains a set of users.

If there are multiple relationship types between principals and resources, then these should be modeled as different attributes. For example, if accounts can also have authorized signatories, and these individuals have different permissions on the account, then this would be represented as a different attribute.

In the above case, Alice might also be an authorized signatory on a third account. The following example shows how this could be represented:

// Using user attributes to represent the primary account holder and authorized signatory relationships { "id": "df82e4ad-949e-44cb-8acf-2d1acda71798", "name": "alice", "email": "alice@example.com", "primaryOnAccounts": [ "Account::\"c943927f-d803-4f40-9a53-7740272cb969\"", "Account::\"b8ee140c-fa09-46c3-992e-099438930894\"" ], "authorizedSignatoryOnAccounts": [ "Account::\"661817a9-d478-4096-943d-4ef1e082d19a\"" ] }

The following are the corresponding policies:

// Derived relationship permissions permit ( principal, action in Action::"primaryAccountHolderActions", resource )when { resource in principal.primaryOnAccounts }; permit ( principal, action in Action::"authorizedSignatoryActions", resource )when { resource in principal.authorizedSignatoryOnAccounts };

Template-based relationships

If the relationship between resources exists solely for the purpose of permissions management then it’s appropriate to store this relationship as a template-linked policy, or template. You can also think of these templates as roles that are assigned on a specific resource.

For example, in a document management system, the document owner, Alice, may choose to grant permission to another user, Bob, to contribute to the document. This establishes a contributor relationship between Bob and Alice’s document. The sole purpose of this relationship is to grant permission to edit and comment on the document, and hence this relationship can be represented as a template. In these cases the recommended approach is to create a template for each type of relationship. In the following examples there are two relationship types, Contributor and Reviewer, and therefore two templates.

The following templates can be used to create template-linked policies for individual users.

// Managed relationship permissions - Contributor template permit ( principal == ?principal, action in Action::"DocumentContributorActions", resource in ?resource ); // Managed relationship permissions - Reviewer template permit ( principal == ?principal, action in Action::"DocumentReviewerActions", resource in ?resource );

The following templates can be used to create template-linked policies for groups of users. The only difference from the templates for individual users is that use of the in operator instead of the ==.

// Managed relationship permissions - Contributor template permit ( principal in ?principal, action in Action::"DocumentContributorActions", resource in ?resource ); // Managed relationship permissions - Reviewer template permit ( principal in ?principal, action in Action::"DocumentReviewerActions", resource in ?resource );

You can then use these templates to create policies, like the following ones, representing managed relationship permissions each time access is granted to a document.

//Managed relationship permissions permit ( principal in User::"df82e4ad-949e-44cb-8acf-2d1acda71798", action in Action::"DocumentContributorActions", resource in Document::"c943927f-d803-4f40-9a53-7740272cb969" ); permit ( principal in UserGroup::"df82e4ad-949e-44cb-8acf-2d1acda71798", action in Action::"DocumentReviewerActions", resource == Document::"661817a9-d478-4096-943d-4ef1e082d19a" ); permit ( principal in User::"df82e4ad-949e-44cb-8acf-2d1acda71798", action in Action::"DocumentContributorActions", resource in Folder::"b8ee140c-fa09-46c3-992e-099438930894" );

Amazon Verified Permissions can efficiently handle many individual, fine-grained policies during authorization evaluation and modeling things in this way means that Verified Permissions maintains a full audit log, in AWS CloudTrail, of all authorization decisions.

Prefer fine-grained permissions in the model and aggregate permissions in the user interface

One strategy that designers often regret later is designing an authorization model with very broad actions, such as Read and Write, and realizing later that finer-grained actions are necessary. The need for finer granularity can be driven by customer feedback for more granular access controls, or by compliance and security auditors who encourage least-privilege permissions.

If fine-grained permissions are not defined upfront, it can require a complicated conversion to modify the application code and policy statements to user finer grained permissions. For example, application code that previously authorized against a course-grained action will need to be modified to use the fine-grained actions. In addition, policies will need to be updated to reflect the migration:

permit ( principal == User::"6688f676-1aa9-456a-acf4-228340b54e9d", // action == Action::"read", -- coarse-grained permission -- commented out action in [ // -- finer grained permissions Action::"listFolderContents", Action::"viewFile" ], resource in Account::"c863f89b-461f-4fc2-b638-e5fa5f79a48b" );

To avoid this costly migration, it's better to define fine-grained permissions upfront. However, this can result in a tradeoff if your end-users are subsequently forced to understand a larger number of fine-grained permissions, especially if most customers would be satisfied with course-grained controls such as Read and Write. To attain the best of both worlds, you can group fine-grained permissions into predefined collections such as Read and Write using mechanisms like policy templates or action groups. By using this approach, customers see only the course-grained permissions. But behind the scenes, you've future-proofed your application by modeling the course-grained permissions as a collection of fine-grained actions. When either customers or auditors ask for it, the fine-grained permissions can be exposed.

Consider other reasons to query authorization

We usually associate authorization checks with user requests. The check is a way to determine whether the user has permission to perform that request. However, you can also use authorization data to influence the design of the application's interface. For example, you might want to display a home screen that shows a list of only those resources that the end-user can access. When viewing the details of a resource, you might want the interface to show only those operations that the user can perform on that resource.

These situations can introduce tradeoffs into the authorization model. For example, heavy reliance on attributed-based access control (ABAC) policies can make it more difficult to quickly answer the question "who has access to what?" This is because answering that question requires examining each rule against every principal and resource to determine if there is a match. As a result, a product that needs to optimize for listing only those resources accessible by the user might choose to use a role-based access control (RBAC) model. By using RBAC, it can be easier to iterate over all the policies attached to a user to determine resource access.