Skip to content

Commit

Permalink
Extend Access Control Management APIs to Project Admins (feathr-ai#535)
Browse files Browse the repository at this point in the history
* enable project admin to use management APIs

* update readme

* fix comments

* update docs
  • Loading branch information
Yuqing-cat authored and ahlag committed Aug 26, 2022
1 parent 8a50337 commit aba29dd
Show file tree
Hide file tree
Showing 3 changed files with 91 additions and 47 deletions.
31 changes: 18 additions & 13 deletions registry/access_control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,27 @@ 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.
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.
In current version, user needs to manually initialize `userroles` table in SQL database with [schema.sql](scripts/schema.sql) and insert global admin roles.
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]', 'Initialize First Global Admin', getutcdate())
```

When a feathr project is created though rbac protected registry API, default project admin role will be assigned to the creator.
Admin roles can add or delete roles in management UI page or through management API under certain scope.

### 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 +72,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

64 changes: 42 additions & 22 deletions registry/access_control/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,87 +11,107 @@
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')
response = requests.get(
url=f"{registry_url}/projects").content.decode('utf-8')
return json.loads(response)


@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(url=f"{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(url=f"{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(url=f"{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(url=f"{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(url=f"{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=f"{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=f"{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=f"{registry_url}/projects/{project}/anchors", 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=f"{registry_url}/projects/{project}/anchors/{anchor}/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=f"{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()

0 comments on commit aba29dd

Please sign in to comment.