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

Extend Access Control Management APIs to Project Admins #535

Merged
merged 4 commits into from
Aug 1, 2022
Merged
Show file tree
Hide file tree
Changes from 2 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
24 changes: 14 additions & 10 deletions registry/access_control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,25 @@ Users needs to create a `userroles` table with [schema.sql](scripts/schema.sql)
### Initialize `userroles` records

In current version, user needs to manually initialize `userroles` table admins in SQL table.
At least one `global admin` is needed to use rbac features. You can add `[your-email-account]` as global admin with the following SQL script in [Azure portal query editor](https://docs.microsoft.com/en-us/azure/azure-sql/database/connect-query-portal?view=azuresql)
```SQL
insert into userroles (project_name, user_name, role_name, create_by, create_reason, create_time) values ('global', '[your-email-account]','admin', '[your-email-account]', 'test data', getutcdate())
Yuqing-cat marked this conversation as resolved.
Show resolved Hide resolved
```
When `create_registry` and `create_project` API is enabled, default admin role will be assigned to the creator.
Yuqing-cat marked this conversation as resolved.
Show resolved Hide resolved
Admin roles can add or delete roles in management UI page or through management API.

### Environment Settings

`ENABLE_RBAC` needs to be set to deploy a registry backend with access control plugin.

| Variable| Description|
|---|---|
| RBAC_CONNECTION_STR| Connection String of the SQL database that host access control tables, required.|
| RBAC_API_BASE| Aligned API base|
| RBAC_REGISTRY_URL| The downstream Registry API Endpoint|
| RBAC_AAD_INSTANCE | Instance like "https://login.microsoftonline.com" |
| RBAC_AAD_TENANT_ID| Used get auth url together with `RBAC_AAD_INSTANCE`|
| RBAC_API_AUDIENCE| Used as audience to decode jwt tokens|
| Variable | Description |
| ------------------- | -------------------------------------------------------------------------------- |
| RBAC_CONNECTION_STR | Connection String of the SQL database that host access control tables, required. |
| RBAC_API_BASE | Aligned API base |
| RBAC_REGISTRY_URL | The downstream Registry API Endpoint |
| RBAC_AAD_INSTANCE | Instance like "https://login.microsoftonline.com" |
| RBAC_AAD_TENANT_ID | Used get auth url together with `RBAC_AAD_INSTANCE` |
| RBAC_API_AUDIENCE | Used as audience to decode jwt tokens |

## Notes

Expand All @@ -67,12 +71,12 @@ Supported scenarios status are tracked below:
- [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] Hidden page `../management` for project admin to make CUD requests to `userroles` table
- [x] Use id token in Management API Request headers to identify requestor
- Future Enhancements:
- [x] Support AAD Application token
- [x] Support OAuth tokens with `email` attributes
- [ ] Functional in Feathr Client
- [x] Functional in Feathr Client
- [ ] Support AAD Groups
- [ ] Support Other OAuth Providers

61 changes: 40 additions & 21 deletions registry/access_control/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
rbac = DbRBAC()
registry_url = config.RBAC_REGISTRY_URL


@router.get('/projects', name="Get a list of Project Names [No Auth Required]")
async def get_projects() -> list[str]:
response = requests.get(registry_url + "/projects").content.decode('utf-8')
Expand All @@ -19,79 +20,97 @@ async def get_projects() -> list[str]:

@router.get('/projects/{project}', name="Get My Project [Read Access Required]")
async def get_project(project: str, requestor: User = Depends(project_read_access)):
response = requests.get(registry_url + "/projects/" + project, headers = get_api_header(requestor)).content.decode('utf-8')
response = requests.get(registry_url + "/projects/" + project,
headers=get_api_header(requestor)).content.decode('utf-8')
return json.loads(response)


@router.get("/projects/{project}/datasources", name="Get data sources of my project [Read Access Required]")
def get_project_datasources(project: str, requestor: User = Depends(project_read_access)) -> list:
response = requests.get(registry_url + "/projects/" + project + "/datasources", headers = get_api_header(requestor)).content.decode('utf-8')
response = requests.get(registry_url + "/projects/" + project + "/datasources",
headers=get_api_header(requestor)).content.decode('utf-8')
return json.loads(response)


@router.get("/projects/{project}/features", name="Get features under my project [Read Access Required]")
def get_project_features(project: str, keyword: Optional[str] = None, requestor: User = Depends(project_read_access)) -> list:
response = requests.get(registry_url + "/projects/" + project + "/features", headers = get_api_header(requestor)).content.decode('utf-8')
response = requests.get(registry_url + "/projects/" + project + "/features",
headers=get_api_header(requestor)).content.decode('utf-8')
return json.loads(response)


@router.get("/features/{feature}", name="Get a single feature by feature Id [Read Access Required]")
def get_feature(feature: str, requestor: User = Depends(get_user)) -> dict:
response = requests.get(registry_url + "/features/" + feature, headers = get_api_header(requestor)).content.decode('utf-8')
response = requests.get(registry_url + "/features/" + feature,
headers=get_api_header(requestor)).content.decode('utf-8')
ret = json.loads(response)

feature_qualifiedName = ret['attributes']['qualifiedName']
validate_project_access_for_feature(feature_qualifiedName, requestor, AccessType.READ)
validate_project_access_for_feature(
feature_qualifiedName, requestor, AccessType.READ)
return ret


@router.get("/features/{feature}/lineage", name="Get Feature Lineage [Read Access Required]")
def get_feature_lineage(feature: str, requestor: User = Depends(get_user)) -> dict:
response = requests.get(registry_url + "/features/" + feature + "/lineage", headers = get_api_header(requestor)).content.decode('utf-8')
response = requests.get(registry_url + "/features/" + feature + "/lineage",
headers=get_api_header(requestor)).content.decode('utf-8')
ret = json.loads(response)

feature_qualifiedName = ret['guidEntityMap'][feature]['attributes']['qualifiedName']
validate_project_access_for_feature(feature_qualifiedName, requestor, AccessType.READ)
validate_project_access_for_feature(
feature_qualifiedName, requestor, AccessType.READ)
return ret


@router.post("/projects", name="Create new project with definition [Auth Required]")
def new_project(definition: dict, requestor: User = Depends(get_user)) -> dict:
rbac.init_userrole(requestor, definition["name"])
response = requests.post(url = registry_url + "/projects", params=definition, headers = get_api_header(requestor)).content.decode('utf-8')
response = requests.post(url=registry_url + "/projects", params=definition,
headers=get_api_header(requestor)).content.decode('utf-8')
return json.loads(response)


@router.post("/projects/{project}/datasources", name="Create new data source of my project [Write Access Required]")
def new_project_datasource(project: str, definition: dict, requestor: User = Depends(project_write_access)) -> dict:
response = requests.post(url = registry_url + "/projects/" + project + '/datasources', params=definition, headers = get_api_header(requestor)).content.decode('utf-8')
response = requests.post(url=registry_url + "/projects/" + project + '/datasources',
params=definition, headers=get_api_header(requestor)).content.decode('utf-8')
return json.loads(response)


@router.post("/projects/{project}/anchors", name="Create new anchors of my project [Write Access Required]")
def new_project_anchor(project: str, definition: dict, requestor: User = Depends(project_write_access)) -> dict:
response = requests.post(url = registry_url + "/projects/" + project + '/datasources', params=definition, headers = get_api_header(requestor)).content.decode('utf-8')
response = requests.post(url=registry_url + "/projects/" + project + '/datasources',
params=definition, headers=get_api_header(requestor)).content.decode('utf-8')
return json.loads(response)


@router.post("/projects/{project}/anchors/{anchor}/features", name="Create new anchor features of my project [Write Access Required]")
def new_project_anchor_feature(project: str, anchor: str, definition: dict, requestor: User = Depends(project_write_access)) -> dict:
response = requests.post(url = registry_url + "/projects/" + project + '/anchors/' + anchor + '/features', params=definition, headers = get_api_header(requestor)).content.decode('utf-8')
response = requests.post(url=registry_url + "/projects/" + project + '/anchors/' + anchor +
Yuqing-cat marked this conversation as resolved.
Show resolved Hide resolved
'/features', params=definition, headers=get_api_header(requestor)).content.decode('utf-8')
return json.loads(response)


@router.post("/projects/{project}/derivedfeatures", name="Create new derived features of my project [Write Access Required]")
def new_project_derived_feature(project: str,definition: dict, requestor: User = Depends(project_write_access)) -> dict:
response = requests.post(url = registry_url + "/projects/" + project + '/derivedfeatures', params=definition, headers = get_api_header(requestor)).content.decode('utf-8')
def new_project_derived_feature(project: str, definition: dict, requestor: User = Depends(project_write_access)) -> dict:
response = requests.post(url=registry_url + "/projects/" + project + '/derivedfeatures',
params=definition, headers=get_api_header(requestor)).content.decode('utf-8')
return json.loads(response)

# Below are access control management APIs
@router.get("/userroles", name="List all active user role records [Global Admin Required]")
def get_userroles(requestor: User = Depends(global_admin_access)) -> list:
return list([r.to_dict() for r in rbac.userroles])


@router.post("/users/{user}/userroles/add", name="Add a new user role [Global Admin Required]")
def add_userrole(project: str, user: str, role: str, reason: str, requestor: User = Depends(global_admin_access)):
@router.get("/userroles", name="List all active user role records [Project Manage Access Required]")
def get_userroles(requestor: User = Depends(get_user)) -> list:
return rbac.list_userroles(requestor.username)


@router.post("/users/{user}/userroles/add", name="Add a new user role [Project Manage Access Required]")
def add_userrole(project: str, user: str, role: str, reason: str, requestor: User = Depends(project_manage_access)):
return rbac.add_userrole(project, user, role, reason, requestor.username)


@router.delete("/users/{user}/userroles/delete", name="Delete a user role [Global Admin Required]")
def delete_userrole(project: str, user: str, role: str, reason: str, requestor: User = Depends(global_admin_access)):
@router.delete("/users/{user}/userroles/delete", name="Delete a user role [Project Manage Access Required]")
def delete_userrole(project: str, user: str, role: str, reason: str, requestor: User = Depends(project_manage_access)):
return rbac.delete_userrole(project, user, role, reason, requestor.username)

43 changes: 31 additions & 12 deletions registry/access_control/rbac/db_rbac.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os
import logging


class DbRBAC(RBAC):
def __init__(self):
if not os.environ.get("RBAC_CONNECTION_STR"):
Expand All @@ -31,7 +32,7 @@ def _get_userroles(self) -> list[UserRole]:

def get_global_admin_users(self) -> list[str]:
self.get_userroles()
return [u.user_name for u in self.userroles if (u.project_name == SUPER_ADMIN_SCOPE and u.role_name == RoleType.ADMIN)]
return [u.user_name for u in self.userroles if (u.project_name == SUPER_ADMIN_SCOPE and u.role_name == RoleType.ADMIN.value)]

def validate_project_access_users(self, project: str, user: str, access: str = AccessType.READ) -> bool:
self.get_userroles()
Expand All @@ -48,9 +49,9 @@ def get_userroles_by_user(self, user_name: str, role_name: str = None) -> list[U
where delete_reason is null and user_name ='%s'"""
if role_name:
query += fr"and role_name = '%s'"
rows = self.conn.query(query%(user_name, role_name))
rows = self.conn.query(query % (user_name, role_name))
else:
rows = self.conn.query(query%(user_name))
rows = self.conn.query(query % (user_name))
ret = []
for row in rows:
ret.append(UserRole(**row))
Expand All @@ -64,14 +65,26 @@ def get_userroles_by_project(self, project_name: str, role_name: str = None) ->
where delete_reason is null and project_name ='%s'"""
if role_name:
query += fr"and role_name = '%s'"
rows = self.conn.query(query%(project_name, role_name))
rows = self.conn.query(query % (project_name, role_name))
else:
rows = self.conn.query(query%(project_name))
rows = self.conn.query(query % (project_name))
ret = []
for row in rows:
ret.append(UserRole(**row))
return ret

def list_userroles(self, user_name: str) -> list[UserRole]:
ret = []
if user_name in self.get_global_admin_users():
return list([r.to_dict() for r in self.userroles])
else:
admin_roles = self.get_userroles_by_user(
user_name, RoleType.ADMIN.value)
ret = []
for r in admin_roles:
ret.extend(self.get_userroles_by_project(r.project_name))
return list([r.to_dict() for r in ret])

def add_userrole(self, project_name: str, user_name: str, role_name: str, create_reason: str, by: str):
"""insert new user role relationship into sql table
"""
Expand All @@ -86,8 +99,10 @@ def add_userrole(self, project_name: str, user_name: str, role_name: str, create
# insert new record
query = fr"""insert into userroles (project_name, user_name, role_name, create_by, create_reason, create_time)
values ('%s','%s','%s','%s' ,'%s', getutcdate())"""
self.conn.update(query%(project_name, user_name, role_name, by, create_reason))
logging.info(f"Userrole added with query: {query%(project_name, user_name, role_name, by, create_reason)}")
self.conn.update(query % (project_name, user_name,
role_name, by, create_reason))
logging.info(
f"Userrole added with query: {query%(project_name, user_name, role_name, by, create_reason)}")
self.get_userroles()
return

Expand All @@ -100,8 +115,10 @@ def delete_userrole(self, project_name: str, user_name: str, role_name: str, del
[delete_time] = getutcdate()
WHERE [user_name] = '%s' and [project_name] = '%s' and [role_name] = '%s'
and [delete_time] is null"""
self.conn.update(query%(by, delete_reason, user_name, project_name, role_name))
logging.info(f"Userrole removed with query: {query%(by, delete_reason, user_name, project_name, role_name)}")
self.conn.update(query % (by, delete_reason,
user_name, project_name, role_name))
logging.info(
f"Userrole removed with query: {query%(by, delete_reason, user_name, project_name, role_name)}")
self.get_userroles()
return

Expand All @@ -113,6 +130,8 @@ def init_userrole(self, creator_name: str, project_name: str):
create_reason = "creator of project, get admin by default."
query = fr"""insert into userroles (project_name, user_name, role_name, create_by, create_reason, create_time)
values ('%s','%s','%s','%s','%s', getutcdate())"""
self.conn.update(query%(project_name, creator_name, RoleType.ADMIN, create_by, create_reason))
logging.info(f"Userrole initialized with query: {query%(project_name, creator_name, RoleType.ADMIN, create_by, create_reason)}")
return self.get_userroles()
self.conn.update(query % (project_name, creator_name,
RoleType.ADMIN.value, create_by, create_reason))
logging.info(
f"Userrole initialized with query: {query%(project_name, creator_name, RoleType.ADMIN.value, create_by, create_reason)}")
return self.get_userroles()