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

[Backend] Add Role-Based Permissions [#2606] #2671

Merged
merged 16 commits into from
Feb 27, 2023
Merged
Show file tree
Hide file tree
Changes from 15 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
8 changes: 8 additions & 0 deletions .fides/db_dataset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ dataset:
data_categories:
- system.operations
data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified
- name: roles
data_categories:
- system.operations
data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified
- name: updated_at
data_categories:
- system.operations
Expand Down Expand Up @@ -615,6 +619,10 @@ dataset:
data_categories:
- system.operations
data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified
- name: roles
data_categories:
- system.operations
data_qualifier: aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified
- name: updated_at
data_categories:
- system.operations
Expand Down
1 change: 1 addition & 0 deletions scripts/create_test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ def create_test_data(db: orm.Session) -> FidesUser:
"hashed_secret": "autoseededdata",
"salt": "autoseededdata",
"scopes": [],
"roles": [],
},
)

Expand Down
7 changes: 5 additions & 2 deletions scripts/setup/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from fides.api.ops.api.v1 import urn_registry as urls
from fides.api.ops.api.v1.scope_registry import SCOPE_REGISTRY
from fides.core.config import CONFIG
from fides.lib.oauth.roles import ADMIN

from . import constants

Expand All @@ -14,7 +16,7 @@ def create_user(
username=constants.FIDES_USERNAME,
password=constants.FIDES_PASSWORD,
):
"""Adds a user with full permissions"""
"""Adds a user with full permissions - all scopes and admin role"""
login_response = requests.post(
f"{constants.BASE_URL}{urls.LOGIN}",
headers=auth_header,
Expand Down Expand Up @@ -52,7 +54,8 @@ def create_user(
headers=auth_header,
json={
"id": user_id,
"scopes": SCOPE_REGISTRY,
"scopes": CONFIG.security.root_user_scopes,
"roles": CONFIG.security.root_user_roles,
},
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""add role based permissions

Revision ID: eb1e6ec39b83
Revises: d65bbc647083
Create Date: 2023-02-24 19:27:17.844231

"""
import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
from sqlalchemy.dialects import postgresql

revision = "eb1e6ec39b83"
down_revision = "d65bbc647083"
branch_labels = None
depends_on = None


def upgrade():
op.add_column(
"client",
sa.Column("roles", sa.ARRAY(sa.String()), server_default="{}", nullable=False),
pattisdr marked this conversation as resolved.
Show resolved Hide resolved
)
op.add_column(
"fidesuserpermissions",
sa.Column("roles", sa.ARRAY(sa.String()), server_default="{}", nullable=False),
)
op.alter_column(
"fidesuserpermissions",
"scopes",
existing_type=postgresql.ARRAY(sa.VARCHAR()),
nullable=False,
server_default="{}",
)
op.alter_column(
"client",
"scopes",
existing_type=postgresql.ARRAY(sa.VARCHAR()),
nullable=False,
server_default="{}",
)


def downgrade():
op.drop_column("fidesuserpermissions", "roles")
op.drop_column("client", "roles")

op.alter_column(
"fidesuserpermissions",
"scopes",
existing_type=postgresql.ARRAY(sa.VARCHAR()),
nullable=False,
server_default=None,
)

op.alter_column(
"client",
"scopes",
existing_type=postgresql.ARRAY(sa.VARCHAR()),
nullable=False,
server_default=None,
)
34 changes: 27 additions & 7 deletions src/fides/api/ops/api/v1/endpoints/oauth_endpoints.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import List, Optional
from typing import Dict, List, Optional

from fastapi import Body, Depends, HTTPException, Request, Security
from fastapi.security import HTTPBasic
Expand Down Expand Up @@ -27,6 +27,7 @@
CLIENT_BY_ID,
CLIENT_SCOPE,
OAUTH_CALLBACK,
ROLE,
SCOPE,
TOKEN,
V1_URL_PREFIX,
Expand All @@ -49,6 +50,7 @@
from fides.api.ops.util.oauth_util import verify_oauth_client
from fides.core.config import CONFIG
from fides.lib.models.client import ClientDetail
from fides.lib.oauth.roles import ROLES_TO_SCOPES_MAPPING
from fides.lib.oauth.schemas.oauth import (
AccessToken,
OAuth2ClientCredentialsRequestForm,
Expand Down Expand Up @@ -80,9 +82,13 @@ async def acquire_access_token(
else:
raise AuthenticationFailure(detail="Authentication Failure")

# scopes param is only used if client is root client, otherwise we use the client's associated scopes
# scopes/roles params are only used if client is root client, otherwise we use the client's associated scopes and/or roles
client_detail = ClientDetail.get(
db, object_id=client_id, config=CONFIG, scopes=SCOPE_REGISTRY
db,
object_id=client_id,
config=CONFIG,
scopes=CONFIG.security.root_user_scopes,
roles=CONFIG.security.root_user_roles,
)

if client_detail is None:
Expand All @@ -109,7 +115,7 @@ def create_client(
db: Session = Depends(get_db),
scopes: List[str] = Body([]),
) -> ClientCreatedResponse:
"""Creates a new client and returns the credentials"""
"""Creates a new client and returns the credentials. Only direct scopes can be added to the client via this endpoint."""
logger.info("Creating new client")
if not all(scope in SCOPE_REGISTRY for scope in scopes):
raise HTTPException(
Expand Down Expand Up @@ -145,13 +151,15 @@ def delete_client(client_id: str, db: Session = Depends(get_db)) -> None:
response_model=List[str],
)
def get_client_scopes(client_id: str, db: Session = Depends(get_db)) -> List[str]:
"""Returns a list of the scopes associated with the client. Returns an empty list if client does not exist."""
"""Returns a list of the directly-assigned scopes associated with the client.
Does not return roles associated with the client.
Returns an empty list if client does not exist."""
client = ClientDetail.get(db, object_id=client_id, config=CONFIG)
if not client:
return []

logger.info("Getting client scopes")
return client.scopes
return client.scopes or []


@router.put(
Expand All @@ -164,7 +172,9 @@ def set_client_scopes(
scopes: List[str],
db: Session = Depends(get_db),
) -> None:
"""Overwrites the client's scopes with those provided. Does nothing if the client doesn't exist"""
"""Overwrites the client's directly-assigned scopes with those provided.
Roles cannot be edited via this endpoint.
Does nothing if the client doesn't exist"""
client = ClientDetail.get(db, object_id=client_id, config=CONFIG)
if not client:
return
Expand All @@ -190,6 +200,16 @@ def read_scopes() -> List[str]:
return SCOPE_REGISTRY


@router.get(
ROLE,
dependencies=[Security(verify_oauth_client, scopes=[SCOPE_READ])],
)
def read_roles_to_scopes_mapping() -> Dict[str, List]:
"""Returns a list of all roles and associated scopes available for assignment in the system"""
logger.info("Getting all available roles")
return ROLES_TO_SCOPES_MAPPING


@router.get(OAUTH_CALLBACK, response_model=None)
def oauth_callback(code: str, state: str, db: Session = Depends(get_db)) -> None:
"""
Expand Down
21 changes: 17 additions & 4 deletions src/fides/api/ops/api/v1/endpoints/user_permission_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from fides.api.ops.api import deps
from fides.api.ops.api.v1 import urn_registry as urls
from fides.api.ops.api.v1.scope_registry import (
SCOPE_REGISTRY,
USER_PERMISSION_CREATE,
USER_PERMISSION_READ,
USER_PERMISSION_UPDATE,
Expand Down Expand Up @@ -55,12 +54,17 @@ def create_user_permissions(
user_id: str,
permissions: UserPermissionsCreate,
) -> FidesUserPermissions:
"""Create user permissions with big picture roles and/or scopes."""
user = validate_user_id(db, user_id)
if user.permissions is not None: # type: ignore[attr-defined]
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST,
detail="This user already has permissions set.",
)

if user.client:
# Just in case - this shouldn't happen in practice.
user.client.update(db=db, data=permissions.dict())
logger.info("Created FidesUserPermission record")
return FidesUserPermissions.create(
db=db, data={"user_id": user_id, **permissions.dict()}
Expand All @@ -78,13 +82,21 @@ def update_user_permissions(
user_id: str,
permissions: UserPermissionsEdit,
) -> FidesUserPermissions:
"""Update either a user's role(s) and/or scope(s).

Typically we'll assign roles to a user and they'll inherit the associated scopes,
but we're still supporting assigning scopes directly as well.
"""
user = validate_user_id(db, user_id)
logger.info("Updated FidesUserPermission record")

if user.client:
user.client.update(db=db, data={"scopes": permissions.scopes})
user.client.update(
db=db, data={"scopes": permissions.scopes, "roles": permissions.roles}
)
return user.permissions.update( # type: ignore[attr-defined]
db=db,
data={"id": user.permissions.id, "user_id": user_id, **permissions.dict()}, # type: ignore[attr-defined]
data={"id": user.permissions.id, "user_id": user_id, "scopes": permissions.scopes, "roles": permissions.roles}, # type: ignore[attr-defined]
sanders41 marked this conversation as resolved.
Show resolved Hide resolved
)


Expand All @@ -107,7 +119,8 @@ async def get_user_permissions(
return FidesUserPermissions(
id=CONFIG.security.oauth_root_client_id,
user_id=CONFIG.security.oauth_root_client_id,
scopes=SCOPE_REGISTRY,
scopes=CONFIG.security.root_user_scopes,
roles=CONFIG.security.root_user_roles,
)

logger.info("Retrieved FidesUserPermission record for current user")
Expand Down
1 change: 1 addition & 0 deletions src/fides/api/ops/api/v1/urn_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
TOKEN = "/oauth/token"
CLIENT = "/oauth/client"
SCOPE = "/oauth/scope"
ROLE = "/oauth/role"
CLIENT_BY_ID = "/oauth/client/{client_id}"
CLIENT_SCOPE = "/oauth/client/{client_id}/scope"
OAUTH_CALLBACK = "/oauth/callback"
Expand Down
44 changes: 28 additions & 16 deletions src/fides/api/ops/schemas/user_permission.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
from typing import List
from typing import List, Optional

from fastapi import HTTPException
from pydantic import validator
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY

from fides.api.ops.api.v1.scope_registry import SCOPE_REGISTRY
from fides.lib.oauth.schemas.user_permission import (
UserPermissionsCreate as UserPermissionsCreateLib,
)
from fides.lib.oauth.schemas.user_permission import (
UserPermissionsEdit as UserPermissionsEditLib,
)
from fides.lib.oauth.schemas.user_permission import (
UserPermissionsResponse as UserPermissionsResponseLib,
)
from fides.api.ops.schemas.base_class import BaseSchema
from fides.lib.oauth.roles import RoleRegistry


class UserPermissionsCreate(UserPermissionsCreateLib):
"""Data required to create a FidesUserPermissions record"""
class UserPermissionsCreate(BaseSchema):
"""Data required to create a FidesUserPermissions record

Users will generally be assigned role(s) directly which are associated with many scopes,
but we also will continue to support the ability to be assigned specific individual scopes.
"""

scopes: List[str] = []
pattisdr marked this conversation as resolved.
Show resolved Hide resolved
roles: List[RoleRegistry] = []

@validator("scopes")
@classmethod
Expand All @@ -31,10 +31,22 @@ def validate_scopes(cls, scopes: List[str]) -> List[str]:
)
return scopes

class Config:
"""So roles are strings when we add to the db"""

use_enum_values = True


class UserPermissionsEdit(UserPermissionsCreate):
"""Data required to edit a FidesUserPermissions record."""

id: Optional[
str
] # I don't think this should be in the request body, so making it optional.
pattisdr marked this conversation as resolved.
Show resolved Hide resolved

class UserPermissionsEdit(UserPermissionsEditLib):
"""Data required to edit a FidesUserPermissions record"""

class UserPermissionsResponse(UserPermissionsCreate):
"""Response after creating, editing, or retrieving a FidesUserPermissions record."""

class UserPermissionsResponse(UserPermissionsResponseLib):
"""Response after creating, editing, or retrieving a FidesUserPermissions record"""
id: str
user_id: str
Loading