-
Notifications
You must be signed in to change notification settings - Fork 243
Access Control Plugin for Registry APIs #409
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
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| FROM python:3.9 | ||
|
|
||
| COPY ./ /usr/src | ||
|
|
||
| WORKDIR /usr/src | ||
| RUN pip install -r requirements.txt | ||
|
|
||
| # Start web server | ||
| CMD [ "uvicorn","main:app","--host", "0.0.0.0", "--port", "80" ] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| API_BASE = /api/v1 | ||
| API_CLIENT_ID = db8dc4b0-202e-450c-b38d-7396ad9631a5 | ||
| AAD_TENANT_ID = common | ||
| AAD_INSTANCE = https://login.microsoftonline.com | ||
| API_AUDIENCE = db8dc4b0-202e-450c-b38d-7396ad9631a5 | ||
| REGISTRY_URL = https://feathr-sql-registry.azurewebsites.net/api/v1 | ||
| CONNECTION_STR = |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| # Feathr Registry Access Control Gateway Specifications | ||
|
|
||
| ## Registry API with Access Control Gateway | ||
|
|
||
| **Access Control Gateway** is an access control **Plugin** component of feature registry API. It can work with different type of backend registry. When user enables this component, registry requests will be validated in a gateway as below flow chart: | ||
|
|
||
| ```mermaid | ||
| flowchart TD | ||
| A[Get Registry API Request] --> B{Is Id Token Valid?}; | ||
| B -- No --> D[Return 401]; | ||
| B -- Yes --> C{Have Permission?}; | ||
| C -- No --> F[Return 403]; | ||
| C -- Yes --> E[Call Downstream API*]; | ||
| E --> G{API Service Available?} | ||
| G -- No --> I[Return 503] | ||
| G -- Yes --> H[Return API Results] | ||
| ``` | ||
|
|
||
| If Access control plugin is NOT enabled, the flow will start from **Call Downstream API*** | ||
|
|
||
| ## Access Control Registry API | ||
|
|
||
| - For all **get** requests, check **read** permission for certain project. | ||
| - For all **post** request, check **write** permission for certain project. | ||
| - For all **access control management** request, check **manage** permission for certain project. | ||
| - In case of feature level query, will verify the parent project access of the feature. | ||
| - Registry API calls and returns will be transparently transferred. | ||
|
|
||
| ## Management Rules | ||
|
|
||
| ### Initialize `userroles` table | ||
|
|
||
| Users needs to create a `userroles` table with [schema.sql](scripts/schema.sql) at the very first place. The process will be similar with SQL Registry `bacpac` initialization. | ||
|
|
||
| ### Initialize `userroles` records | ||
|
|
||
| In current version, user needs to manually initialize `userroles` table admins in SQL table. | ||
| When `create_registry` and `create_project` API is enabled, default admin role will be assigned to the creator. | ||
| Admin roles can add or delete roles in management UI page or through management API. | ||
|
|
||
| ### Environment Settings | ||
|
|
||
| | Variable| Description| | ||
| |---|---| | ||
| | CONNECTION_STR| Connection String of the SQL database that host access control tables| | ||
| | API_BASE| Aligned API base| | ||
| |REGISTRY_URL| The downstream Registry API Endpoint| | ||
| | AAD_INSTANCE | Set to "https://login.microsoftonline.com" by default | | ||
| | AAD_TENANT_ID| Used get auth url together with AAD_INSTANCE| | ||
| |API_AUDIENCE| Used as audience to decode jwt tokens| | ||
|
|
||
| ## Notes | ||
|
|
||
| Supported scenarios status are tracked below: | ||
|
|
||
| - General Foundations: | ||
| - [x] Access Control Abstract Class | ||
| - [x] API Spec Contents for Access Control Management APIs | ||
| - [x] API Spec Contents for Registry API Access Control | ||
| - [x] Separate Registry API and Access Control into different implementation | ||
| - [ ] A docker file to contain all required component for deployments | ||
| - SQL Implementation: | ||
| - [x] `userroles` table CRUD through FastAPI | ||
| - [x] `userroles` table schema & test data, could be used to make `.bacpac` file for SQL table initialize. | ||
| - [x] Initialize default Project Admin role for project creator | ||
| - [ ] Initialize default Global Admin Role for workspace creator | ||
| - UI Experience | ||
| - [x] Hidden page `../management` for global admin to make CUD requests to `userroles` table | ||
| - [x] Use id token in Management API Request headers to identify requestor | ||
| - Future Enhancements: | ||
| - [ ] Functional in Feathr Client | ||
| - [ ] Support AAD Groups | ||
| - [ ] Support Other OAuth Providers | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| __all__ = ["auth", "access", "models", "interface", "db_rbac"] | ||
|
|
||
|
|
||
| from access_control.auth import * | ||
| from access_control.access import * | ||
| from access_control.interface import RBAC | ||
| from access_control.models import * | ||
| from access_control.db_rbac import DbRBAC | ||
| from common.database import DbConnection, connect |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| from typing import Any | ||
| from fastapi import Depends, HTTPException, status | ||
| from access_control.db_rbac import DbRBAC | ||
|
|
||
| from access_control.models import AccessType, User | ||
| from access_control.auth import authorize | ||
|
|
||
| """ | ||
| All Access Validation Functions. Used as FastAPI Dependencies. | ||
| """ | ||
|
|
||
| rbac = DbRBAC() | ||
|
|
||
|
|
||
| class ForbiddenAccess(HTTPException): | ||
| def __init__(self, detail: Any = None) -> None: | ||
| super().__init__(status_code=status.HTTP_403_FORBIDDEN, | ||
| detail=detail, headers={"WWW-Authenticate": "Bearer"}) | ||
|
|
||
|
|
||
| def get_user(user: User = Depends(authorize)) -> User: | ||
| return user | ||
|
|
||
|
|
||
| def project_read_access(project: str, user: User = Depends(authorize)) -> User: | ||
| return _project_access(project, user, AccessType.READ) | ||
|
|
||
|
|
||
| def project_write_access(project: str, user: User = Depends(authorize)) -> User: | ||
| return _project_access(project, user, AccessType.WRITE) | ||
|
|
||
|
|
||
| def project_manage_access(project: str, user: User = Depends(authorize)) -> User: | ||
| return _project_access(project, user, AccessType.MANAGE) | ||
|
|
||
|
|
||
| def _project_access(project: str, user: User, access: str): | ||
| if rbac.validate_project_access_users(project, user.preferred_username, access): | ||
| return user | ||
| else: | ||
| raise ForbiddenAccess( | ||
| f"{access} privileges for project {project} required for user {user.preferred_username}") | ||
|
|
||
|
|
||
| def global_admin_access(user: User = Depends(authorize)): | ||
| if user.preferred_username in rbac.get_global_admin_users(): | ||
| return user | ||
| else: | ||
| raise ForbiddenAccess('Admin privileges required') | ||
|
|
||
| def validate_project_access_for_feature(feature:str, user:str, access:str): | ||
| project = _get_project_from_feature(feature) | ||
| _project_access(project, user, access) | ||
|
|
||
|
|
||
| def _get_project_from_feature(feature: str): | ||
| feature_delimiter = "__request_features__" | ||
| return feature.split(feature_delimiter)[0] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| import base64 | ||
| import logging | ||
| import requests | ||
| import rsa | ||
| from typing import Any, Mapping, Optional | ||
| from fastapi import HTTPException, Request, status | ||
| from fastapi.security import OAuth2AuthorizationCodeBearer | ||
| import jwt | ||
| from jwt.exceptions import ExpiredSignatureError, PyJWKError | ||
|
|
||
| import access_control.config as config | ||
| from access_control.models import User | ||
|
|
||
|
|
||
| log = logging.getLogger() | ||
| BEARER_TOKEN = "BEARER " | ||
|
|
||
|
|
||
| class InvalidAuthorization(HTTPException): | ||
| def __init__(self, detail: Any = None) -> None: | ||
| super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, | ||
| detail=detail, headers={"WWW-Authenticate": "Bearer"}) | ||
|
|
||
|
|
||
| class AzureADAuth(OAuth2AuthorizationCodeBearer): | ||
| # cached AAD jwt keys | ||
| aad_jwt_keys_cache: dict = {} | ||
|
|
||
| def __init__(self, aad_instance: str = config.AAD_INSTANCE, aad_tenant: str = config.AAD_TENANT_ID): | ||
| self.base_auth_url: str = f"{aad_instance}/{aad_tenant}" | ||
| super(AzureADAuth, self).__init__( | ||
| authorizationUrl=f"{self.base_auth_url}/oauth2/v2.0/authorize", | ||
| tokenUrl=f"{self.base_auth_url}/oauth2/v2.0/token", | ||
| refreshUrl=f"{self.base_auth_url}/oauth2/v2.0/token", | ||
| scheme_name="oauth2", | ||
| scopes={ | ||
| f'api://{config.API_CLIENT_ID}/access_as_user': 'Access API as user', | ||
| } | ||
| ) | ||
|
|
||
| async def __call__(self, request: Request) -> User: | ||
| bearer_token: str = request.headers.get("authorization") | ||
| if bearer_token: | ||
| token = bearer_token[len(BEARER_TOKEN):] | ||
| decoded_token = self._decode_token(token) | ||
| return self._get_user_from_token(decoded_token) | ||
| else: | ||
| raise InvalidAuthorization(detail='No authorization token was found') | ||
|
|
||
| @staticmethod | ||
| def _get_user_from_token(decoded_token: Mapping) -> User: | ||
| try: | ||
| user_id = decoded_token['oid'] | ||
| except Exception as e: | ||
| logging.debug(e) | ||
| raise InvalidAuthorization(detail='Unable to extract user details from token') | ||
|
|
||
| return User( | ||
| id=user_id, | ||
| name=decoded_token.get('name', ''), | ||
| preferred_username=decoded_token.get('preferred_username', ''), | ||
| roles=decoded_token.get('roles', []) | ||
| ) | ||
|
|
||
| @staticmethod | ||
| def _get_key_id(token: str) -> Optional[str]: | ||
| headers = jwt.get_unverified_header(token) | ||
| return headers['kid'] if headers and 'kid' in headers else None | ||
|
|
||
| @staticmethod | ||
| def _ensure_b64padding(key: str) -> str: | ||
| """ | ||
| The base64 encoded keys are not always correctly padded, so pad with the right number of = | ||
| """ | ||
| key = key.encode('utf-8') | ||
| missing_padding = len(key) % 4 | ||
| for _ in range(missing_padding): | ||
| key = key + b'=' | ||
| return key | ||
|
|
||
| def _cache_aad_keys(self) -> None: | ||
| """ | ||
| Cache all AAD JWT keys - so we don't have to make a web call each auth request | ||
| """ | ||
| response = requests.get( | ||
| f"{self.base_auth_url}/v2.0/.well-known/openid-configuration") | ||
| aad_metadata = response.json() if response.ok else None | ||
| jwks_uri = aad_metadata['jwks_uri'] if aad_metadata and 'jwks_uri' in aad_metadata else None | ||
| if jwks_uri: | ||
| response = requests.get(jwks_uri) | ||
| keys = response.json() if response.ok else None | ||
| if keys and 'keys' in keys: | ||
| for key in keys['keys']: | ||
| n = int.from_bytes(base64.urlsafe_b64decode( | ||
| self._ensure_b64padding(key['n'])), "big") | ||
| e = int.from_bytes(base64.urlsafe_b64decode( | ||
| self._ensure_b64padding(key['e'])), "big") | ||
| pub_key = rsa.PublicKey(n, e) | ||
| # Cache the PEM formatted public key. | ||
| AzureADAuth.aad_jwt_keys_cache[key['kid']] = pub_key.save_pkcs1( | ||
| ) | ||
|
|
||
| def _get_token_key(self, key_id: str) -> str: | ||
| if key_id not in AzureADAuth.aad_jwt_keys_cache: | ||
| self._cache_aad_keys() | ||
| return AzureADAuth.aad_jwt_keys_cache[key_id] | ||
|
|
||
| def _decode_token(self, token: str) -> Mapping: | ||
| key_id = self._get_key_id(token) | ||
| if not key_id: | ||
| raise InvalidAuthorization('The token does not contain kid') | ||
| key = self._get_token_key(key_id) | ||
| try: | ||
| decode = jwt.decode(token, key=key, algorithms=[ | ||
| 'RS256'], audience=config.API_AUDIENCE) | ||
| return decode | ||
| except ExpiredSignatureError as e: | ||
| logging.debug(f'The token signature has expired: {e}') | ||
| raise InvalidAuthorization('The token signature has expired') | ||
| except PyJWKError as e: | ||
| logging.debug(f'Invalid token: {e}') | ||
| raise InvalidAuthorization('The token is invalid') | ||
| except Exception as e: | ||
| logging.debug(f'Unexpected error: {e}') | ||
| raise InvalidAuthorization('Unable to decode token, error: {e}') | ||
|
|
||
|
|
||
| authorize = AzureADAuth() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import os | ||
| from starlette.config import Config | ||
|
|
||
| env_file = os.path.join("registry", "access_control", ".env") | ||
| config = Config(os.path.abspath(env_file)) | ||
|
|
||
| def _get_config(key:str, default:str = "", config:Config = config): | ||
| return os.environ.get(key) or config.get(key, default=default) | ||
|
|
||
| # API Settings | ||
| API_BASE: str = _get_config("API_BASE", default="/api/v1") | ||
|
|
||
| # Authentication | ||
| API_CLIENT_ID: str = _get_config("API_CLIENT_ID", default="db8dc4b0-202e-450c-b38d-7396ad9631a5") | ||
| AAD_TENANT_ID: str = _get_config("AAD_TENANT_ID", default="common") | ||
| AAD_INSTANCE: str = _get_config("AAD_INSTANCE", default="https://login.microsoftonline.com") | ||
| API_AUDIENCE: str = _get_config("API_AUDIENCE", default="db8dc4b0-202e-450c-b38d-7396ad9631a5") | ||
|
|
||
| # SQL Database | ||
| CONNECTION_STR: str = _get_config("CONNECTION_STR", default= "") | ||
|
|
||
| # Downstream API Endpoint | ||
| REGISTRY_URL: str = _get_config("REGISTRY_URL", default= "https://feathr-sql-registry.azurewebsites.net/api/v1") |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.