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

Feature.authorization rbac #326

Merged
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
424a25b
Added roles etc to security
Nov 14, 2023
9838f8c
Rbac +testing
Josephine-Rutten Dec 1, 2023
7e6220f
Merge branch 'SUNET:develop' into feature.authorization
Josephine-Rutten Dec 4, 2023
a2dc12a
reformat
Josephine-Rutten Dec 4, 2023
13d291b
example permissions
Josephine-Rutten Dec 4, 2023
af65f91
Changes backend review Peter
Josephine-Rutten Dec 5, 2023
b0cc14a
worked on unit tests and improved the short uri
Josephine-Rutten Dec 12, 2023
19970c5
Update app_settings.py
Josephine-Rutten Dec 12, 2023
ff3ef44
format app.py
Josephine-Rutten Dec 12, 2023
cd06d30
Use token type
Josephine-Rutten Dec 12, 2023
eaeb8c6
AssertTrue & False instead of AssertEqual
Josephine-Rutten Dec 12, 2023
a3ceb85
Tried to decode jwt token without algorithm. Didn't work.
Josephine-Rutten Dec 13, 2023
33faca6
Made the data in errors into messages and changed error codes on not …
Josephine-Rutten Dec 18, 2023
a481e13
Merge branch 'feature.authenticationwithoauth' into feature.authoriza…
Josephine-Rutten Dec 18, 2023
a7e075e
Bad merge, default permissions bug, no permission bug
Josephine-Rutten Dec 19, 2023
5716557
Merge branch 'feature.authenticationwithoauth' into feature.authoriza…
Josephine-Rutten Jan 8, 2024
0599de8
Merge branch 'feature.authenticationwithoauth' into feature.authoriza…
Josephine-Rutten Jan 11, 2024
80ed6be
use token type
Josephine-Rutten Jan 15, 2024
963737a
Merge branch 'feature.authenticationwithoauth' into feature.authoriza…
Josephine-Rutten Jan 15, 2024
149c9b3
small code changes, bugfix socket_connect, permissions_disabled in co…
Josephine-Rutten Jan 15, 2024
506e546
Add permissions yaml to the docker config and add documentation for t…
Josephine-Rutten Jan 16, 2024
0bcff8b
dic instead of array
Josephine-Rutten Jan 16, 2024
e349814
Update app_settings.py
Josephine-Rutten Jan 16, 2024
fa76112
Small changes based on own code review
Josephine-Rutten Jan 17, 2024
7c5f647
bugfix based on unittests
Josephine-Rutten Jan 17, 2024
b874216
Combine conditions with same result
Josephine-Rutten Jan 17, 2024
4f5db2f
Bugfix: security
Josephine-Rutten Jan 18, 2024
5eabdd5
Merge dev into branch
Josephine-Rutten Jan 18, 2024
e486911
Bug for the audience
Josephine-Rutten Jan 18, 2024
db8444b
Include permissions in docker
Josephine-Rutten Jan 29, 2024
7febf9a
Merge remote-tracking branch 'upstream/develop' into feature.authoriz…
Josephine-Rutten Jan 29, 2024
1cd1bfc
AUDIENCE in auth_settings should be optional str
indy-independence Jan 31, 2024
0e632de
Pydantic checking of the permissions.yaml
Feb 1, 2024
4f3ba69
Made glob notation less general
Feb 1, 2024
02cd433
save on number of docker build steps, similar to earlier optimizations
indy-independence Feb 1, 2024
8ffa685
change rbac.py to use new permissions structure and make use of typing
indy-independence Feb 1, 2024
2ff20ca
Merge branch 'feature.authorization' of github.com:Josephine-Rutten/c…
indy-independence Feb 1, 2024
4748e7c
Updated unit test + small code fixes + pre-commit
Feb 5, 2024
34ab9bb
Precommit retry
Feb 5, 2024
e26e374
made the rules for unti test 2 vars instead of defining them for ever…
Josephine-Rutten Feb 5, 2024
3d6d5d8
small fixes based on sonarcloud
Josephine-Rutten Feb 5, 2024
75aaa03
last change based on sonarcloud
Josephine-Rutten Feb 5, 2024
a2e7e4b
Small bug fix + tests changed to check for that bug in the future
Josephine-Rutten Feb 5, 2024
79a47ec
fix verify_audience setting
indy-independence Feb 5, 2024
be3da89
Merge branch 'develop' into feature.authorization
Josephine-Rutten Feb 5, 2024
f0560d9
Merge branch 'feature.authorization' of github.com:Josephine-Rutten/c…
indy-independence Feb 5, 2024
209f9bf
use requests session to take advantage of http keepalive/pipelineing …
indy-independence Feb 13, 2024
af576da
Add redis cache of oidc userinfo data so we don't need to query ident…
indy-independence Feb 14, 2024
34b2287
fix request logging with email from userinfo
indy-independence Feb 15, 2024
f64ce1a
fix import error when not using oidc_enabled
indy-independence Feb 15, 2024
3ceac57
validate token for websockets to get sub -> userinfo to log user email
indy-independence Feb 16, 2024
6569092
don't log any query params for /auth/ endpoints. allow cookies to be …
indy-independence Feb 22, 2024
f4a82c8
Add email param to callback, and add refresh_token as httpOnly cookie to
indy-independence Feb 22, 2024
6745c6d
query token introspection endpoint if userinfo is not available, to b…
indy-independence Feb 22, 2024
6c3061b
did not mean to commit this ssl config for the dev server
indy-independence Feb 22, 2024
9284d80
Merge branch 'develop' into feature.authorization
indy-independence Feb 23, 2024
8f00d01
add configurable attribute to use as username from OIDC tokens instea…
indy-independence Feb 27, 2024
4380f16
make dhcp hook try using OAUTH_CLIENT_ID etc if JWT_AUTH_TOKEN is not…
indy-independence Feb 28, 2024
739c603
formatting + code cleanup
Feb 29, 2024
d774064
formatting + code cleanup security
Feb 29, 2024
9c300cb
seperate security.py into multiple different files
Mar 1, 2024
0d7ba7b
Make typing a bit more strict, make sure introspect token data is ret…
indy-independence Mar 1, 2024
f04d2bc
fix logic so introspect endpoint will be called on HTTPError
indy-independence Mar 4, 2024
c2ce5cb
update docs for changes in permissions.yml
indy-independence Mar 11, 2024
29ac456
don't log 'permissions disabled' with every API call if disabled
indy-independence Mar 11, 2024
1af667f
add dummy integrationtest JWT_SECRET_KEY
indy-independence Mar 11, 2024
87fe766
docker compose env var for JWT_SECRET_KEY
indy-independence Mar 11, 2024
7172253
increase apscheduler misfire_grace_time from 1 to 5 seconds, seems to…
indy-independence Mar 11, 2024
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
57 changes: 34 additions & 23 deletions src/cnaas_nms/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,24 @@
import sys
from typing import Optional

from authlib.integrations.flask_client import OAuth
from engineio.payload import Payload
from flask import Flask, jsonify, request
from flask_cors import CORS
from flask_jwt_extended import JWTManager
from flask_jwt_extended.exceptions import InvalidHeaderError, NoAuthorizationError
from flask_restx import Api
from flask_socketio import SocketIO, join_room
from jwt.exceptions import DecodeError, InvalidSignatureError, InvalidTokenError, ExpiredSignatureError, InvalidKeyError
from authlib.integrations.flask_client import OAuth
from jwt.exceptions import (
DecodeError,
ExpiredSignatureError,
InvalidAudienceError,
InvalidSignatureError,
InvalidTokenError,
InvalidKeyError
)

from cnaas_nms.api.auth import api as auth_api
from cnaas_nms.api.device import (
device_api,
device_cert_api,
Expand All @@ -25,7 +33,6 @@
device_update_interfaces_api,
devices_api,
)
from cnaas_nms.api.auth import api as auth_api
from cnaas_nms.api.firmware import api as firmware_api
from cnaas_nms.api.groups import api as groups_api
from cnaas_nms.api.interface import api as interfaces_api
Expand All @@ -37,15 +44,11 @@
from cnaas_nms.api.repository import api as repository_api
from cnaas_nms.api.settings import api as settings_api
from cnaas_nms.api.system import api as system_api

from cnaas_nms.app_settings import auth_settings
from cnaas_nms.app_settings import api_settings

from cnaas_nms.app_settings import api_settings, auth_settings
from cnaas_nms.tools.log import get_logger
from cnaas_nms.tools.security import get_identity, login_required
from cnaas_nms.version import __api_version__


logger = get_logger()


Expand All @@ -64,26 +67,38 @@
class CnaasApi(Api):
def handle_error(self, e):
if isinstance(e, DecodeError):
data = {"status": "error", "data": "Could not decode JWT token"}
data = {"status": "error", "message": "Could not decode JWT token"}
return jsonify(data), 401
elif isinstance(e, InvalidAudienceError):
data = {"status": "error", "message": "You don't seem to have the rights to execute this call"}
return jsonify(data), 403
elif isinstance(e, ExpiredSignatureError):
data = {"status": "error", "message": "The JWT token is expired", "errorCode": "auth_expired"}
return jsonify(data), 401
elif isinstance(e, InvalidKeyError):
data = {"status": "error", "data": "Invalid keys {}".format(e)}
return jsonify(data), 401
elif isinstance(e, InvalidTokenError):
data = {"status": "error", "data": "Invalid authentication header: {}".format(e)}
data = {"status": "error", "message": "Invalid authentication header: {}".format(e)}
return jsonify(data), 401
elif isinstance(e, InvalidSignatureError):
data = {"status": "error", "data": "Invalid token signature"}
data = {"status": "error", "message": "Invalid token signature"}
return jsonify(data), 401
elif isinstance(e, IndexError):
# We might catch IndexErrors which are not caused by JWT,
# but this is better than nothing.
data = {"status": "error", "data": "JWT token missing?"}
data = {"status": "error", "message": "JWT token missing?"}
return jsonify(data), 401
elif isinstance(e, NoAuthorizationError):
data = {"status": "error", "data": "JWT token missing?"}
data = {"status": "error", "message": "JWT token missing?"}
return jsonify(data), 401
elif isinstance(e, InvalidHeaderError):
data = {"status": "error", "data": "Invalid header, JWT token missing? {}".format(e)}
elif isinstance(e, ExpiredSignatureError):
data = {"status": "error", "data": "The JWT token is expired"}
data = {"status": "error", "message": "Invalid header, JWT token missing? {}".format(e)}
return jsonify(data), 401
else:
return super(CnaasApi, self).handle_error(e)
return jsonify(data), 401




app = Flask(__name__)
Expand Down Expand Up @@ -196,18 +211,14 @@ def socketio_on_events(data):
def log_request(response):
try:
url = re.sub(jwt_query_r, "", request.url)
if request.headers.get('content-type') == 'application/json':
if request.headers.get("content-type") == "application/json":
logger.info(
"Method: {}, Status: {}, URL: {}, JSON: {}".format(
request.method, response.status_code, url, request.json
)
)
else:
logger.info(
"Method: {}, Status: {}, URL: {}".format(
request.method, response.status_code, url
)
)
logger.info("Method: {}, Status: {}, URL: {}".format(request.method, response.status_code, url))
except Exception:
pass
return response
18 changes: 17 additions & 1 deletion src/cnaas_nms/api/auth.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from authlib.integrations.base_client.errors import MismatchingStateError
from authlib.integrations.flask_oauth2 import current_token

from flask import current_app, redirect, url_for
from flask_restx import Namespace, Resource
from requests.models import PreparedRequest

from cnaas_nms.api.generic import empty_result
from cnaas_nms.app_settings import auth_settings
from cnaas_nms.tools.security import login_required, get_identity, get_oauth_userinfo, login_required_all_permitted
from cnaas_nms.tools.rbac.rbac import get_permissions_user
from cnaas_nms.tools.log import get_logger
from cnaas_nms.tools.security import get_identity, login_required
from cnaas_nms.version import __api_version__

logger = get_logger()
Expand Down Expand Up @@ -80,6 +83,19 @@ def get(self):
return identity


class PermissionsAPI(Resource):
@login_required_all_permitted
def get(self):
permissions_rules = auth_settings.PERMISSIONS
if len(permissions_rules) == 0:
logger.debug('No permissions defined, so nobody is permitted to do any api calls.')
return {}
user_info = get_oauth_userinfo(current_token)
permissions_of_user = get_permissions_user(permissions_rules, user_info)
return permissions_of_user


api.add_resource(LoginApi, "/login")
api.add_resource(AuthApi, "/auth")
api.add_resource(IdentityApi, "/identity")
api.add_resource(PermissionsAPI, "/permissions")
18 changes: 16 additions & 2 deletions src/cnaas_nms/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,12 @@ class AuthSettings(BaseSettings):
OIDC_CLIENT_SECRET: str = "xxx"
OIDC_CLIENT_ID: str = "client-id"
OIDC_ENABLED: bool = False
PERMISSIONS: dict = {}
PERMISSIONS_DISABLED: bool = False


def construct_api_settings() -> ApiSettings:
api_config = Path("/etc/cnaas-nms/api.yml")
api_config = Path("etc/cnaas-nms/api.yml")

if api_config.is_file():
with open(api_config, "r") as api_file:
Expand Down Expand Up @@ -109,7 +111,6 @@ def construct_api_settings() -> ApiSettings:
def construct_app_settings() -> AppSettings:
db_config = Path("/etc/cnaas-nms/db_config.yml")
repo_config = Path("/etc/cnaas-nms/repository.yml")

app_settings = AppSettings()

def _create_db_config(settings: AppSettings, config: dict) -> None:
Expand Down Expand Up @@ -142,6 +143,19 @@ def _create_repo_config(settings: AppSettings, config: dict) -> None:

def construct_auth_settings() -> AuthSettings:
auth_settings = AuthSettings()
permission_config = Path("/etc/cnaas-nms/permissions.yml")

def _create_permissions_config(settings: AuthSettings, permissions_rules: dict) -> None:
settings.PERMISSIONS = permissions_rules

if auth_settings.PERMISSIONS_DISABLED:
auth_settings.PERMISSIONS = {'config': {'default_permissions': 'default'}, 'roles': {'default': {'allowed_api_methods': ['*'], 'allowed_api_calls': ['*']}}}

elif permission_config.is_file():
'''Load the file with role permission'''
with open(permission_config, "r") as permission_file:
permissions_rules = yaml.safe_load(permission_file)
_create_permissions_config(auth_settings, permissions_rules)
return auth_settings


Expand Down
9 changes: 9 additions & 0 deletions src/cnaas_nms/roles.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@

-
role: read
allowed_api_methods: ["GET"]
allowed_api_calls: ["/devices", "/jobs"]
-
role: write
allowed_api_methods: ["GET", "PUT"]
allowed_api_calls: []
61 changes: 61 additions & 0 deletions src/cnaas_nms/tools/rbac/rbac.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import re
import fnmatch
from authlib.oauth2.rfc6749.wrappers import HttpRequest

from cnaas_nms.version import __api_version__

def get_permissions_user(permissions_rules, user_info):
'''Get the API permissions of the user'''
permissions_of_user = []

# if no rules, return
if len(permissions_rules) == 0:
return permissions_of_user

# first give all the permissions of the fallback role
if "default_permissions" in permissions_rules["config"] and permissions_rules["config"]["default_permissions"] in permissions_rules["roles"]:
permissions_of_user.extend(permissions_rules["roles"][permissions_rules["config"]["default_permissions"]]["permissions"])

# if the element is not defined or the user doesn't have the element, return
if 'group_claim_key' not in permissions_rules['config'] or permissions_rules['config']['group_claim_key'] not in user_info:
return permissions_of_user

# make the roles of userinfo into the right format, a list of roles
if isinstance(user_info[permissions_rules['config']['group_claim_key']], list):
user_roles = user_info[permissions_rules['config']['group_claim_key']]
else:
user_roles = [user_info[permissions_rules['config']['group_claim_key']]]

# find the relevant roles and add permissions
relevant_roles = list(set(permissions_rules["roles"]) & set(user_roles))
for relevant_role in relevant_roles:
permissions_of_user.extend(permissions_rules["roles"][relevant_role]["permissions"])

return permissions_of_user


def check_if_api_call_is_permitted(request: HttpRequest, permissions_of_user):
'''Checks if the user has permission to execute the API call'''
for permission in permissions_of_user:
allowed_methods = permission['methods']
allowed_endpoints = permission['endpoints']

# check if allowed based on the method
if "*" not in allowed_methods and request.method not in allowed_methods:
continue

# prepare the uri
prefix = "/api/{}".format(__api_version__)
short_uri = request.uri.strip().removeprefix(prefix).split('?', 1)[0]

# check if you're permitted to make api call based on uri
if "*" in allowed_endpoints or short_uri in allowed_endpoints:
return True

# added the glob patterns so it's easier to add a bunch of api calls (like all /device api calls)
for allowed_api_call in allowed_endpoints:
matches = fnmatch.filter([short_uri], allowed_api_call)
if len(matches) > 0:
return True

return False
Loading