Implementing Role Based Access Control
- Introduction
- Goals
- Prerequisites
- Configuring the Project
- Updating the Project
- Building the Component
- Deploying the Component
- Testing the Component
- Conclusion
Introduction
All the Hello World Service requests we have made so far were insecure because we have not passed an access token in the header. In this guide we will add that security and show how to validate the access token and get a list of permissions associated with it.
Goals
- secure the API requests
- validate the access token
- define and enforce RBAC rules
Prerequisites
Configuring the Project
Adding security to the Hello World Service is an exercise in configuration. In addition to updating some existing files, we will also add some new ones. Take note of the vertical ellipsis which are used to demarcate partial updates.
requirements.txt
An MSX integration library is required to support RBAC (roles based access control) and Tenancy. We declare that dependency in requirements.txt
as shown:
Flask==1.1.2
Flask-Cors==3.0.10
flask-restplus==0.13.0
Werkzeug==0.16.1
psycopg2-binary==2.9.1
PyYAML==5.4.1
python-consul==1.1.0
urllib3==1.26.5
hvac==0.10.14
msxswagger @ git+https://github.com/CiscoDevNet/python-msx-swagger@v0.6.0
msxsecurity @ git+https://github.com/CiscoDevNet/python-msx-security@v0.2.0
Dockerfile
The MSX Swagger package is hosted on GitHub, so we have to make some changes to the Dockerfile
so that it can be installed in the container.
FROM --platform=linux/amd64 python:3.9.6-slim-buster
WORKDIR /app
ADD . /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& apt-get purge -y --auto-remove \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install -r requirements.txt
EXPOSE 8082
ENTRYPOINT ["flask", "run", "--host", "0.0.0.0", "--port", "8082"]
helloworld.yml
When a service is deployed to MSX it will pick up the Security configuration from Consul and Vault. When developing locally you can pass values in helloworld.yml
instead.
.
.
.
security:
ssourl: "http://localhost:9515/idm" # CONSUL {prefix}/defaultapplication/swagger.security.sso.baseUrl
clientid: # CONSUL {prefix}/helloworldservice/integration.security.clientId
clientsecret: # VAULT {prefix}/helloworldservice/integration.security.clientSecret
sslverify: false
.
.
.
manifest.yml
For MSX <= 4.2 update manifest.yml
to include configuration for the public security client identifier required by Swagger (help me).
For MSX >= 4.3 the security client will be created for you automatically.
#
# Copyright (c) 2021 Cisco Systems, Inc and its affiliates
# All Rights reserved
#
---
Name: "helloworldservice"
Description: "Hello World service with support for multiple languages."
Version: "1.0.0"
Type: Internal
Containers:
- Name: "helloworldservice"
Version: "1.0.0"
Artifact: "helloworldservice-1.0.0.tar.gz"
Port: 8082
ContextPath: "/helloworld"
Tags:
- "3.10.0"
- "4.0.0"
- "4.1.0"
- "4.2.0"
- "4.3.0"
- "5.0.0"
- "managedMicroservice"
- "name=Hello World Service"
- "componentAttributes=serviceName:helloworldservice~context:/helloworld~name:Hello World Service~description:Hello World service with support for multiple languages."
Check:
Http:
Scheme: "http"
Host: "127.0.0.1"
Path: "/helloworld/api/v1/items"
IntervalSec: 60
InitialDelaySec: 30
TimeoutSec: 30
Limits:
Memory: "1000Mi"
CPU: "1"
Command:
- "/usr/local/bin/gunicorn"
- "--bind"
- "0.0.0.0:8082"
- "wsgi:app"
ConfigFiles:
- Name: "helloworld.yml"
MountTo:
Container: "helloworldservice"
Path: "/helloworld.yml"
ConsulKeys:
- Name: "favourite.color"
Value: "Green"
- Name: "favourite.food"
Value: "Pizza"
- Name: "favourite.dinosaur"
Value: "Moros Intrepidus"
# NOT NEEDED FOR MSX >= 4.3
# - Name: "public.security.clientId"
# Value: "hello-world-service-public-client"
# - Name: "integration.security.clientId"
# Value: "hello-world-service-private-client"
Secrets:
- Name: "secret.squirrel.location"
Value: "The acorns are buried under the big oak tree!"
# NOT NEEDED FOR MSX >= 4.3
# - Name: "integration.security.clientSecret"
# Value: "make-up-a-private-client-secret-and-keep-it-safe"
Infrastructure:
Database:
Type: Cockroach
Name: "helloworld"
Updating the Project
models/error.py
So far all the Hello World Service responses have been fixed. As we are going to introduce RBAC we need to declare the error model defined in the contract, so we return suitable responses.
class Error:
def __init__(self, code=None, message=None):
self._code = code
self._message = message
def to_dict(self):
return {
"code": self._code,
"message": self._message,
}
controllers/languages_controller.py
Hello World Service uses GET /helloworld/api/v1/items
for the health check, so we cannot add security to that endpoint. Instead, we secure the Languages
controller updating it as follows:
import http
import logging
import flask
from flask_restplus import Resource
from flask_restplus import reqparse
from models.error import Error
from models.language import Language
from config import Config
from helpers.cockroach_helper import CockroachHelper
LANGUAGE_INPUT_ARGUMENTS = ['name', 'description']
LANGUAGE_NOT_FOUND = 'Language not found'
def get_access_token():
# Authorization: Bearer MY_ACCESS_TOKEN
return flask.request.headers.get("Authorization", "")[7:]
class LanguagesApi(Resource):
def __init__(self, *args, **kwargs):
self._config = kwargs["config"]
self._security = kwargs["security"]
def get(self):
if not self._security.has_permission("HELLOWORLD_READ_LANGUAGE", get_access_token()):
return Error(code="my_error_code", message="permission denied").to_dict(), 403
with CockroachHelper(self._config) as db:
rows = db.get_rows('Languages')
logging.info(rows)
languages = [Language(row=x) for x in rows]
return [x.to_dict() for x in languages], http.HTTPStatus.OK
def post(self):
if not self._security.has_permission("HELLOWORLD_WRITE_LANGUAGE", get_access_token()):
return Error(code="my_error_code", message="permission denied").to_dict(), 403
parser = reqparse.RequestParser()
[parser.add_argument(arg) for arg in LANGUAGE_INPUT_ARGUMENTS]
args = parser.parse_args()
logging.info(args)
with CockroachHelper(self._config) as db:
row = db.insert_row('Languages', args)
return Language(row=row).to_dict(), http.HTTPStatus.CREATED
class LanguageApi(Resource):
def __init__(self, *args, **kwargs):
self._config = kwargs["config"]
self._security = kwargs["security"]
def get(self, id):
if not self._security.has_permission("HELLOWORLD_READ_LANGUAGE", get_access_token()):
return Error(code="my_error_code", message="permission denied").to_dict(), 403
with CockroachHelper(self._config) as db:
row = db.get_row('Languages', id)
if not row:
return LANGUAGE_NOT_FOUND, http.HTTPStatus.NOT_FOUND
return Language(row=row).to_dict(), http.HTTPStatus.OK
def put(self, id):
if not self._security.has_permission("HELLOWORLD_WRITE_LANGUAGE", get_access_token()):
return Error(code="my_error_code", message="permission denied").to_dict(), 403
parser = reqparse.RequestParser()
[parser.add_argument(arg) for arg in LANGUAGE_INPUT_ARGUMENTS]
args = parser.parse_args()
logging.info(args)
with CockroachHelper(self._config) as db:
row = db.update_row('Languages', id, args)
if not row:
return LANGUAGE_NOT_FOUND, http.HTTPStatus.NOT_FOUND
return Language(row=row).to_dict(), http.HTTPStatus.OK
def delete(self, id):
if not self._security.has_permission("HELLOWORLD_WRITE_LANGUAGE", get_access_token()):
return Error(code="my_error_code", message="permission denied").to_dict(), 403
with CockroachHelper(self._config) as db:
result = db.delete_row("Languages", id)
if result == "DELETE 1":
return None, http.HTTPStatus.NO_CONTENT
return LANGUAGE_NOT_FOUND, http.HTTPStatus.NOT_FOUND
We have added __init__
methods to both classes, which take an MSXSecurity
object as an argument. The convenience method has_permission
checks if the user has a given permission, takes the permission name and MSX access token as arguments. You can pull the MSX access token out of the HTTP Authorization header.
If you want to implement Tenancy use has_tenant
, passing it the tenant identifier you care about.
helpers/security_helper.py
The module helpers/security_helper.py
provides the code to support RBAC and Tenancy.
from msxsecurity import MSXSecurityConfig
from config import Config
from helpers.consul_helper import ConsulHelper
from helpers.vault_helper import VaultHelper
class SecurityHelper(object):
def __init__(self, config: Config, consul_helper: ConsulHelper, vault_helper: VaultHelper):
self._config = config
self._consul_helper = consul_helper
self._vault_helper = vault_helper
def get_config(self, cache_enabled, cache_ttl_seconds):
sso_url = self._consul_helper.get_string(
key=f"{self._config.config_prefix}/defaultapplication/swagger.security.sso.baseUrl",
default=self._config.security.ssourl)
client_id = self._consul_helper.get_string(
key=f"{self._config.config_prefix}/helloworldservice/integration.security.clientId",
default=self._config.security.clientid)
client_secret = self._vault_helper.get_string(
secret=f"{self._config.config_prefix}/helloworldservice",
key="integration.security.clientSecret",
default=self._config.security.clientsecret)
ssl_verify = self._vault_helper.get_string(
secret=f"{self._config.config_prefix}/helloworldservice",
key="integration.security.sslVerify",
default=self._config.security.sslverify)
# 20220524 - Temporary workaround for key issue.
if not client_id:
client_id = self._consul_helper.get_string(
key=f"{self._config.config_prefix}/helloworldservice/integration.security.client.clientId",
default=self._config.security.clientid)
if not client_secret:
client_secret = self._vault_helper.get_string(
secret=f"{self._config.config_prefix}/helloworldservice",
key="integration.security.client.clientSecret",
default=self._config.security.clientsecret)
return MSXSecurityConfig(
sso_url=sso_url,
client_id=client_id,
client_secret=client_secret,
ssl_verify=ssl_verify,
cache_enabled=cache_enabled,
cache_ttl_seconds=cache_ttl_seconds)
config.py
In previous guides we created config.py
to bootstrap Consul and Vault into our service. That same module also serves as a common place for us to store other configuration. Update config.py
to include a structure to store the Security values. Note that they will be populated from Consul, Vault, and helloworld.yml
, depending on whether your service is running on local infrastructure or in an MSX environment.
Add a named tuple to config.py
for the Security configuration:
.
.
.
ConsulConfig = namedtuple("ConsulConfig", ["host", "port", "cacert"])
VaultConfig = namedtuple("VaultConfig", ["scheme", "host", "port", "token", "cacert"])
CockroachConfig = namedtuple("CockroachConfig", ["host", "port", "databasename", "username", "sslmode", "cacert"])
SwaggerConfig = namedtuple("SwaggerConfig", ["rootpath", "secure", "ssourl", "clientid", "swaggerjsonpath"])
SecurityConfig = namedtuple("SecurityConfig", ["ssourl", "clientid", "clientsecret", "sslverify"])
.
.
.
Then populate it in the __init__
method:
def __init__(self, resource_name):
.
.
.
# Create Cockroach config object.
self.cockroach = CockroachConfig(**config["cockroach"])
# Create Swagger config object.
self.swagger = SwaggerConfig(**config["swagger"])
# Create Security config object.
self.security = SecurityConfig(**config["security"])
.
.
.
app.py
We updated the Languages
controller to take an MSXSecurity
object, but we have not created it yet. The changes to app.py
below configure that instance and pass it to the controller.
import logging
from flask import Flask
from msxsecurity import MSXSecurity
from msxswagger import MSXSwaggerConfig
from config import Config
from controllers.items_controller import ItemsApi, ItemApi
from controllers.languages_controller import LanguageApi, LanguagesApi
from helpers.consul_helper import ConsulHelper
from helpers.security_helper import SecurityHelper
from helpers.swagger_helper import SwaggerHelper
from helpers.vault_helper import VaultHelper
from helpers.cockroach_helper import CockroachHelper
config = Config("helloworld.yml")
consul_helper = ConsulHelper(config.consul)
vault_helper = VaultHelper(config.vault)
swagger_helper = SwaggerHelper(config, consul_helper)
security_helper = SecurityHelper(config, consul_helper, vault_helper)
app = Flask(__name__)
consul_helper.test()
vault_helper.test()
with CockroachHelper(config) as db:
db.test()
swagger = MSXSwaggerConfig(
app=app,
documentation_config=swagger_helper.get_documentation_config(),
swagger_resource=swagger_helper.get_swagger_resource())
security = MSXSecurity(
config=security_helper.get_config(cache_enabled=True, cache_ttl_seconds=300))
swagger.api.add_resource(ItemsApi, "/api/v1/items", resource_class_kwargs={"config": config})
swagger.api.add_resource(ItemApi, "/api/v1/items/<id>", resource_class_kwargs={"config": config})
swagger.api.add_resource(LanguagesApi, "/api/v1/languages", resource_class_kwargs={"config": config, "security": security})
swagger.api.add_resource(LanguageApi, "/api/v1/languages/<id>", resource_class_kwargs={"config": config, "security": security})
app.register_blueprint(swagger.api.blueprint)
if __name__ == '__main__':
app.run()
Building the Component
Like we did in earlier guides build the component helloworldservice-1.0.0-component.tar.gz
by calling make with component “NAME” and “VERSION” parameters. If you do not see helloworld.yml
being added to the tarball you need to back and check the Makefile.
$ make NAME=helloworldservice VERSION=1.0.0
.
.
.
docker save helloworldservice:1.0.0 | gzip > helloworldservice-1.0.0.tar.gz
tar -czvf helloworldservice-1.0.0-component.tar.gz manifest.yml helloworld.yml helloworldservice-1.0.0.tar.gz
a manifest.yml
a helloworld.yml
a helloworldservice-1.0.0.tar.gz
rm -f helloworldservice-1.0.0.tar.gz
Deploying the Component
Log in to your MSX environment and deploy helloworldservice-1.0.0-component.tar.gz
using MSX UI->Settings->Components (help me). If the helloworldservice is already deployed, delete it before uploading it again.
Testing the Component
Looking at the code above in app.py
you can see that we only secured the Languages controller. So you can still make insecure Item requests like this.
$ export MY_MSX_HOSTNAME=dev-plt-aio1.lab.ciscomsx.com
$ curl --insecure --request GET https://$MY_MSX_HOSTNAME/helloworld/api/v1/items
[
{
"id": "68963944-a88c-4e39-98fd-d77878231d81",
"language_id": "01f643a5-7e34-4366-af1a-9cce5e5c68e8",
"language_name": "English", "value": "Hello, World!"
},
{
"id": "62ef8e5f-628a-4f8b-92c9-485981205d92",
"language_id": "55f3028f-1b94-4edd-b14f-183b51b33d68",
"language_name": "Russian",
"value": "\u041f\u0440\u0438\u0432\u0435\u0442 \u043c\u0438\u0440!"}
]
However, if you try to get a collection of Languages without passing an access token, you will get a “permission denied” response.
$ export MY_MSX_HOSTNAME=dev-plt-aio1.lab.ciscomsx.com
$ curl --insecure --request GET https://$MY_MSX_HOSTNAME/helloworld/api/v1/languages
{
"code": "my_error_code",
"message": "permission denied"
}
If you log in to the Cisco MSX Portal as superuser and go to the Swagger documentation for the Hello World Service, you will be able to make a request that works because the superuser can do everything. To restrict access to the API, we need to create some roles and permissions then assign them to a user.
Creating Custom Permissions
To keep things simple, we will use Swagger to create the Permissions.
Capabilities are synonymous with Permissions in the UI, so use the payload below with Swagger -> IDM Microservice -> Roles -> POST /idm/api/v1/roles/capabilities to create the Permissions.
{
"capabilities": [
{
"name": "HELLOWORLD_WRITE_LANGUAGE",
"displayName": "com.example.helloworldservice.HELLOWORLD_WRITE_LANGUAGE",
"description": "Permission to write Hello World Language resources."
},
{
"name": "HELLOWORLD_READ_LANGUAGE",
"displayName": "com.example.helloworldservice.HELLOWORLD_READ_LANGUAGE",
"description": "Permission to read Hello World Language resources."
},
{
"name": "HELLOWORLD_WRITE_ITEM",
"displayName": "com.example.helloworldservice.HELLOWORLD_WRITE_ITEM",
"description": "Permission to write Hello World Item resources."
},
{
"name": "HELLOWORLD_READ_ITEM",
"displayName": "com.example.helloworldservice.HELLOWORLD_READ_ITEM",
"description": "Permission to read Hello World Item resources."
}
]
}
The response will look like this but with different identifiers.
{
"capabilities": [
{
"id": "2c6cfb30-3f2d-11eb-8762-6dbfa7fa7420",
"name": "HELLOWORLD_WRITE_LANGUAGE",
"displayName": "com.example.helloworldservice.HELLOWORLD_WRITE_LANGUAGE",
"description": "Permission to write Hello World Language resources.",
"isSeeded": "false",
"owner": "system",
"category": null,
"objectName": null,
"operation": null,
"isDefault": null,
"resources": null
},
{
"id": "2c722b50-3f2d-11eb-8762-6dbfa7fa7420",
"name": "HELLOWORLD_READ_LANGUAGE",
"displayName": "com.example.helloworldservice.HELLOWORLD_READ_LANGUAGE",
"description": "Permission to read Hello World Language resources.",
"isSeeded": "false",
"owner": "system",
"category": null,
"objectName": null,
"operation": null,
"isDefault": null,
"resources": null
},
{
"id": "2c73b1f0-3f2d-11eb-8762-6dbfa7fa7420",
"name": "HELLOWORLD_WRITE_ITEM",
"displayName": "com.example.helloworldservice.HELLOWORLD_WRITE_ITEM",
"description": "Permission to write Hello World Item resources.",
"isSeeded": "false",
"owner": "system",
"category": null,
"objectName": null,
"operation": null,
"isDefault": null,
"resources": null
},
{
"id": "2c755fa0-3f2d-11eb-8762-6dbfa7fa7420",
"name": "HELLOWORLD_READ_ITEM",
"displayName": "com.example.helloworldservice.HELLOWORLD_READ_ITEM",
"description": "Permission to read Hello World Item resources.",
"isSeeded": "false",
"owner": "system",
"category": null,
"objectName": null,
"operation": null,
"isDefault": null,
"resources": null
}
]
}
Creating Custom Roles
Now that we have some Permissions we can create an administration role with read/write access to the Language resources, and a consumer role with read-only access.
Create the consumer role with read-only access with the following payload, and an owner
of helloworld
using Swagger -> IDM Microservice -> Roles -> POST /idm/api/v1/roles.
{
"roleName": "HELLOWORLD_CONSUMER",
"description": "A consumer role for the Hello World Service.",
"capabilitylist": [
"HELLOWORLD_READ_LANGUAGE",
"HELLOWORLD_READ_ITEM"
],
"displayName": "Hello World Consumer"
}
Save the response, as we will need the roleid
when we create the user in the next step. Note that the roleid
from your system will be different.
{
"status": "Success",
"href": "/v1/roles/HELLOWORLD_CONSUMER",
"roleid": "1811c107-9433-4285-872b-84d6130c8dcf",
"roleName": "HELLOWORLD_CONSUMER",
"capabilitylist": [
"HELLOWORLD_READ_ITEM",
"HELLOWORLD_READ_LANGUAGE"
],
"displayName": "Hello World Consumer",
"description": "A consumer role for the Hello World Service.",
"isSeeded": "false",
"owner": "helloworld",
"resourceDescriptor": null
}
Creating the administration role is left as an exercise for the reader. You need to update the name and description in the original payload and add the other Permissions.
Creating a Special User
We still need to create a user that is assigned the Role HELLOWORLD_CONSUMER
, but for it to have access to the Cisco MSX Portal we also need to give it the OPERATOR
role.
Use Swagger -> IDM Microservice -> Roles -> GET /idm/api/v1/roles/{name} in the Swagger documentation to look up the role identifier for OPERATOR
. On the system we used that requests looks like as follows, but your access token and response will be different.
$ export MY_MSX_HOSTNAME=dev-plt-aio1.lab.ciscomsx.com
$ curl -k -X GET "https://$MY_MSX_HOSTNAME/idm/api/v1/roles/OPERATOR" \
-H "accept: application/json" \
-H "Authorization: Bearer eyJhb…truncated…abc"
You now have role identifiers for HELLOWORLD_CONSUMER
and OPERATOR
which we can use to create a user.
Expand the Swagger documentation for Users and find Swagger -> IDM Microservice -> Users -> POST /idm/api/v8/users”, plug your role identifiers into the payload below, then call it.
{
"email": "nobody@example.com",
"firstName": "Jeff",
"lastName": "Pop",
"password": "Password@1",
"passwordPolicyName": "ppolicy_default",
"roleIds": [
"1811c107-9433-4285-872b-84d6130c8dcf",
"d6660cd0-38cf-11eb-9843-0916e7f369e0"
],
"tenantIds": [
"3fa85f64-5717-4562-b3fc-2c963f66afa6"
],
"username": "jeff"
}
If everything went according to plan you have created a user called Jeff
with roles OPERATOR
and HELLOWORLD_CONSUMER
, and a terrible password of Password@1
. The response from our test environment looks like the following, but your identifiers will be different.
{
"id": "9bce0e6e-6902-4254-b939-8758c51c8e87",
"status": "true",
"deleted": "false",
"username": "jeff",
"firstName": "Jeff",
"lastName": "Pop",
"email": "nobody@example.com",
"roleIds": [
"d6660cd0-38cf-11eb-9843-0916e7f369e0",
"1811c107-9433-4285-872b-84d6130c8dcf"
],
"tenantIds": [
"d66e4a30-38cf-11eb-9843-0916e7f369e0"
],
"passwordPolicyName": "ppolicy_default",
"password": null
}
Making Requests As Jeff
If you have not used Swagger much, the last few steps might have seemed like a chore, but we hope you made it. We will cover scripting the creation of roles and permissions in a future guide to take the sting out of it.
We are now ready to make some requests as Jeff. Open the Cisco MSX Portal in an incognito browser window and login in a jeff
with password Password@1
.
Once you logged in navigate to the Hello World Service Swagger documentation (help me).
Our implementation only enforces RBAC rules on the language resources and HELLOWORLD_CONSUMER
can only read language resources, so we should be able to do a GET
but not POST
, PUT
, or DELETE
. Here is a screenshot showing a successful GET
request.
The RBAC rules will prevent Jeff from creating a new language; Poor Jeff. Aut viam inveniam aut faciam.
Conclusion
That is it folks. We created a service from an OpenAPI Specification that integrates with MSX Swagger, and MSX Security. Then we containerized, packaged, deployed, and tested it in a production-like MSX environment.
Please check back periodically for new MSX development guides.
PREVIOUS | HOME |