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

Enforcing Organization and Role API Permissions #127

Open
wants to merge 52 commits into
base: develop
Choose a base branch
from

Conversation

jacob6838
Copy link
Collaborator

PR Details

Description

This PR enforces organization and role permissions in the cvmanager api. This includes the following changes:

  • Pulling all user properties from the decoded access token (name, username, super_user, and organizations/roles
  • The user's info and org/role info is stored in the flask environment variable, under the key 'user'. This data is saved in the classes EnvironNoAuth, EnvironWithoutOrg, and EnvironWithOrg (saves Organization header for later use)
  • Non-200 response codes (400, 403, 404, 500, and 503), are now handled by throwing specific exceptions, stored in common/errors.py. common/errors.py also registers error handlers for these exceptions, and emits the expected response code when each is thrown
  • Authentication is enforced by decorators. This decorator, @require_permission, takes in 4 params:
    • required_role: minimum role required to access method
    • resource_type: Optional, 'intersection', 'rsu', 'user', or 'organization'. This enables enforcement of resource-specific permissions. When specified, the first argument in the decorated method must be the resource id (intersection_id, rsu_ip, user_email, or org_name). The decorator will use this resource ID and the user's organizations and role associations to identify whether the user has access to that resource, with at least the required_role level of access.
    • checker: Optional, a PermissionChecker class, to completely customize the authentication system
    • additional_check: Optional, an AdditionalCheck method, which can apply additional enforcement after the other authentication is evaluated. This can be seen in api/src/admin_intersection.py protecting modify_intersection_authorized from "organizations_to_add" and "organizations_to_remove" fields
    • The decorator pulls the user information from the flask environment, so there is no need to pass this into the decorator
    • For the most part, authentication and user property access is limited to this decorator. In some cases, queries can be modified to include user info, such as only filtering by organization if the user is not a super_user. In cases like this, a method can receive a PermissionRequest object from the decorator, by specifying the named parameter "permission_result". This includes the qualified organizations (if non-super user), and user info (super_user, all orgs/roles, and name/email). An example of this is in api/src/admin_intersection.py
  • auth_tools helper methods
    • common/auth_tools.py holds many helper methods and types for authentication
  • Unit tests
    • the src/tests/conftest.py method adds a fake user to the flask environment, to enable most tests to pass without modification. This can be easily overridden by patching the common.auth_tools.environ in an individual test

How Has This Been Tested?

This has been extensively tested by unit tests, going through the GUI, and making manual requests through postman, to test responses to malicious requests

Types of changes

  • Defect fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that cause existing functionality to change)

Checklist:

  • My changes require new environment variables:
    • I have updated the docker-compose, K8s YAML, and all dependent deployment configuration files.
  • My changes require updates to the documentation:
    • I have updated the documentation accordingly.
  • My changes require updates and/or additions to the unit tests:
    • I have modified/added tests to cover my changes.
  • All existing tests pass.

Copy link
Collaborator

@drewjj drewjj left a comment

Choose a reason for hiding this comment

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

Some questions. Looks nice though.

@jacob6838 jacob6838 requested a review from drewjj December 16, 2024 21:11
Copy link

@mcook42 mcook42 left a comment

Choose a reason for hiding this comment

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

I love the test coverage and a lot of the additions. I have some chores and open questions that I think should be addressed before I'm ready to approve this

logging.warning(f"User info: {user_info}")
if user_info:
environ[ENVIRON_USER_KEY] = EnvironWithoutOrg(user_info)
# environ["user_info"] = user_info
Copy link

Choose a reason for hiding this comment

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

question: is there value in keeping this commented out code around, or can we remove it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

removed


rsu_dict = get_organization_rsus(request.environ["organization"])
data, code = query_rsu_counts_mongo(rsu_dict, message, start, end)
rsu_dict = get_organization_rsus(permission_result)
Copy link

Choose a reason for hiding this comment

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

Suggested change
rsu_dict = get_organization_rsus(permission_result)
rsu_dict = get_organization_rsus(permission_result, [])

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

updated to pass in qualified_orgs

@@ -96,12 +112,11 @@ def get(self):
logging.debug("RsuQueryMsgFwd GET requested")
# Schema check for arguments
schema = RsuQueryMsgFwdSchema()
user: EnvironWithOrg = request.environ[ENVIRON_USER_KEY]
Copy link

Choose a reason for hiding this comment

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

chore: remove unused variable

Suggested change
user: EnvironWithOrg = request.environ[ENVIRON_USER_KEY]

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

removed, along with similar unused variables

@@ -143,6 +143,8 @@ def options(self):
def get(self):
logging.debug("RsuSsmSrmData GET requested")
data = []

# TODO: Filter by RSUs within authenticated organizations
Copy link

Choose a reason for hiding this comment

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

question(non-blocking): How/when do we plan to address this TODO? Is there a work item to track it so it doesn't get forgotten?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

went ahead and addressed this now!

logging.debug("RsuInfo GET requested")
return (get_rsu_data(request.environ["organization"]), 200, self.headers)
return (get_rsu_data(permission_result), 200, self.headers)
Copy link

Choose a reason for hiding this comment

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

question: I'm seeing calls to get_rsu_data mostly without providing the positional arg of qualified_orgs. Should we be passing an empty list in for qualified_orgs everywhere, remove the qualified_orgs arg, or is there another approach that makes most sense?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

get_rsu_info was updated to take in only what it needed, the user object and qualified organizations list

Copy link
Collaborator

@payneBrandon payneBrandon left a comment

Choose a reason for hiding this comment

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

A few questions so far

@@ -87,15 +109,22 @@ def modify_notification(notification_spec):
failed_value = failed_value.replace(")", '"')
failed_value = failed_value.replace("=", " = ")
logging.error(f"Exception encountered: {failed_value}")
return {"message": failed_value}, 500
raise InternalServerError(failed_value) from e
except InternalServerError:
Copy link
Collaborator

Choose a reason for hiding this comment

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

If we're not doing anything with this particular error, do we need a called out "except" for it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is an oddity within the python try/except, while having a very broad Exception catcher at the end. Without the additional except IntersectionServerError: pass, then raise InternalServerError is caught by the last clause of the try catch. The real issue here is that the last except clause is way too broad

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I just updated all of the following cases to catch a SQLAlchemyError instead of a generic Exception. This allowed me to remove the except InternalServerError: pass cases

@@ -145,6 +168,7 @@ def options(self):
# CORS support
return ("", 204, self.options_headers)

@require_permission(required_role=None)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this just generically verify an authenticated user?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, this ensures a user is authorized without requiring any organization access. This is technically unnecessary since the middleware already enforces it, but this way every single method has a decorator and it's obvious that it wasn't accidentally skipped

@@ -153,15 +209,23 @@ def modify_intersection(intersection_spec):
failed_value = failed_value.replace(")", '"')
failed_value = failed_value.replace("=", " = ")
logging.error(f"Exception encountered: {failed_value}")
return {"message": failed_value}, 500
raise InternalServerError(failed_value) from e
except InternalServerError:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we need this InternalSererError except?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

same as above -

@@ -126,20 +138,18 @@ def add_intersection(intersection_spec):
failed_value = failed_value.replace(")", '"')
failed_value = failed_value.replace("=", " = ")
logging.error(f"Exception encountered: {failed_value}")
return {"message": failed_value}, 500
raise InternalServerError(failed_value) from e
except InternalServerError:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this needed?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

same as above -

@@ -143,6 +146,23 @@ class AdminNewUserSchema(Schema):
)


def enforce_add_user_org_permissions(
Copy link
Collaborator

Choose a reason for hiding this comment

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

We've got pretty similary "enforce_xxx_permissions" in several of these admin file additions. Could we abstract to a shared location to reuse code?

Base automatically changed from feature-flags to develop January 13, 2025 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants