From aba29dd514abaa8caeab1b807c8f2735d10058c5 Mon Sep 17 00:00:00 2001 From: Yuqing Wei Date: Mon, 1 Aug 2022 21:52:33 +0800 Subject: [PATCH] Extend Access Control Management APIs to Project Admins (#535) * enable project admin to use management APIs * update readme * fix comments * update docs --- registry/access_control/README.md | 31 +++++++----- registry/access_control/api.py | 64 ++++++++++++++++--------- registry/access_control/rbac/db_rbac.py | 43 ++++++++++++----- 3 files changed, 91 insertions(+), 47 deletions(-) diff --git a/registry/access_control/README.md b/registry/access_control/README.md index 54d11bd51..ca282f51d 100644 --- a/registry/access_control/README.md +++ b/registry/access_control/README.md @@ -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 @@ -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 \ No newline at end of file diff --git a/registry/access_control/api.py b/registry/access_control/api.py index 63c3811d7..a47c9233c 100644 --- a/registry/access_control/api.py +++ b/registry/access_control/api.py @@ -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) - diff --git a/registry/access_control/rbac/db_rbac.py b/registry/access_control/rbac/db_rbac.py index 0363b1f7f..f822a4b32 100644 --- a/registry/access_control/rbac/db_rbac.py +++ b/registry/access_control/rbac/db_rbac.py @@ -5,6 +5,7 @@ import os import logging + class DbRBAC(RBAC): def __init__(self): if not os.environ.get("RBAC_CONNECTION_STR"): @@ -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() @@ -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)) @@ -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 """ @@ -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 @@ -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 @@ -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() \ No newline at end of file + 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()