Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENDOC-495 RBAC tutorial cleanup #506

Merged
merged 9 commits into from
Aug 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 151 additions & 63 deletions vuepress/docs/next/tutorials/create/ms/add-access-controls.md
Original file line number Diff line number Diff line change
@@ -1,109 +1,198 @@
---
sidebarDepth: 2
---
# Role Based Access Controls

## Overview
This tutorial guides you through adding access controls to your existing Entando project. Security experts recommend following a practice known as `Defense in Depth` where security controls are placed in each layer of an architecture. This tutorial will help you setup such controls in both the frontend and backend of your Entando application.
Experts recommend following a practice known as Defense in Depth where security controls are placed in each layer of an architecture. This tutorial guides you through adding access controls to your existing Entando project, in both the frontend and backend of your Entando Application.

For the purpose of this tutorial we'll use the simple Conference application from [this tutorial](./generate-microservices-and-micro-frontends.md) as a starting point. Please work through that tutorial if you have not already.
The simple Conference application found in the [Generate Microservices and Micro Frontends tutorial](./generate-microservices-and-micro-frontends.md) is used as a starting point. We recommend working through that tutorial for background and context.

The basic security setup for a blueprint-generated application allows any authenticated user to access the functionality contained in the MFEs and/or microservices. Our business requirement for this tutorial is to define two kinds of users in our application - `Conference Users` who can view the Conferences in the tableWidget, and `Conference Admins` who can view and also delete Conferences from the tableWidget.
The basic security setup for a blueprint-generated application allows any authenticated user to access the functionality contained in the MFEs and/or microservices. This tutorial defines two user roles for our application:

## Tutorial
Let's start by securing the list of Conferences so only our two user roles can view the list.
- `conference-user`: Permitted to view the Conferences in the tableWidget
- `conference-admin`: Permitted to view Conferences in the tableWidget, and also to delete Conferences from the tableWidget

1. Edit `ConferenceResource.java` located in the `src/main/java/com/<ORG>/<NAME>.web.rest` directory. Modify the REST API `Conference:getAllConferences` method by adding the following annotation.

## Apply and Verify Access Controls

### Step 1: Secure the Conference list

The list of Conferences must be visible to only the `conference-user` and `conference-admin` user roles.

1. Go to the `src/main/java/com/YOUR-ORG/YOUR-NAME/web/rest` directory
2. Open `ConferenceResource.java`
3. Add the following to the list of imports:
```java
import org.springframework.security.access.prepost.PreAuthorize;
```
4. Modify the REST API `Conference:getAllConferences` method by preceding it with the @PreAuthorize annotation. Your method signature may be different depending on your blueprint selections.
```java{1}
@PreAuthorize("hasAnyAuthority('conference-user','conference-admin')")
public List<Conference> getAllConferences() {
public List<Conference> getAllConferences()
```
This confines use of the `getAllConferences` method to users who are assigned either the `conference-user` or the `conference-admin` role on the Keycloak client configured for the microservice.

> Note: In local testing, the default client is `internal`. Refer to the [Spring Security documentation](https://spring.io/projects/spring-security) for more information.

We also need to modify the blueprint JWT handling to deal with a recent change to the Spring libraries.

5. Edit `src/main/java/com/mycompany/myapp/config/SecurityConfiguration.java` and make two changes.
* Add this code after the other @Value fields
``` java
@Value("${spring.security.oauth2.client.registration.oidc.client-id}")
private String clientId;
```
* Modify the following call in the `authenticationConverter` method to provide the clientId field
``` java
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(new JwtGrantedAuthorityConverter(clientId));
```
6. Now modify
`src/main/java/com/mycompany/myapp/security/oauth2/JwtGrantedAuthorityConverter.java` to accept the clientId. Three changes are required.
* Remove the @Component annotation on the class definition
```java{1}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for the most part, the instructions reads much clearer now, especially with the highlighted code and maybe I've become more familiar with it. I'm guessing those familiar with Java will not flounder as we did. But wondering can annotation refer to multiple lines? Annotation to me could be single or many lines and that made it hard to get a firm idea of what to implement or look for. But if annotation is commonly used to refer to single lines of code, then this is a moot point.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jyunmitch Annotation is a Java feature, indicated by a line beginning with @something. I'm reasonably sure it cannot be multi-line, certainly 99% of annotations are one line even if the spec allows more. https://docs.oracle.com/javase/tutorial/java/annotations/basics.html

@Component
public class JwtGrantedAuthorityConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
```
* Remove the @Value annotation on the clientId field
```java{1}
@Value("${spring.security.oauth2.client.registration.oidc.client-id}")
private String clientId;
```
* Modify the constructor to accept the clientId
```java
public JwtGrantedAuthorityConverter(String clientId) {
this.clientId = clientId;
}
```
See the [Spring Security documentation](https://spring.io/projects/spring-security) for more details but this restricts use of the `getConference` method to users who have been assigned either the `conference-user` or the `conference-admin` role on the Keycloak client configured for the microservice. In local testing this defaults to the `internal` client but see notes below on how that works in production.

Now we should verify this security check is working.
### Step 2: Run your project in a local developer environment
The following commands must be run from your project directory. They leverage the [ent CLI](../../../docs/reference/entando-cli.md).

2. Start up your Keycloak, tableWidget MFE, and microservice. See [these instructions](./run-local.md) if you need a refresher but these are the basic commands using the ent CLI and Docker for keycloak.
```
> Note: Refer to the [Run Blueprint-generated Microservices and Micro Frontends in Dev Mode tutorial](./run-local.md) for details.

1. Start up your Keycloak instance
``` sh
ent prj ext-keycloak start
ent prj be-test-run
```
Using a separate cmdline:
2. Start the microservice in another shell
``` sh
ent prj be-test-run
```
3. Start the tableWidget MFE in a third shell
``` sh
ent prj fe-test-run
```
4. When prompted to select a widget to run, choose the option corresponding to the tableWidget, e.g. `ui/widgets/conference/tableWidget`

### Step 3: Access the tableWidget MFE

3. Access the tableWidget MFE, typically on <http://localhost:3000>, using the default admin/admin account.
1. In your browser, go to <http://localhost:3000>. This is typically the location of the tableWidget MFE.
2. Access the tableWidget MFE with the default credentials of `username: admin`, `password: admin`

Once authenticated, you'll get the message "No conferences are available" and, if you check your browser console, you should see a `403 (Forbidden)` error for the request made to `localhost:8080/services/conference/api/conferences`. This is expected because we have not yet granted the new role to the admin user.
> Note: Once authenticated, the message "No conferences are available" is generated. If you check your browser
console, you should see a `403 (Forbidden)` error for the request made to `localhost:8080/services/conference/api/conferences`. This is expected because the admin user has not yet been granted the new role.

Now let's give the admin user the correct role.
### Step 4: Login to Keycloak

4. Login to keycloak on <http://localhost:9080> using the `admin/admin` credentials.
1. Go to <http://localhost:9080>
2. Login using the the default credentials of `username: admin`, `password: admin`

First we need to create the two roles per our requirements. We're going to add the roles to the `internal` client because it's the one configured by default in the Spring Boot application.yml.
nshaw marked this conversation as resolved.
Show resolved Hide resolved
### Step 5: Create the `conference-user` and `conference-admin` roles

Add the `conference-user` and `conference-admin` roles to the `internal` client.

5. Go to `Clients → internal → Roles` and click `Add Role`
6. Fill in the `Role Name` with `conference-admin` and click `Save`
7. Repeat steps 5-6 to create the `conference-user` role.
1. Go to `Clients` → `internal` → `Roles`
2. Click `Add Role`
3. Fill in the `Role Name` with `conference-admin`
4. Click `Save`
5. Repeat these steps to create the `conference-user` role

Now we need to map this role to our user.
> Note: The `internal` client is configured by default in the Spring Boot `application.yml`.

8. Go to `Users → View all users → admin → Role Mappings`
9. Select `internal` for the `Client Roles` and then move `conference-user` from `Available Roles` to `Assigned Roles`
10. Go back to the MFE and you should now see the full list of Conferences.
### Step 6: Map the `conference-user` role to the admin user

We've now successfully secured the `getAllConferences` API but we have more to do. The admin user was granted just the `conference-user` role but still has access to delete Conferences. We need to lock that down.
To grant access to the `getAllConferences` API:

11. Go back into the `ConferenceResource.java` file and add this annotation to the `deleteConference` method:
1. Go to `Users` → `View all users` → `admin` → `Role Mappings`
2. Select `internal` for the `Client Roles`
3. Move `conference-user` from `Available Roles` to `Assigned Roles`
4. Return to the MFE to confirm you see the full list of Conferences

```
### Step 7: Restrict the ability to delete Conferences

The `conference-admin` role should grant a user permission to delete Conferences. To restrict the delete method to the `conference-admin` role:

1. Go to the `src/main/java/com/YOUR-ORG/YOUR-NAME/web/rest` directory
2. Open `ConferenceResource.java`
3. Modify the `deleteConference` method by preceding it with the following annotation:
jyunmitch marked this conversation as resolved.
Show resolved Hide resolved
```java{1}
@PreAuthorize("hasAuthority('conference-admin')")
public ResponseEntity<Void> deleteConference(@PathVariable Long id) {
public ResponseEntity<Void> deleteConference(@PathVariable Long id)
```
Here we're restricting the delete method to only the `conference-admin` role.

12. Restart the microservice. By default this will include rebuilding any changed source files.
13. Once the microservice is available, go back to the MFE and try deleting one of the Conferences in the list. You should be able to attempt the delete in the UI but you'll get a 403 error in the browser console and an error like this in the service logs:
To verify that a user without the `conference-admin` role is unable to call the delete API:

1. Restart the microservice. By default this includes rebuilding any changed source files.
2. Once the microservice is available, return to the MFE and try deleting one of the Conferences in the list
3. Verify that attempting to delete via the UI generates a `403 error` in the browser console and an error in the service logs similar to the following:
```
2021-03-22 15:56:16.205 WARN 3208 --- [ XNIO-1 task-3] o.z.problem.spring.common.AdviceTraits : Forbidden: Access is denied
WARN 3208 --- [ XNIO-1 task-3] o.z.problem.spring.common.AdviceTraits : Forbidden: Access is denied
```
That's exactly what we wanted! This demonstrates that a user without `conference-admin` is unable to call the delete API.
nshaw marked this conversation as resolved.
Show resolved Hide resolved

Next, let's update the MFE so a user without the `conference-admin` authority cannot even see the delete button in the UI.
### Step 8: Hide the delete button

14. Edit the `ConferenceTableContainer.js` under `ui/widgets/conference/tableWidget/src/components`. Replace the onDelete logic with an additional check on the user's authorities.
```
const isAdmin = (keycloak && keycloak.authenticate) ? keycloak.hasResourceRole("conference-admin", "internal"): false;
The MFE UI can be updated to hide the delete button from a user without the `conference-admin` authority. The key logic checks whether the `internal` client role `conference-admin` is mapped to the current user via the hasResourceRole call.

1. Go to the `ui/widgets/conference/tableWidget/src/components` directory
2. Open `ConferenceTableContainer.js`
3. Replace the `onDelete` logic with an additional user permission:
```javascript
const isAdmin = (keycloak && keycloak.authenticated) ? keycloak.hasResourceRole("conference-admin", "internal"): false;
const showDelete = onDelete && isAdmin;

const Actions = ({ item }) =>
showDelete ? (
```
4. Confirm that the delete icon is no longer visible in the MFE. The MFE should have automatically reloaded to reflect the code changes.

The key logic there is the hasResourceRole call which checks whether the `internal` client role `conference-admin` was mapped to the current user.

15. View the MFE (whch should have automatically reloaded) and you should see that the delete icon is no longer visible, matching the admin's current permissions. We've now verified that a user with just `conference-user` can neither see the delete action in the UI nor call its corresponding API.

Next, let's promote the admin user to a full `conference-admin` so they can delete Conferences.
### Step 9: Grant and verify delete permissions
nshaw marked this conversation as resolved.
Show resolved Hide resolved

16. Go back into Keycloak at <http://localhost:9080>, then go to `Users → View all users → admin → Role Mappings`, and also give the user the `conference-admin` role.
Promote the admin user to a full `conference-admin` to reinstate the ability to delete Conferences.

17. Reload the MFE. The delete icons should now be visible and you should be able to successfully delete a Conference from the list. This satisfies our original business requirement.
1. Return to Keycloak at <http://localhost:9080>
2. Go to `Users` → `View all users` → `admin` → `Role Mappings`
3. Give the user the `conference-admin` role
4. Reload the MFE
5. Confirm the delete icon is visible
6. Confirm a Conference can be successfully deleted from the list

## Notes
### Realm Roles versus Client Authorities
This tutorial made use of authorities which in Keycloak are Roles mapped to a User for a specific Client. You could also make use of higher-level Realm Roles assigned directly to users, e.g. `ROLE_ADMIN`. That will work but can result in collisions between applications if they happen to use the same roles.
This tutorial utilizes authorities. In Keycloak, authorities are roles mapped to a user for a specific client. It is possible to assign higher-level Realm Roles directly to users, e.g. `ROLE_ADMIN`, but this can result in collisions between applications using the same roles.

If you choose to use Realm-assigned roles then the code above would need to change. In the backend, use the following annotations: `@Secured('ROLE_ADMIN)` or `@PreAuthorize(hasRole('ROLE_ADMIN'))`. In the frontend, use `keycloak.hasRealmRole` instead of `keycloak.hasResourceRole`. See the [Spring Security page](https://www.baeldung.com/spring-security-check-user-role) for more examples.
To implement Realm-assigned roles, the code above must be modified:
- In the backend, use the annotation `@Secured('ROLE_ADMIN)` or `@PreAuthorize(hasRole('ROLE_ADMIN'))`
nshaw marked this conversation as resolved.
Show resolved Hide resolved
- In the frontend, use `keycloak.hasRealmRole` instead of `keycloak.hasResourceRole`

### Local versus Kubernetes Testing
This tutorial also makes use of the `internal` client configured in the microservice via the application.yml with roles manually created and assigned in Keycloak. In Kubernetes, Entando will automatically create client roles per the bundle plugin definition (see the plugin definition [here](../../../docs/curate/ecr-bundle-details.md) for more information). Those roles will be created for the client specific to the microservice itself, e.g. `<docker username>-conference-server`. This client name will be injected as an environment variable into the plugin container itself so the annotations noted above will work both in local and Kubernetes environments.
See the [Spring Security page](https://www.baeldung.com/spring-security-check-user-role) for more examples.

The MFE authorization checks in the tutorial explicitly note the client id, e.g. `internal`, which won't work in Kubernetes. There are a couple options here:
1) Change the application.yml clientId under `security.oauth2.client.registration.oidc` to match the Kubernetes clientId. That's the most secure and allows the MFE checks to work the same in both local and Kubernetes environments. However, you not be be able to use the same clientId depending on how the microservice is deployed
2) An alternative is to broaden the MFE authorization check to look for a named role on any client. This could result in overlap with other clients but with appropriately named roles (e.g. prefixed by feature, e.g. `conference-admin`) this could be the most flexible option. This can be provided via a helper function, e.g. in `api/helpers.js`:
```
//Check if the authenticated user has the clientRole for any keycloak clients
### Local vs. Kubernetes Testing
This tutorial leverages the `internal` client, which is configured in the microservice via the `application.yml`. Client roles are manually created and assigned in Keycloak.

In Kubernetes, Entando will automatically create client roles per the bundle plugin definition (see the [plugin definition](../../../docs/curate/ecr-bundle-details.md) for more information). These roles are created for the client specific to the microservice, e.g. `<docker username>-conference-server`. The client name is injected as an environment variable into the plugin container, so the annotations noted above will work in both local and Kubernetes environments.

#### Modify Security Checks for Kubernetes

In this tutorial, the MFE authorization checks explicitly note the client ID, e.g. `internal`. The following options modify the checks to work in Kubernetes:

1) Change the `application.yml` client ID under `security.oauth2.client.registration.oidc` to match the Kubernetes client ID.

This is the most secure option and allows the MFE checks to work identically in both local and Kubernetes environments. However, you may not be be able to use the same clientId, depending on how the microservice is deployed.
nshaw marked this conversation as resolved.
Show resolved Hide resolved

2) Broaden the MFE authorization check to look for a named role on any client.

This could result in overlap with other clients, but this is the most flexible option when using appropriately named roles (e.g. with a bundle or feature prefix like `conference-` in `conference-admin`). It can be achieved via a helper function, e.g. `api/helpers.js`, and results in a simpler role check:
```javascript
// Add helper function
// Check if the authenticated user has the clientRole for any Keycloak clients
nshaw marked this conversation as resolved.
Show resolved Hide resolved
export const hasKeycloakClientRole = clientRole => {
if (getKeycloakToken()) {
const { resourceAccess } = window.entando.keycloak;
Expand All @@ -119,11 +208,10 @@ export const hasKeycloakClientRole = clientRole => {
}
return false;
};
```
This would result in a simpler role check:
```
const isAdmin = hasKeycloakClientRole('conference-admin');

// Perform role check
nshaw marked this conversation as resolved.
Show resolved Hide resolved
const isAdmin = hasKeycloakClientRole('conference-admin');
```

### Debugging
In both local and Kubernetes environments, the default blueprint javascript will make a global variable available in the browser, e.g. `window.entando.keycloak`. Examining this variable can help diagnose issues with assigned roles and authorities. In some cases you may need to logout of Entando and re-authenticate in order to get the latest role assignments.
### Troubleshooting
In both local and Kubernetes environments, the default Blueprint Javascript provides a global variable in the browser, e.g. `window.entando.keycloak`. Examining this variable can help diagnose issues with assigned roles and authorities. In some cases, you may need to logout of Entando and reauthenticate for the latest role assignments to be applied.
Loading