Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions registry/Dockerfile
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" ]
7 changes: 7 additions & 0 deletions registry/access_control/.env
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 =
74 changes: 74 additions & 0 deletions registry/access_control/README.md
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

9 changes: 9 additions & 0 deletions registry/access_control/__init__.py
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
58 changes: 58 additions & 0 deletions registry/access_control/access.py
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]
128 changes: 128 additions & 0 deletions registry/access_control/auth.py
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()
23 changes: 23 additions & 0 deletions registry/access_control/config.py
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")
Loading