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.models.db import db
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.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.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.background_processing_service import BackgroundProcessingService

View File

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

View File

@ -20,11 +20,11 @@ from SpiffWorkflow.exceptions import SpiffWorkflowException # type: ignore
from SpiffWorkflow.exceptions import WorkflowException
from SpiffWorkflow.specs.base import TaskSpec # 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.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 TaskService
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.
"""
MATCH_ALL_USERS = "*"
__tablename__ = "user_group_assignment_waiting"
__table_args__ = (db.UniqueConstraint("username", "group_id", name="user_group_assignment_staged_unique"),)
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(255), nullable=False)
group_id = db.Column(ForeignKey(GroupModel.id), nullable=False, index=True)
id: int = db.Column(db.Integer, primary_key=True)
username: str = db.Column(db.String(255), nullable=False)
group_id: int = db.Column(ForeignKey(GroupModel.id), nullable=False, index=True)
group = relationship("GroupModel", overlaps="groups,user_group_assignments_waiting,users") # type: ignore
def is_match_all(self) -> bool:
if self.username == self.MATCH_ALL_USERS:
def is_wildcard(self) -> bool:
if self.username.startswith("REGEX:"):
return True
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 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.models.group import SPIFF_GUEST_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 UserModel
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.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:
if current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED"):
AuthorizationService.create_guest_token(
AuthenticationService.create_guest_token(
username=SPIFF_NO_AUTH_USER,
group_identifier=SPIFF_NO_AUTH_GROUP,
permission_target="/*",
@ -99,7 +99,7 @@ def login(redirect_url: str = "/", process_instance_id: int | None = None, task_
return redirect(redirect_url)
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,
group_identifier=SPIFF_GUEST_GROUP,
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.reference_cache import ReferenceCacheModel
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.git_service import GitService
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
def github_webhook_receive(body: dict) -> Response:
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)
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 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.secret_service import SecretService
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 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 db
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 _get_process_model
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.file_system_service import FileSystemService
from spiffworkflow_backend.services.git_service import GitCommandError

View File

@ -2,52 +2,29 @@ import base64
import enum
import json
import time
from hashlib import sha256
from hmac import HMAC
from hmac import compare_digest
import jwt
import requests
from flask import current_app
from flask import g
from flask import redirect
from flask import request
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.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
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):
open_id = "open_id"
internal = "internal"
@ -249,3 +226,57 @@ class AuthenticationService:
response = requests.post(request_url, data=data, headers=headers, timeout=HTTP_REQUEST_TIMEOUT_SECONDS)
auth_token_object: dict = json.loads(response.text)
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 re
from dataclasses import dataclass
from hashlib import sha256
from hmac import HMAC
from hmac import compare_digest
from typing import TypedDict
import jwt
import yaml
from flask import current_app
from flask import g
from flask import request
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.models.db import db
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 UserModel
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.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 sqlalchemy import and_
from sqlalchemy import or_
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
class PermissionToAssign:
permission: str
@ -104,6 +83,7 @@ class AddedPermissionDict(TypedDict):
group_identifiers: set[str]
permission_assignments: list[PermissionAssignmentModel]
user_to_group_identifiers: list[UserToGroupDict]
waiting_user_group_assignments: list[UserGroupAssignmentWaitingModel]
class DesiredGroupPermissionDict(TypedDict):
@ -120,40 +100,6 @@ class GroupPermissionsDict(TypedDict):
class AuthorizationService:
"""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
def has_permission(cls, principals: list[PrincipalModel], permission: str, target_uri: str) -> bool:
principal_ids = [p.id for p in principals]
@ -390,24 +336,6 @@ class AuthorizationService:
return True
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
def assert_user_can_complete_task(
process_instance_id: int,
@ -827,6 +755,7 @@ class AuthorizationService:
) -> AddedPermissionDict:
unique_user_group_identifiers: set[str] = set()
user_to_group_identifiers: list[UserToGroupDict] = []
waiting_user_group_assignments: list[UserGroupAssignmentWaitingModel] = []
permission_assignments = []
default_group = None
@ -847,8 +776,11 @@ class AuthorizationService:
"group_identifier": group_identifier,
}
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)
for group in group_permissions:
group_identifier = group["name"]
if user_model and group_identifier not in unique_user_group_identifiers:
@ -875,6 +807,7 @@ class AuthorizationService:
"group_identifiers": unique_user_group_identifiers,
"permission_assignments": permission_assignments,
"user_to_group_identifiers": user_to_group_identifiers,
"waiting_user_group_assignments": waiting_user_group_assignments,
}
@classmethod
@ -883,11 +816,13 @@ class AuthorizationService:
added_permissions: AddedPermissionDict,
initial_permission_assignments: list[PermissionAssignmentModel],
initial_user_to_group_assignments: list[UserGroupAssignmentModel],
initial_waiting_group_assignments: list[UserGroupAssignmentWaitingModel],
group_permissions_only: bool = False,
) -> None:
added_permission_assignments = added_permissions["permission_assignments"]
added_group_identifiers = added_permissions["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:
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()
for gtd in groups_to_delete:
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()
@classmethod
@ -930,6 +870,7 @@ class AuthorizationService:
.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()
added_permissions = cls.add_permissions_from_group_permissions(
group_permissions, group_permissions_only=group_permissions_only
@ -938,5 +879,6 @@ class AuthorizationService:
added_permissions,
initial_permission_assignments,
initial_user_to_group_assignments,
initial_waiting_group_assignments,
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_backend import db
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.human_task import HumanTaskModel
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.user import UserModel
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 GitService
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 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.db import db
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.reference_cache import ReferenceCacheModel
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.file_system_service import FileSystemService
from spiffworkflow_backend.services.process_caller_service import ProcessCallerService

View File

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

View File

@ -1,10 +1,11 @@
import pytest
from flask import Flask
from flask.testing import FlaskClient
from spiffworkflow_backend.exceptions.error import InvalidPermissionError
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 GroupPermissionsDict
from spiffworkflow_backend.services.authorization_service import InvalidPermissionError
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService
from spiffworkflow_backend.services.user_service import UserService
@ -527,3 +528,67 @@ class TestAuthorizationService(BaseTest):
("/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 SpiffWorkflow.task import Task as SpiffTask # 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.db import db
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_definition import TaskDefinitionModel
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_service import ProcessInstanceService
from spiffworkflow_backend.services.workflow_execution_service import WorkflowExecutionServiceError

View File

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