Implementação de Controle de Acesso Baseado em RBAC em Python com Modelagem, Configuração e Testes

screens

Introdução

Este artigo explora como implementei um sistema de controle de autorizações utilizando o formato Role-Based Access Control (RBAC). Baseado em pesquisas realizadas em diversas fontes, incluindo artigos e vídeos do YouTube, cheguei a uma solução que, embora simples, é eficaz e pode ser facilmente adaptada e aprimorada conforme necessário.

O que é RBAC?

O termo RBAC vem de Role-Based Access Control, que, como o próprio nome indica, é um Controle de Acesso (Access Control - AC) baseado em papéis, também conhecidos como roles. Em termos simples, isso significa que os privilégios de um usuário são definidos de acordo com o papel que ele desempenha dentro de um sistema.

Um Controle de Acesso (Access Control - AC) é uma maneira eficaz de limitar o que os usuários podem ver e fazer em um ambiente específico, de acordo com suas permissões. Dependendo do contexto, essas restrições podem ser vitais para manter a segurança e a organização de um sistema.

Por exemplo, em um cenário de SaaS (Software as a Service), o controle de acesso pode ser usado para restringir o acesso a informações sensíveis, limitar as visualizações de páginas a determinados usuários, ou definir quem pode executar certas funções. Essa abordagem garante que cada usuário tenha acesso apenas ao que é necessário para desempenhar suas tarefas, ajudando a prevenir erros e aumentar a segurança.

Em termos de software, essa prática também é conhecida como autorização ou authorization, e é uma parte fundamental do design de sistemas que precisam gerenciar múltiplos níveis de acesso.

Modelos

A maneira de modelar um sistema para utilizar o Role-Based Access Control (RBAC) pode variar conforme o autor. Embora exista uma definição formal clara, conhecida como RBAC96, que estabelece os princípios fundamentais desse modelo, é importante considerar adaptações conforme as necessidades do sistema.

Por exemplo, se o objetivo é criar um SaaS com múltiplos clientes, cada um com seu próprio banco de dados, pode ser mais apropriado adotar uma variante do RBAC, como o Organization-Based Access Control (OrBAC). Nesse modelo é criado uma camada a mais, no qual as permissões e o acesso dos usuários são definidos e limitados de acordo com a organização ou empresa que oferece o serviço, permitindo uma maior flexibilidade e personalização em ambientes multi-tenant.

Outro aspecto interessante do RBAC é a criação de roles e permissions. Normalmente, o cliente que utiliza o serviço não tem controle sobre essas definições. O que se vê hoje é que o prestador de serviços que cria e gerencia os papéis, determinando quais permissões estão associadas a cada usuário.

Modelagem

Conceitos

Para a minha modelagem, criei um cenário em que desejava controlar o acesso de usuários a determinados recursos na interface. A visualização desses recursos dependeria do papel atribuído a cada usuário. Os papéis que defini foram os seguintes:

  • guest - um usuário que pode visualizar todos os recursos, mas sem permissões adicionais
  • user - um usuário autenticado, com permissões para comprar e vender produtos
  • premium - um usuário com acesso a recomendações de produtos com desconto
  • admin - um usuário com permissão para visualizar e gerenciar todo o sistema

Visualmente, isso seria representado da seguinte forma:

Usuário guest

screen-1.png

Usuário user

screen-2.png

screen-3.png

Usuário premium

screen-4.png

Usuário admin

screen-5.png

Note que, em cada um desses papéis, as permissões dos papéis anteriores são mantidas, mas isso não é uma regra obrigatória; as permissões podem ser definidas de forma independente conforme necessário.

Base de Dados

Para gerenciar algumas dessas permissões, segui o conceito de OrBAC (Organization-Based Access Control), que é uma extensão do RBAC (Role-Based Access Control), adicionando uma camada extra para a gestão de organizações.

MER

Neste modelo, um usuário pode pertencer a várias organizações e, da mesma forma, uma organização pode ter vários usuários. Como o relacionamento é muitos-para-muitos (n-n), utilizei uma entidade intermediária para conectar as entidades Users e Organizations.

Você pode estar se perguntando sobre as permissões e os papéis (roles). Optei por não incluí-los na modelagem para simplificar. Em vez disso, essas permissões são gerenciadas diretamente no código e mapeadas conforme necessário.

Implementação

Implementei essa solução em Python utilizando um Jupyter Notebook para demonstrar como as permissões são gerenciadas. Você pode encontrar o código completo da implementação do modelo no seguinte link: Contruindo um Controle de Acesso via RBAC com Python.

Configuração do Modelo de Dados

Primeiramente, criei o banco de dados, apagando-o caso ele já existisse. Essa abordagem é útil no contexto de um notebook:

import os
import sqlite3

database = 'rbac.db'

if os.path.exists(database):
  os.remove(database)

conn = sqlite3.connect(database)

Em seguida, criei a conexão e as tabelas principais: Users, Organizations e Memberships:

cursor = conn.cursor()

conn.execute("""
CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  username TEXT NOT NULL UNIQUE,
  password TEXT NOT NULL
);
""")

conn.execute("""
CREATE TABLE IF NOT EXISTS organizations (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  cnpj TEXT NOT NULL UNIQUE
);
""")

conn.execute("""
CREATE TABLE IF NOT EXISTS membership (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  
  user_id INTEGER NOT NULL,
  organization_id INTEGER NOT NULL,
  role TEXT NOT NULL,
  
  FOREIGN KEY (user_id) REFERENCES users (id),
  FOREIGN KEY (organization_id) REFERENCES organizations (id)
);
""")

Com isso, o banco de dados está configurado para a próxima etapa.

Implementação RBAC

Iniciei definindo as roles e permissions necessárias:

from typing import Literal, TypedDict

Role = Literal["guest", "user", "premium", "admin"]
Permission = Literal["view", "buy", "sell", "discounts", "manage"]

permissions = {
  "guest": ["view"],
  "user": ["view", "buy", "sell"],
  "premium": ["view", "buy", "sell", "discounts"],
  "admin": ["view", "buy", "sell", "discounts", "manage"]
}

Em seguida, criei classes de tipagem para representar os dados como dicionários baseados nas colunas das tabelas:

class UserInfo(TypedDict):
  id: int
  username: str

class OrganizationInfo(TypedDict):
  id: int
  name: str
  cnpj: str

class MembershipInfo(TypedDict):
  id: int
  user_id: int
  organization_id: int
  role: Role

Também desenvolvi uma função auxiliar para hash de senha:

import hashlib

def hash(password: str) -> str:
  byte_input = password.encode()
  hash_object = hashlib.sha256(byte_input)
  hash_hex = hash_object.hexdigest()

  return hash_hex

Criei funções auxiliares para a gestão de usuários, organizações e associações. Como o foco não é descrever essa implementação, eu vou pular os detalhes dela, mas fique a vontate para me perguntar se sentir alguma dúvida.

class UserNotFoundError(Exception):
  def __init__(self, *args: object) -> None:
    super('User not found').__init__(*args)

class OrganizationNotFoundError(Exception):
  def __init__(self, *args: object) -> None:
    super('Organization not found').__init__(*args)

def find_user_by_username(username: str) -> UserInfo | None:
  user = cursor.execute("SELECT id, username FROM users WHERE username = ?", (username,)).fetchone()

  if not user:
    return None

  return UserInfo(
    id=user[0],
    username=user[1]
  )

def find_organization_by_cnpj(cnpj: str) -> OrganizationInfo | None:
  organization = cursor.execute("SELECT id, name, cnpj FROM organizations WHERE cnpj = ?", (cnpj, )).fetchone()

  if not organization:
    return None

  return OrganizationInfo(
    id=organization[0],
    name=organization[1],
    cnpj=organization[2]
  )

def create_user(username: str, password: str) -> UserInfo:
  hashed_password = hash(password)
  cursor.execute("INSERT INTO users (username, password) VALUES (?, ?)", (username, hashed_password))
  conn.commit()

  user = find_user_by_username(username)

  if not user:
    raise UserNotFoundError()

  return user

def create_organization(name: str, cnpj: str) -> OrganizationInfo:
  cursor.execute("INSERT INTO organizations (name, cnpj) VALUES (?, ?)", (name, cnpj))
  conn.commit()

  organization = find_organization_by_cnpj(cnpj)

  if not organization:
    raise OrganizationNotFoundError()

  return organization

def associate_user_with_organization(user_id: int, organization_id: int, role: Role) -> None:
  cursor.execute("INSERT INTO membership (user_id, organization_id, role) VALUES (?, ?, ?)", (user_id, organization_id, role))
  conn.commit()

def get_all_users() -> UserInfo:
  users = cursor.execute("SELECT id, username FROM users").fetchall()

  return [UserInfo(id=user[0], username=user[1]) for user in users]

def get_all_organizations() -> OrganizationInfo:
  organizations = cursor.execute("SELECT id, name, cnpj FROM organizations").fetchall()

  return [OrganizationInfo(id=organization[0], name=organization[1], cnpj=organization[2]) for organization in organizations]

Agora é a criação usuários e empresas para gerar os relacionamentos.

company = create_organization('Company', '57232717000186')

matheus = create_user('matheus1714', '123')
lost = create_user('lost', '123')
lucas = create_user('lucas', '123')
marcia = create_user('marcia', '123')

associate_user_with_organization(matheus['id'], company['id'], 'admin')
associate_user_with_organization(lucas['id'], company['id'], 'guest')
associate_user_with_organization(lost['id'], company['id'], 'user')
associate_user_with_organization(marcia['id'], company['id'], 'premium')

Por fim montei essa função final para me dizer se um determinado usuário pode ou não fazer algo. Se esse código estivesse em uma API, caso o usuário tentasse acessar um recurso não permitido um retorno bom poderia ser o 404, pois para aquele usuário aquele recursos não só não é autorizado como não existe.

def has_permission(user_id: int, organization_id: int, permission: Permission) -> bool:
  user_role = cursor.execute("SELECT role FROM membership WHERE user_id = ? AND organization_id = ?", (user_id, organization_id)).fetchone()

  if user_role is None:
    return False

  user_role = user_role[0]

  return permission in permissions[user_role]

Finalmente, criei uma função para verificar se um usuário tem uma permissão específica e realizei alguns testes visuais:

cases = [
  {
    "user": lost,
    "company": company,
    "permission": "view",
  },
  {
    "user": lost,
    "company": company,
    "permission": "buy",
  },
  {
    "user": lost,
    "company": company,
    "permission": "manage",
  },
  {
    "user": lost,
    "company": company,
    "permission": "no_exist",
  }
]

def format_cnpj(cnpj: str) -> str:
  return f'{cnpj[:2]}.{cnpj[2:5]}.{cnpj[5:8]}/{cnpj[8:12]}-{cnpj[12:]}'

for objs in cases:
  user = objs['user']
  company = objs['company']
  permission = objs['permission']

  question = '{username}, da organização {company_name} ({cnpj}), tem permissão de {permission}?'.format(
    username=user['username'],
    company_name=company['name'],
    cnpj=format_cnpj(company['cnpj']),
    permission=permission
  )

  asnwer = 'R: {asnwer}'.format(
    asnwer='Sim' if has_permission(user['id'], company['id'], permission) else 'Não'
  )
  
  print(question)
  print(asnwer)
  print()

Os resultados foram:

lost, da organização Company (57.232.717/0001-86), tem permissão de view?
R: Sim

lost, da organização Company (57.232.717/0001-86), tem permissão de buy?
R: Sim

lost, da organização Company (57.232.717/0001-86), tem permissão de manage?
R: Não

lost, da organização Company (57.232.717/0001-86), tem permissão de no_exist?
R: Não

Como mostrado, uma implementação simples pode ser suficiente para criar um sistema de autorização baseado em RBAC.

Melhorias

Uma possível melhoria para esse sistema seria adicionar tabelas adicionais para gerenciar permissões e papéis (rules). Isso seria útil em cenários onde há um grande número de recursos e papéis a serem gerenciados, permitindo uma gestão mais granular e flexível. Contudo, não acho necessário para um sistema desse tamanho que montei.

Conclusão

A minha implementação do controle de acesso baseada em RBAC, por mais que seja simples, parece ser eficaz para gerenciamento de permissões e roles em sistemas. A solução criada em Python oferece uma base sólida e simples para fazer i controle de acesso, permitindo expanção. A adição de funcionalidades como gerenciamento dinâmico de permissões e roles pode aumentar ainda mais a flexibilidade e escalabilidade do sistema, proporcionando uma solução robusta para ambientes variados. Contudo, isso também traria mais complexidade ao sistema.

Referências

Excelente e super didática aprensentação do conteúdo. Fiquei com dúvidas quanto a implementação e desculpe minhas perguntas de iniciantes, mas ajuda-me a visualizar o fim do tunel.

  1. Apesar de ter usado o Jupyter, e talvez todo o código esteja em sequência, um forma baseada em arquivos seria dividí-los em quantos arquivos?
  2. A função de hash tem o papel somente de proteger a senha do usuário? A saída dela é um hash diferente para senhas diferentes?

Por enquanto é isso... ;)

Parabéns pelo conteúdo!!

Olá `rodimendes`. Relativo a utilizaçã do Jupyter, o motivo foi mais porque eu queria testar algo que tinha aprendido rápido sem me preocupar com estrutura de pastas. O Notebook tem essa vantagem de você ficar executando apenas as células e isso já ajuda bastante. Acho que comentei no artigo, mas se eu fosse usar esse código seria para uma API. Então respondendo a pergunta sobre estrutura que faria, eu organizaria meu código em algo assim: ```py src/ __init__.py utils/ __init__.py format_cnpj.py hash.py controllers __init__.py register.py authorization.py repositories __init__.py sqlite/ __init__.py SqliteUserRepository.py SqliteOrganizationRepository.py UserRepository.py # interfaces do repositorio do usuário OrganizationResitory.py # interfaces do repositório da organização errors/ __init__.py UserNotFoundError.py OrganizationNotFoundError.py services/ __init__.py CreateUserService.py CreateOrganizationService.py AssociateUserWithOrganizationService.py ChangeUserRoleService.py routes.py setup_database.py app.py ``` Eu basicamente dividi o codigo em várias camadas e isso me permite realizar testes automatizados de forma mais isolada. Eu usaria mais alguns princípios do SOLID para isso. Contudo, não se preocupa muito com estrutura, pois isso acaba sendo pessoal e do momento de cada um. Relativo a pergunta sobre a função `hash`, ela gera uma sequência de caracteres e sim, isso é variável para cada string. Tem como deixar ela um pouco mais fote usando outros parâmetros para deixar ela mais difícil de quebrar, como uma chave especial para concatenar com a senha ou informações do usuário concatenadas, para deixar o hash mais diferente ainda. Por fim, obrigado por ter curtido o conteúdo. Eu estou postando mais coisas que estou vendo e quero colocar em prática.