Feature/regex support in permissions (#530)

* support wildcards when adding users to groups from waiting table

* moved the user route to authentication_controller to avoid having so many user routes and this controller was all about login

* added test to ensure regexes work for permissions - still need to remove old ones on refresh

* moved token related code out of authorization service and into authentication service w/ burnettk

* remove old user group assignment waiting entries when refreshing permissions w/ burnettk

---------

Co-authored-by: jasquat <jasquat@users.noreply.github.com>
This commit is contained in:
jasquat 2023-10-06 13:47:40 -04:00 committed by GitHub
parent 01ef4e6eaa
commit 8bf92f7a39
17 changed files with 275 additions and 170 deletions

View File

@ -24,9 +24,9 @@ from spiffworkflow_backend.exceptions.api_error import api_error_blueprint
from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX
from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.db import migrate from spiffworkflow_backend.models.db import migrate
from spiffworkflow_backend.routes.authentication_controller import _set_new_access_token_in_cookie
from spiffworkflow_backend.routes.authentication_controller import verify_token
from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import openid_blueprint from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import openid_blueprint
from spiffworkflow_backend.routes.user import _set_new_access_token_in_cookie
from spiffworkflow_backend.routes.user import verify_token
from spiffworkflow_backend.routes.user_blueprint import user_blueprint from spiffworkflow_backend.routes.user_blueprint import user_blueprint
from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.background_processing_service import BackgroundProcessingService from spiffworkflow_backend.services.background_processing_service import BackgroundProcessingService

View File

@ -29,7 +29,7 @@ paths:
type: string type: string
get: get:
summary: redirect to open id authentication server summary: redirect to open id authentication server
operationId: spiffworkflow_backend.routes.user.login operationId: spiffworkflow_backend.routes.authentication_controller.login
tags: tags:
- Authentication - Authentication
responses: responses:
@ -53,7 +53,7 @@ paths:
schema: schema:
type: string type: string
get: get:
operationId: spiffworkflow_backend.routes.user.login_return operationId: spiffworkflow_backend.routes.authentication_controller.login_return
tags: tags:
- Authentication - Authentication
responses: responses:
@ -72,7 +72,7 @@ paths:
schema: schema:
type: string type: string
get: get:
operationId: spiffworkflow_backend.routes.user.logout operationId: spiffworkflow_backend.routes.authentication_controller.logout
summary: Logout authenticated user summary: Logout authenticated user
tags: tags:
- Authentication - Authentication
@ -81,7 +81,7 @@ paths:
description: Logout Authenticated User description: Logout Authenticated User
/logout_return: /logout_return:
get: get:
operationId: spiffworkflow_backend.routes.user.logout_return operationId: spiffworkflow_backend.routes.authentication_controller.logout_return
summary: Logout authenticated user summary: Logout authenticated user
tags: tags:
- Authentication - Authentication
@ -97,7 +97,7 @@ paths:
schema: schema:
type: string type: string
post: post:
operationId: spiffworkflow_backend.routes.user.login_with_access_token operationId: spiffworkflow_backend.routes.authentication_controller.login_with_access_token
summary: Authenticate user for API access with an openid token already posessed. summary: Authenticate user for API access with an openid token already posessed.
tags: tags:
- Authentication - Authentication
@ -111,7 +111,7 @@ paths:
/login_api: /login_api:
get: get:
operationId: spiffworkflow_backend.routes.user.login_api operationId: spiffworkflow_backend.routes.authentication_controller.login_api
summary: Authenticate user for API access summary: Authenticate user for API access
tags: tags:
- Authentication - Authentication
@ -136,7 +136,7 @@ paths:
schema: schema:
type: string type: string
get: get:
operationId: spiffworkflow_backend.routes.user.login_api_return operationId: spiffworkflow_backend.routes.authentication_controller.login_api_return
tags: tags:
- Authentication - Authentication
responses: responses:
@ -2628,8 +2628,8 @@ components:
type: http type: http
scheme: bearer scheme: bearer
bearerFormat: JWT bearerFormat: JWT
x-bearerInfoFunc: spiffworkflow_backend.routes.user.verify_token x-bearerInfoFunc: spiffworkflow_backend.routes.authentication_controller.verify_token
x-scopeValidateFunc: spiffworkflow_backend.routes.user.validate_scope x-scopeValidateFunc: spiffworkflow_backend.routes.authentication_controller.validate_scope
oAuth2AuthCode: oAuth2AuthCode:
type: oauth2 type: oauth2
@ -2640,7 +2640,7 @@ components:
tokenUrl: /v1.0/login_api_return tokenUrl: /v1.0/login_api_return
scopes: scopes:
read_email: read email read_email: read email
x-tokenInfoFunc: spiffworkflow_backend.routes.user.get_scope x-tokenInfoFunc: spiffworkflow_backend.routes.authentication_controller.get_scope
schemas: schemas:
OkTrue: OkTrue:

View File

@ -20,11 +20,11 @@ from SpiffWorkflow.exceptions import SpiffWorkflowException # type: ignore
from SpiffWorkflow.exceptions import WorkflowException from SpiffWorkflow.exceptions import WorkflowException
from SpiffWorkflow.specs.base import TaskSpec # type: ignore from SpiffWorkflow.specs.base import TaskSpec # type: ignore
from SpiffWorkflow.task import Task # type: ignore from SpiffWorkflow.task import Task # type: ignore
from spiffworkflow_backend.exceptions.error import NotAuthorizedError
from spiffworkflow_backend.exceptions.error import TokenInvalidError
from spiffworkflow_backend.exceptions.error import TokenNotProvidedError
from spiffworkflow_backend.exceptions.error import UserNotLoggedInError
from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.models.task import TaskModel # noqa: F401
from spiffworkflow_backend.services.authentication_service import NotAuthorizedError
from spiffworkflow_backend.services.authentication_service import TokenInvalidError
from spiffworkflow_backend.services.authentication_service import TokenNotProvidedError
from spiffworkflow_backend.services.authentication_service import UserNotLoggedInError
from spiffworkflow_backend.services.task_service import TaskModelError from spiffworkflow_backend.services.task_service import TaskModelError
from spiffworkflow_backend.services.task_service import TaskService from spiffworkflow_backend.services.task_service import TaskService
from werkzeug.exceptions import MethodNotAllowed from werkzeug.exceptions import MethodNotAllowed

View File

@ -0,0 +1,53 @@
class MissingAccessTokenError(Exception):
pass
class RefreshTokenStorageError(Exception):
pass
# These could be either 'id' OR 'access' tokens and we can't always know which
class TokenExpiredError(Exception):
pass
class TokenInvalidError(Exception):
pass
class TokenNotProvidedError(Exception):
pass
class OpenIdConnectionError(Exception):
pass
class UserNotLoggedInError(Exception):
pass
class NotAuthorizedError(Exception):
pass
class PermissionsFileNotSetError(Exception):
pass
class HumanTaskNotFoundError(Exception):
pass
class HumanTaskAlreadyCompletedError(Exception):
pass
class UserDoesNotHaveAccessToTaskError(Exception):
pass
class InvalidPermissionError(Exception):
pass

View File

@ -12,17 +12,21 @@ class UserGroupAssignmentWaitingModel(SpiffworkflowBaseDBModel):
We cache it here to be applied in the event the user does log in to the system. We cache it here to be applied in the event the user does log in to the system.
""" """
MATCH_ALL_USERS = "*"
__tablename__ = "user_group_assignment_waiting" __tablename__ = "user_group_assignment_waiting"
__table_args__ = (db.UniqueConstraint("username", "group_id", name="user_group_assignment_staged_unique"),) __table_args__ = (db.UniqueConstraint("username", "group_id", name="user_group_assignment_staged_unique"),)
id = db.Column(db.Integer, primary_key=True) id: int = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(255), nullable=False) username: str = db.Column(db.String(255), nullable=False)
group_id = db.Column(ForeignKey(GroupModel.id), nullable=False, index=True) group_id: int = db.Column(ForeignKey(GroupModel.id), nullable=False, index=True)
group = relationship("GroupModel", overlaps="groups,user_group_assignments_waiting,users") # type: ignore group = relationship("GroupModel", overlaps="groups,user_group_assignments_waiting,users") # type: ignore
def is_match_all(self) -> bool: def is_wildcard(self) -> bool:
if self.username == self.MATCH_ALL_USERS: if self.username.startswith("REGEX:"):
return True return True
return False return False
def pattern_from_wildcard_username(self) -> str | None:
if self.is_wildcard():
return self.username.removeprefix("REGEX:")
return None

View File

@ -15,6 +15,8 @@ from flask import request
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.exceptions.error import MissingAccessTokenError
from spiffworkflow_backend.exceptions.error import TokenExpiredError
from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX
from spiffworkflow_backend.models.group import SPIFF_GUEST_GROUP from spiffworkflow_backend.models.group import SPIFF_GUEST_GROUP
from spiffworkflow_backend.models.group import SPIFF_NO_AUTH_GROUP from spiffworkflow_backend.models.group import SPIFF_NO_AUTH_GROUP
@ -24,8 +26,6 @@ from spiffworkflow_backend.models.user import SPIFF_GUEST_USER
from spiffworkflow_backend.models.user import SPIFF_NO_AUTH_USER from spiffworkflow_backend.models.user import SPIFF_NO_AUTH_USER
from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.services.authentication_service import AuthenticationService from spiffworkflow_backend.services.authentication_service import AuthenticationService
from spiffworkflow_backend.services.authentication_service import MissingAccessTokenError
from spiffworkflow_backend.services.authentication_service import TokenExpiredError
from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.user_service import UserService from spiffworkflow_backend.services.user_service import UserService
@ -90,7 +90,7 @@ def verify_token(token: str | None = None, force_run: bool | None = False) -> No
def login(redirect_url: str = "/", process_instance_id: int | None = None, task_guid: str | None = None) -> Response: def login(redirect_url: str = "/", process_instance_id: int | None = None, task_guid: str | None = None) -> Response:
if current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED"): if current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED"):
AuthorizationService.create_guest_token( AuthenticationService.create_guest_token(
username=SPIFF_NO_AUTH_USER, username=SPIFF_NO_AUTH_USER,
group_identifier=SPIFF_NO_AUTH_GROUP, group_identifier=SPIFF_NO_AUTH_GROUP,
permission_target="/*", permission_target="/*",
@ -99,7 +99,7 @@ def login(redirect_url: str = "/", process_instance_id: int | None = None, task_
return redirect(redirect_url) return redirect(redirect_url)
if process_instance_id and task_guid and TaskModel.task_guid_allows_guest(task_guid, process_instance_id): if process_instance_id and task_guid and TaskModel.task_guid_allows_guest(task_guid, process_instance_id):
AuthorizationService.create_guest_token( AuthenticationService.create_guest_token(
username=SPIFF_GUEST_USER, username=SPIFF_GUEST_USER,
group_identifier=SPIFF_GUEST_GROUP, group_identifier=SPIFF_GUEST_GROUP,
auth_token_properties={"only_guest_task_completion": True}, auth_token_properties={"only_guest_task_completion": True},

View File

@ -20,7 +20,7 @@ from spiffworkflow_backend.models.process_instance_file_data import ProcessInsta
from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.process_model import ProcessModelInfo
from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel
from spiffworkflow_backend.models.reference_cache import ReferenceSchema from spiffworkflow_backend.models.reference_cache import ReferenceSchema
from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.services.authentication_service import AuthenticationService # noqa: F401
from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.git_service import GitService from spiffworkflow_backend.services.git_service import GitService
from spiffworkflow_backend.services.process_caller_service import ProcessCallerService from spiffworkflow_backend.services.process_caller_service import ProcessCallerService
@ -178,7 +178,7 @@ def process_data_file_download(
# where 7000 is the port the app is running on locally # where 7000 is the port the app is running on locally
def github_webhook_receive(body: dict) -> Response: def github_webhook_receive(body: dict) -> Response:
auth_header = request.headers.get("X-Hub-Signature-256") auth_header = request.headers.get("X-Hub-Signature-256")
AuthorizationService.verify_sha256_token(auth_header) AuthenticationService.verify_sha256_token(auth_header)
result = GitService.handle_web_hook(body) result = GitService.handle_web_hook(body)
return Response(json.dumps({"git_pull": result}), status=200, mimetype="application/json") return Response(json.dumps({"git_pull": result}), status=200, mimetype="application/json")

View File

@ -10,7 +10,7 @@ from flask import request
from flask.wrappers import Response from flask.wrappers import Response
from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.routes.user import verify_token from spiffworkflow_backend.routes.authentication_controller import verify_token
from spiffworkflow_backend.services.oauth_service import OAuthService from spiffworkflow_backend.services.oauth_service import OAuthService
from spiffworkflow_backend.services.secret_service import SecretService from spiffworkflow_backend.services.secret_service import SecretService
from spiffworkflow_backend.services.service_task_service import ServiceTaskService from spiffworkflow_backend.services.service_task_service import ServiceTaskService

View File

@ -27,6 +27,9 @@ from sqlalchemy.orm import aliased
from sqlalchemy.orm.util import AliasedClass from sqlalchemy.orm.util import AliasedClass
from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError
from spiffworkflow_backend.exceptions.error import HumanTaskNotFoundError
from spiffworkflow_backend.exceptions.error import UserDoesNotHaveAccessToTaskError
from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel
from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.group import GroupModel
@ -49,9 +52,6 @@ from spiffworkflow_backend.routes.process_api_blueprint import _find_principal_o
from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_by_id_or_raise from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_by_id_or_raise
from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model
from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.authorization_service import HumanTaskAlreadyCompletedError
from spiffworkflow_backend.services.authorization_service import HumanTaskNotFoundError
from spiffworkflow_backend.services.authorization_service import UserDoesNotHaveAccessToTaskError
from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService
from spiffworkflow_backend.services.file_system_service import FileSystemService from spiffworkflow_backend.services.file_system_service import FileSystemService
from spiffworkflow_backend.services.git_service import GitCommandError from spiffworkflow_backend.services.git_service import GitCommandError

View File

@ -2,52 +2,29 @@ import base64
import enum import enum
import json import json
import time import time
from hashlib import sha256
from hmac import HMAC
from hmac import compare_digest
import jwt import jwt
import requests import requests
from flask import current_app from flask import current_app
from flask import g
from flask import redirect from flask import redirect
from flask import request
from spiffworkflow_backend.config import HTTP_REQUEST_TIMEOUT_SECONDS from spiffworkflow_backend.config import HTTP_REQUEST_TIMEOUT_SECONDS
from spiffworkflow_backend.exceptions.error import OpenIdConnectionError
from spiffworkflow_backend.exceptions.error import RefreshTokenStorageError
from spiffworkflow_backend.exceptions.error import TokenExpiredError
from spiffworkflow_backend.exceptions.error import TokenInvalidError
from spiffworkflow_backend.exceptions.error import TokenNotProvidedError
from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.refresh_token import RefreshTokenModel from spiffworkflow_backend.models.refresh_token import RefreshTokenModel
from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.user_service import UserService
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
class MissingAccessTokenError(Exception):
pass
class NotAuthorizedError(Exception):
pass
class RefreshTokenStorageError(Exception):
pass
class UserNotLoggedInError(Exception):
pass
# These could be either 'id' OR 'access' tokens and we can't always know which
class TokenExpiredError(Exception):
pass
class TokenInvalidError(Exception):
pass
class TokenNotProvidedError(Exception):
pass
class OpenIdConnectionError(Exception):
pass
class AuthenticationProviderTypes(enum.Enum): class AuthenticationProviderTypes(enum.Enum):
open_id = "open_id" open_id = "open_id"
internal = "internal" internal = "internal"
@ -249,3 +226,57 @@ class AuthenticationService:
response = requests.post(request_url, data=data, headers=headers, timeout=HTTP_REQUEST_TIMEOUT_SECONDS) response = requests.post(request_url, data=data, headers=headers, timeout=HTTP_REQUEST_TIMEOUT_SECONDS)
auth_token_object: dict = json.loads(response.text) auth_token_object: dict = json.loads(response.text)
return auth_token_object return auth_token_object
@staticmethod
def decode_auth_token(auth_token: str) -> dict[str, str | None]:
secret_key = current_app.config.get("SECRET_KEY")
if secret_key is None:
raise KeyError("we need current_app.config to have a SECRET_KEY")
try:
payload = jwt.decode(auth_token, options={"verify_signature": False})
return payload
except jwt.ExpiredSignatureError as exception:
raise TokenExpiredError(
"The Authentication token you provided expired and must be renewed.",
) from exception
except jwt.InvalidTokenError as exception:
raise TokenInvalidError(
"The Authentication token you provided is invalid. You need a new token. ",
) from exception
# https://stackoverflow.com/a/71320673/6090676
@classmethod
def verify_sha256_token(cls, auth_header: str | None) -> None:
if auth_header is None:
raise TokenNotProvidedError(
"unauthorized",
)
received_sign = auth_header.split("sha256=")[-1].strip()
secret = current_app.config["SPIFFWORKFLOW_BACKEND_GITHUB_WEBHOOK_SECRET"].encode()
expected_sign = HMAC(key=secret, msg=request.data, digestmod=sha256).hexdigest()
if not compare_digest(received_sign, expected_sign):
raise TokenInvalidError(
"unauthorized",
)
@classmethod
def create_guest_token(
cls,
username: str,
group_identifier: str,
permission_target: str | None = None,
permission: str = "all",
auth_token_properties: dict | None = None,
) -> None:
guest_user = UserService.find_or_create_guest_user(username=username, group_identifier=group_identifier)
if permission_target is not None:
AuthorizationService.add_permission_from_uri_or_macro(
group_identifier, permission=permission, target=permission_target
)
g.user = guest_user
g.token = guest_user.encode_auth_token(auth_token_properties)
tld = current_app.config["THREAD_LOCAL_DATA"]
tld.new_access_token = g.token
tld.new_id_token = g.token

View File

@ -1,17 +1,20 @@
import inspect import inspect
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from hashlib import sha256
from hmac import HMAC
from hmac import compare_digest
from typing import TypedDict from typing import TypedDict
import jwt
import yaml import yaml
from flask import current_app from flask import current_app
from flask import g from flask import g
from flask import request from flask import request
from flask import scaffold from flask import scaffold
from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError
from spiffworkflow_backend.exceptions.error import HumanTaskNotFoundError
from spiffworkflow_backend.exceptions.error import InvalidPermissionError
from spiffworkflow_backend.exceptions.error import NotAuthorizedError
from spiffworkflow_backend.exceptions.error import PermissionsFileNotSetError
from spiffworkflow_backend.exceptions.error import UserDoesNotHaveAccessToTaskError
from spiffworkflow_backend.exceptions.error import UserNotLoggedInError
from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX
from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.group import SPIFF_GUEST_GROUP from spiffworkflow_backend.models.group import SPIFF_GUEST_GROUP
@ -26,38 +29,14 @@ from spiffworkflow_backend.models.task import TaskModel # noqa: F401
from spiffworkflow_backend.models.user import SPIFF_GUEST_USER from spiffworkflow_backend.models.user import SPIFF_GUEST_USER
from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.models.user_group_assignment import UserGroupAssignmentModel from spiffworkflow_backend.models.user_group_assignment import UserGroupAssignmentModel
from spiffworkflow_backend.models.user_group_assignment_waiting import UserGroupAssignmentWaitingModel
from spiffworkflow_backend.routes.openid_blueprint import openid_blueprint from spiffworkflow_backend.routes.openid_blueprint import openid_blueprint
from spiffworkflow_backend.services.authentication_service import NotAuthorizedError
from spiffworkflow_backend.services.authentication_service import TokenExpiredError
from spiffworkflow_backend.services.authentication_service import TokenInvalidError
from spiffworkflow_backend.services.authentication_service import TokenNotProvidedError
from spiffworkflow_backend.services.authentication_service import UserNotLoggedInError
from spiffworkflow_backend.services.user_service import UserService from spiffworkflow_backend.services.user_service import UserService
from sqlalchemy import and_ from sqlalchemy import and_
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy import text from sqlalchemy import text
class PermissionsFileNotSetError(Exception):
pass
class HumanTaskNotFoundError(Exception):
pass
class HumanTaskAlreadyCompletedError(Exception):
pass
class UserDoesNotHaveAccessToTaskError(Exception):
pass
class InvalidPermissionError(Exception):
pass
@dataclass @dataclass
class PermissionToAssign: class PermissionToAssign:
permission: str permission: str
@ -104,6 +83,7 @@ class AddedPermissionDict(TypedDict):
group_identifiers: set[str] group_identifiers: set[str]
permission_assignments: list[PermissionAssignmentModel] permission_assignments: list[PermissionAssignmentModel]
user_to_group_identifiers: list[UserToGroupDict] user_to_group_identifiers: list[UserToGroupDict]
waiting_user_group_assignments: list[UserGroupAssignmentWaitingModel]
class DesiredGroupPermissionDict(TypedDict): class DesiredGroupPermissionDict(TypedDict):
@ -120,40 +100,6 @@ class GroupPermissionsDict(TypedDict):
class AuthorizationService: class AuthorizationService:
"""Determine whether a user has permission to perform their request.""" """Determine whether a user has permission to perform their request."""
# https://stackoverflow.com/a/71320673/6090676
@classmethod
def verify_sha256_token(cls, auth_header: str | None) -> None:
if auth_header is None:
raise TokenNotProvidedError(
"unauthorized",
)
received_sign = auth_header.split("sha256=")[-1].strip()
secret = current_app.config["SPIFFWORKFLOW_BACKEND_GITHUB_WEBHOOK_SECRET"].encode()
expected_sign = HMAC(key=secret, msg=request.data, digestmod=sha256).hexdigest()
if not compare_digest(received_sign, expected_sign):
raise TokenInvalidError(
"unauthorized",
)
@classmethod
def create_guest_token(
cls,
username: str,
group_identifier: str,
permission_target: str | None = None,
permission: str = "all",
auth_token_properties: dict | None = None,
) -> None:
guest_user = UserService.find_or_create_guest_user(username=username, group_identifier=group_identifier)
if permission_target is not None:
cls.add_permission_from_uri_or_macro(group_identifier, permission=permission, target=permission_target)
g.user = guest_user
g.token = guest_user.encode_auth_token(auth_token_properties)
tld = current_app.config["THREAD_LOCAL_DATA"]
tld.new_access_token = g.token
tld.new_id_token = g.token
@classmethod @classmethod
def has_permission(cls, principals: list[PrincipalModel], permission: str, target_uri: str) -> bool: def has_permission(cls, principals: list[PrincipalModel], permission: str, target_uri: str) -> bool:
principal_ids = [p.id for p in principals] principal_ids = [p.id for p in principals]
@ -390,24 +336,6 @@ class AuthorizationService:
return True return True
return False return False
@staticmethod
def decode_auth_token(auth_token: str) -> dict[str, str | None]:
secret_key = current_app.config.get("SECRET_KEY")
if secret_key is None:
raise KeyError("we need current_app.config to have a SECRET_KEY")
try:
payload = jwt.decode(auth_token, options={"verify_signature": False})
return payload
except jwt.ExpiredSignatureError as exception:
raise TokenExpiredError(
"The Authentication token you provided expired and must be renewed.",
) from exception
except jwt.InvalidTokenError as exception:
raise TokenInvalidError(
"The Authentication token you provided is invalid. You need a new token. ",
) from exception
@staticmethod @staticmethod
def assert_user_can_complete_task( def assert_user_can_complete_task(
process_instance_id: int, process_instance_id: int,
@ -827,6 +755,7 @@ class AuthorizationService:
) -> AddedPermissionDict: ) -> AddedPermissionDict:
unique_user_group_identifiers: set[str] = set() unique_user_group_identifiers: set[str] = set()
user_to_group_identifiers: list[UserToGroupDict] = [] user_to_group_identifiers: list[UserToGroupDict] = []
waiting_user_group_assignments: list[UserGroupAssignmentWaitingModel] = []
permission_assignments = [] permission_assignments = []
default_group = None default_group = None
@ -847,8 +776,11 @@ class AuthorizationService:
"group_identifier": group_identifier, "group_identifier": group_identifier,
} }
user_to_group_identifiers.append(user_to_group_dict) user_to_group_identifiers.append(user_to_group_dict)
UserService.add_user_to_group_or_add_to_waiting(username, group_identifier) wugam = UserService.add_user_to_group_or_add_to_waiting(username, group_identifier)
if wugam is not None:
waiting_user_group_assignments.append(wugam)
unique_user_group_identifiers.add(group_identifier) unique_user_group_identifiers.add(group_identifier)
for group in group_permissions: for group in group_permissions:
group_identifier = group["name"] group_identifier = group["name"]
if user_model and group_identifier not in unique_user_group_identifiers: if user_model and group_identifier not in unique_user_group_identifiers:
@ -875,6 +807,7 @@ class AuthorizationService:
"group_identifiers": unique_user_group_identifiers, "group_identifiers": unique_user_group_identifiers,
"permission_assignments": permission_assignments, "permission_assignments": permission_assignments,
"user_to_group_identifiers": user_to_group_identifiers, "user_to_group_identifiers": user_to_group_identifiers,
"waiting_user_group_assignments": waiting_user_group_assignments,
} }
@classmethod @classmethod
@ -883,11 +816,13 @@ class AuthorizationService:
added_permissions: AddedPermissionDict, added_permissions: AddedPermissionDict,
initial_permission_assignments: list[PermissionAssignmentModel], initial_permission_assignments: list[PermissionAssignmentModel],
initial_user_to_group_assignments: list[UserGroupAssignmentModel], initial_user_to_group_assignments: list[UserGroupAssignmentModel],
initial_waiting_group_assignments: list[UserGroupAssignmentWaitingModel],
group_permissions_only: bool = False, group_permissions_only: bool = False,
) -> None: ) -> None:
added_permission_assignments = added_permissions["permission_assignments"] added_permission_assignments = added_permissions["permission_assignments"]
added_group_identifiers = added_permissions["group_identifiers"] added_group_identifiers = added_permissions["group_identifiers"]
added_user_to_group_identifiers = added_permissions["user_to_group_identifiers"] added_user_to_group_identifiers = added_permissions["user_to_group_identifiers"]
added_waiting_group_assignments = added_permissions["waiting_user_group_assignments"]
for ipa in initial_permission_assignments: for ipa in initial_permission_assignments:
if ipa not in added_permission_assignments: if ipa not in added_permission_assignments:
@ -913,6 +848,11 @@ class AuthorizationService:
groups_to_delete = GroupModel.query.filter(GroupModel.identifier.not_in(added_group_identifiers)).all() groups_to_delete = GroupModel.query.filter(GroupModel.identifier.not_in(added_group_identifiers)).all()
for gtd in groups_to_delete: for gtd in groups_to_delete:
db.session.delete(gtd) db.session.delete(gtd)
for wugam in initial_waiting_group_assignments:
if wugam not in added_waiting_group_assignments:
db.session.delete(wugam)
db.session.commit() db.session.commit()
@classmethod @classmethod
@ -930,6 +870,7 @@ class AuthorizationService:
.all() .all()
) )
initial_user_to_group_assignments = UserGroupAssignmentModel.query.all() initial_user_to_group_assignments = UserGroupAssignmentModel.query.all()
initial_waiting_group_assignments = UserGroupAssignmentWaitingModel.query.all()
group_permissions = group_permissions + cls.parse_permissions_yaml_into_group_info() group_permissions = group_permissions + cls.parse_permissions_yaml_into_group_info()
added_permissions = cls.add_permissions_from_group_permissions( added_permissions = cls.add_permissions_from_group_permissions(
group_permissions, group_permissions_only=group_permissions_only group_permissions, group_permissions_only=group_permissions_only
@ -938,5 +879,6 @@ class AuthorizationService:
added_permissions, added_permissions,
initial_permission_assignments, initial_permission_assignments,
initial_user_to_group_assignments, initial_user_to_group_assignments,
initial_waiting_group_assignments,
group_permissions_only=group_permissions_only, group_permissions_only=group_permissions_only,
) )

View File

@ -17,6 +17,9 @@ from SpiffWorkflow.task import Task as SpiffTask # type: ignore
from SpiffWorkflow.util.task import TaskState # type: ignore from SpiffWorkflow.util.task import TaskState # type: ignore
from spiffworkflow_backend import db from spiffworkflow_backend import db
from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError
from spiffworkflow_backend.exceptions.error import HumanTaskNotFoundError
from spiffworkflow_backend.exceptions.error import UserDoesNotHaveAccessToTaskError
from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.group import GroupModel
from spiffworkflow_backend.models.human_task import HumanTaskModel from spiffworkflow_backend.models.human_task import HumanTaskModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceApi from spiffworkflow_backend.models.process_instance import ProcessInstanceApi
@ -30,9 +33,6 @@ from spiffworkflow_backend.models.process_model_cycle import ProcessModelCycleMo
from spiffworkflow_backend.models.task import Task from spiffworkflow_backend.models.task import Task
from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.authorization_service import HumanTaskAlreadyCompletedError
from spiffworkflow_backend.services.authorization_service import HumanTaskNotFoundError
from spiffworkflow_backend.services.authorization_service import UserDoesNotHaveAccessToTaskError
from spiffworkflow_backend.services.git_service import GitCommandError from spiffworkflow_backend.services.git_service import GitCommandError
from spiffworkflow_backend.services.git_service import GitService from spiffworkflow_backend.services.git_service import GitService
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor

View File

@ -4,6 +4,7 @@ from datetime import datetime
from lxml import etree # type: ignore from lxml import etree # type: ignore
from SpiffWorkflow.bpmn.parser.BpmnParser import BpmnValidator # type: ignore from SpiffWorkflow.bpmn.parser.BpmnParser import BpmnValidator # type: ignore
from spiffworkflow_backend.exceptions.error import NotAuthorizedError
from spiffworkflow_backend.models.correlation_property_cache import CorrelationPropertyCache from spiffworkflow_backend.models.correlation_property_cache import CorrelationPropertyCache
from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.file import File from spiffworkflow_backend.models.file import File
@ -13,7 +14,6 @@ from spiffworkflow_backend.models.message_triggerable_process_model import Messa
from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.process_model import ProcessModelInfo
from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel
from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.services.authentication_service import NotAuthorizedError
from spiffworkflow_backend.services.custom_parser import MyCustomParser from spiffworkflow_backend.services.custom_parser import MyCustomParser
from spiffworkflow_backend.services.file_system_service import FileSystemService from spiffworkflow_backend.services.file_system_service import FileSystemService
from spiffworkflow_backend.services.process_caller_service import ProcessCallerService from spiffworkflow_backend.services.process_caller_service import ProcessCallerService

View File

@ -1,3 +1,4 @@
import re
from typing import Any from typing import Any
from flask import current_app from flask import current_app
@ -125,22 +126,28 @@ class UserService:
db.session.commit() db.session.commit()
@classmethod @classmethod
def add_waiting_group_assignment(cls, username: str, group: GroupModel) -> None: def add_waiting_group_assignment(cls, username: str, group: GroupModel) -> UserGroupAssignmentWaitingModel:
wugam = ( """Only called from set-permissions."""
wugam: UserGroupAssignmentWaitingModel | None = (
UserGroupAssignmentWaitingModel().query.filter_by(username=username).filter_by(group_id=group.id).first() UserGroupAssignmentWaitingModel().query.filter_by(username=username).filter_by(group_id=group.id).first()
) )
if not wugam: if wugam is None:
wugam = UserGroupAssignmentWaitingModel(username=username, group_id=group.id) wugam = UserGroupAssignmentWaitingModel(username=username, group_id=group.id)
db.session.add(wugam) db.session.add(wugam)
db.session.commit() db.session.commit()
# to handle people who are already signed in # backfill existing users
if wugam.is_match_all(): wildcard_pattern = wugam.pattern_from_wildcard_username()
for user in UserModel.query.all(): if wildcard_pattern is not None:
users = UserModel.query.filter(UserModel.username.regexp_match(wildcard_pattern)) # type: ignore
for user in users:
cls.add_user_to_group(user, group) cls.add_user_to_group(user, group)
return wugam
@classmethod @classmethod
def apply_waiting_group_assignments(cls, user: UserModel) -> None: def apply_waiting_group_assignments(cls, user: UserModel) -> None:
"""Only called from create_user which is normally called at sign-in time"""
waiting = ( waiting = (
UserGroupAssignmentWaitingModel() UserGroupAssignmentWaitingModel()
.query.filter(UserGroupAssignmentWaitingModel.username == user.username) .query.filter(UserGroupAssignmentWaitingModel.username == user.username)
@ -149,13 +156,14 @@ class UserService:
for assignment in waiting: for assignment in waiting:
cls.add_user_to_group(user, assignment.group) cls.add_user_to_group(user, assignment.group)
db.session.delete(assignment) db.session.delete(assignment)
wildcard = ( wildcards = (
UserGroupAssignmentWaitingModel() UserGroupAssignmentWaitingModel()
.query.filter(UserGroupAssignmentWaitingModel.username == UserGroupAssignmentWaitingModel.MATCH_ALL_USERS) .query.filter(UserGroupAssignmentWaitingModel.username.regexp_match("^REGEX:")) # type: ignore
.all() .all()
) )
for assignment in wildcard: for wildcard in wildcards:
cls.add_user_to_group(user, assignment.group) if re.match(wildcard.pattern_from_wildcard_username(), user.username):
cls.add_user_to_group(user, wildcard.group)
db.session.commit() db.session.commit()
@staticmethod @staticmethod
@ -226,13 +234,16 @@ class UserService:
return group return group
@classmethod @classmethod
def add_user_to_group_or_add_to_waiting(cls, username: str | UserModel, group_identifier: str) -> None: def add_user_to_group_or_add_to_waiting(
cls, username: str | UserModel, group_identifier: str
) -> UserGroupAssignmentWaitingModel | None:
group = cls.find_or_create_group(group_identifier) group = cls.find_or_create_group(group_identifier)
user = UserModel.query.filter_by(username=username).first() user = UserModel.query.filter_by(username=username).first()
if user: if user:
cls.add_user_to_group(user, group) cls.add_user_to_group(user, group)
else: else:
cls.add_waiting_group_assignment(username, group) return cls.add_waiting_group_assignment(username, group)
return None
@classmethod @classmethod
def add_user_to_group_by_group_identifier(cls, user: UserModel, group_identifier: str) -> None: def add_user_to_group_by_group_identifier(cls, user: UserModel, group_identifier: str) -> None:

View File

@ -1,10 +1,11 @@
import pytest import pytest
from flask import Flask from flask import Flask
from flask.testing import FlaskClient from flask.testing import FlaskClient
from spiffworkflow_backend.exceptions.error import InvalidPermissionError
from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.group import GroupModel
from spiffworkflow_backend.models.user_group_assignment_waiting import UserGroupAssignmentWaitingModel
from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.authorization_service import GroupPermissionsDict from spiffworkflow_backend.services.authorization_service import GroupPermissionsDict
from spiffworkflow_backend.services.authorization_service import InvalidPermissionError
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService
from spiffworkflow_backend.services.user_service import UserService from spiffworkflow_backend.services.user_service import UserService
@ -527,3 +528,67 @@ class TestAuthorizationService(BaseTest):
("/service-accounts", "create"), ("/service-accounts", "create"),
] ]
) )
def test_can_refresh_permissions_with_regexes(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
) -> None:
user_regex = "REGEX:^user_.*"
user = self.find_or_create_user(username="user_one")
user_two = self.find_or_create_user(username="second_user_to_not_match_regex")
# this group is not mentioned so it will get deleted
UserService.find_or_create_group("group_two")
assert GroupModel.query.filter_by(identifier="group_two").first() is not None
UserService.find_or_create_group("group_three")
assert GroupModel.query.filter_by(identifier="group_three").first() is not None
group_info: list[GroupPermissionsDict] = [
{
"users": [user_regex],
"name": "group_one",
"permissions": [{"actions": ["create", "read"], "uri": "PG:hey"}],
}
]
AuthorizationService.refresh_permissions(group_info)
waiting_assignments = UserGroupAssignmentWaitingModel.query.filter_by(username=user_regex).all()
assert len(waiting_assignments) == 1
assert waiting_assignments[0].username == user_regex
self.assert_user_has_permission(user, "read", "/v1.0/process-groups/hey")
self.assert_user_has_permission(user_two, "read", "/v1.0/process-groups/hey", expected_result=False)
user_three_dict = {
"username": "user_three",
"email": "user_three@example.com",
"iss": "test_service",
"sub": "unique_id_three",
}
# create the user using the same method that login uses by default as a sanity check
# and since we are testing the authorization service here anyway
user_three = AuthorizationService.create_user_from_sign_in(user_three_dict)
assert user_three is not None
group_identifiers = sorted([g.identifier for g in user_three.groups])
assert group_identifiers == ["everybody", "group_one"]
self.assert_user_has_permission(user_three, "read", "/v1.0/process-groups/hey")
# removing the regex removes permissions as well
group_info = [
{
"users": ["second_user_to_not_match_regex"],
"name": "group_one",
"permissions": [{"actions": ["create", "read"], "uri": "PG:hey"}],
}
]
AuthorizationService.refresh_permissions(group_info)
waiting_assignments = UserGroupAssignmentWaitingModel.query.filter_by(username=user_regex).all()
assert len(waiting_assignments) == 0
self.assert_user_has_permission(user, "read", "/v1.0/process-groups/hey", expected_result=False)
self.assert_user_has_permission(user_three, "read", "/v1.0/process-groups/hey", expected_result=False)
self.assert_user_has_permission(user_two, "read", "/v1.0/process-groups/hey", expected_result=True)
waiting_assignments = UserGroupAssignmentWaitingModel.query.all()
# ensure we didn't delete all of the user group assignments
assert len(waiting_assignments) > 0

View File

@ -6,6 +6,7 @@ from flask.app import Flask
from flask.testing import FlaskClient from flask.testing import FlaskClient
from SpiffWorkflow.task import Task as SpiffTask # type: ignore from SpiffWorkflow.task import Task as SpiffTask # type: ignore
from SpiffWorkflow.util.task import TaskState # type: ignore from SpiffWorkflow.util.task import TaskState # type: ignore
from spiffworkflow_backend.exceptions.error import UserDoesNotHaveAccessToTaskError
from spiffworkflow_backend.models.bpmn_process import BpmnProcessModel from spiffworkflow_backend.models.bpmn_process import BpmnProcessModel
from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.group import GroupModel
@ -16,7 +17,6 @@ from spiffworkflow_backend.models.process_instance_event import ProcessInstanceE
from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.models.task import TaskModel # noqa: F401
from spiffworkflow_backend.models.task_definition import TaskDefinitionModel from spiffworkflow_backend.models.task_definition import TaskDefinitionModel
from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.authorization_service import UserDoesNotHaveAccessToTaskError
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService
from spiffworkflow_backend.services.workflow_execution_service import WorkflowExecutionServiceError from spiffworkflow_backend.services.workflow_execution_service import WorkflowExecutionServiceError

View File

@ -1,7 +1,6 @@
"""Process Model.""" """Process Model."""
from flask.app import Flask from flask.app import Flask
from flask.testing import FlaskClient from flask.testing import FlaskClient
from spiffworkflow_backend.models.user_group_assignment_waiting import UserGroupAssignmentWaitingModel
from spiffworkflow_backend.services.user_service import UserService from spiffworkflow_backend.services.user_service import UserService
from tests.spiffworkflow_backend.helpers.base_test import BaseTest from tests.spiffworkflow_backend.helpers.base_test import BaseTest
@ -26,7 +25,7 @@ class TestUserService(BaseTest):
with_db_and_bpmn_file_cleanup: None, with_db_and_bpmn_file_cleanup: None,
) -> None: ) -> None:
everybody_group = UserService.find_or_create_group("everybodyGroup") everybody_group = UserService.find_or_create_group("everybodyGroup")
UserService.add_waiting_group_assignment(UserGroupAssignmentWaitingModel.MATCH_ALL_USERS, everybody_group) UserService.add_waiting_group_assignment("REGEX:.*", everybody_group)
initiator_user = self.find_or_create_user("initiator_user") initiator_user = self.find_or_create_user("initiator_user")
assert initiator_user.groups[0] == everybody_group assert initiator_user.groups[0] == everybody_group
@ -38,5 +37,5 @@ class TestUserService(BaseTest):
) -> None: ) -> None:
initiator_user = self.find_or_create_user("initiator_user") initiator_user = self.find_or_create_user("initiator_user")
everybody_group = UserService.find_or_create_group("everybodyGroup") everybody_group = UserService.find_or_create_group("everybodyGroup")
UserService.add_waiting_group_assignment(UserGroupAssignmentWaitingModel.MATCH_ALL_USERS, everybody_group) UserService.add_waiting_group_assignment("REGEX:.*", everybody_group)
assert initiator_user.groups[0] == everybody_group assert initiator_user.groups[0] == everybody_group