Skip to content

Commit

Permalink
13248 - Auth ORG changes (#2102)
Browse files Browse the repository at this point in the history
* Auth ORG changes
- Revert service level blocking
- Implement model level blocking (thanks Kial)
- Allow Staff and SBC Staff to add products

* Use roles, add in payment methods for SBC STAFF / STAFF.

* Fix statements and transactions.

* Remove submodule entry

* Lint error

* More lint errors, need to fix IDE.

* Use PREMIUM_ORG_TYPES to default to BCOL

* Another fix, need to use Thor's enums.

* Parametize unit tests
  • Loading branch information
seeker25 authored Oct 6, 2022
1 parent eb5cf0a commit bde3d71
Show file tree
Hide file tree
Showing 9 changed files with 106 additions and 30 deletions.
28 changes: 27 additions & 1 deletion auth-api/src/auth_api/models/org.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@
Basic users will have an internal Org that is not created explicitly, but implicitly upon User account creation.
"""
from flask import current_app
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, and_, cast, func
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, and_, cast, event, func
from sqlalchemy.orm import contains_eager, relationship

from auth_api.exceptions import BusinessException
from auth_api.exceptions.errors import Error
from auth_api.models.affiliation import Affiliation
from auth_api.models.dataclass import OrgSearch
from auth_api.utils.enums import AccessType, InvitationStatus, InvitationType
from auth_api.utils.enums import OrgStatus as OrgStatusEnum
from auth_api.utils.enums import OrgType as OrgTypeEnum
from auth_api.utils.roles import EXCLUDED_FIELDS, VALID_STATUSES

from .base_model import VersionedModel
Expand Down Expand Up @@ -240,3 +244,25 @@ def reset(self):
self.save()
else:
super().reset()


@event.listens_for(Org, 'before_insert')
def receive_before_insert(mapper, connection, target): # pylint: disable=unused-argument; SQLAlchemy callback signature
"""Rejects invalid type_codes on insert."""
org = target
if org.type_code in (OrgTypeEnum.SBC_STAFF.value, OrgTypeEnum.STAFF.value):
raise BusinessException(
Error.INVALID_INPUT,
None
)


@event.listens_for(Org, 'before_update', raw=True)
def receive_before_update(mapper, connection, state): # pylint: disable=unused-argument; SQLAlchemy callback signature
"""Rejects invalid type_codes on update."""
if Org.type_code.key in state.unmodified:
return
raise BusinessException(
Error.INVALID_INPUT,
None
)
5 changes: 1 addition & 4 deletions auth-api/src/auth_api/services/org.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,7 @@ def create_org(org_info: dict, user_id):
if access_type == AccessType.GOVM.value:
org_info.update({'typeCode': OrgType.PREMIUM.value})

org_snake = camelback2snake(org_info)
if org_snake.get('type_code') in (OrgType.STAFF.value, OrgType.SBC_STAFF.value):
raise BusinessException(Error.INVALID_INPUT, None)
org = OrgModel.create_from_dict(org_snake)
org = OrgModel.create_from_dict(camelback2snake(org_info))
org.access_type = access_type

# Set the status based on access type
Expand Down
8 changes: 4 additions & 4 deletions auth-api/src/auth_api/services/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@
from auth_api.schemas import ProductCodeSchema
from auth_api.utils.constants import BCOL_PROFILE_PRODUCT_MAP
from auth_api.utils.enums import (
AccessType, ActivityAction, OrgType, ProductSubscriptionStatus, TaskAction, TaskRelationshipStatus,
TaskRelationshipType, TaskStatus)
AccessType, ActivityAction, ProductSubscriptionStatus, TaskAction, TaskRelationshipStatus, TaskRelationshipType,
TaskStatus)
from auth_api.utils.user_context import UserContext, user_context

from ..utils.account_mailer import publish_to_mailer
from ..utils.cache import cache
from ..utils.roles import CLIENT_ADMIN_ROLES, CLIENT_AUTH_ROLES, STAFF
from ..utils.roles import CLIENT_ADMIN_ROLES, CLIENT_AUTH_ROLES, PREMIUM_ORG_TYPES, STAFF
from .activity_log_publisher import ActivityLogPublisher
from .authorization import check_auth
from .task import Task as TaskService
Expand Down Expand Up @@ -91,7 +91,7 @@ def create_product_subscription(org_id, subscription_data: Dict[str, Any], # py
product_model: ProductCodeModel = ProductCodeModel.find_by_code(product_code)
if product_model:
# Check if product needs premium account, if yes skip and continue.
if product_model.premium_only and org.type_code != OrgType.PREMIUM.value:
if product_model.premium_only and org.type_code not in PREMIUM_ORG_TYPES:
continue

subscription_status = Product.find_subscription_status(org, product_model)
Expand Down
12 changes: 8 additions & 4 deletions auth-api/src/auth_api/services/validators/payment_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@ def validate(is_fatal=False, **kwargs) -> ValidatorResponse:
default_cc_method = PaymentMethod.DIRECT_PAY.value if current_app.config.get(
'DIRECT_PAY_ENABLED') else PaymentMethod.CREDIT_CARD.value
validator_response = ValidatorResponse()
non_ejv_payment_methods = (
PaymentMethod.CREDIT_CARD.value, PaymentMethod.DIRECT_PAY.value,
PaymentMethod.PAD.value, PaymentMethod.BCOL.value)
org_payment_method_mapping = {
OrgType.BASIC: (
PaymentMethod.CREDIT_CARD.value, PaymentMethod.DIRECT_PAY.value, PaymentMethod.ONLINE_BANKING.value),
OrgType.PREMIUM: (
PaymentMethod.CREDIT_CARD.value, PaymentMethod.DIRECT_PAY.value,
PaymentMethod.PAD.value, PaymentMethod.BCOL.value)
OrgType.PREMIUM: non_ejv_payment_methods,
OrgType.SBC_STAFF: non_ejv_payment_methods,
OrgType.STAFF: non_ejv_payment_methods,
}
if access_type == AccessType.GOVM.value:
payment_type = PaymentMethod.EJV.value
Expand All @@ -48,7 +51,8 @@ def validate(is_fatal=False, **kwargs) -> ValidatorResponse:
if is_fatal:
raise BusinessException(Error.INVALID_INPUT, None)
else:
premium_org_types = (OrgType.PREMIUM, OrgType.SBC_STAFF, OrgType.STAFF)
payment_type = PaymentMethod.BCOL.value if \
org_type == OrgType.PREMIUM else default_cc_method
org_type in premium_org_types else default_cc_method
validator_response.add_info({'payment_type': payment_type})
return validator_response
4 changes: 3 additions & 1 deletion auth-api/src/auth_api/utils/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"""Role definitions."""
from enum import Enum

from .enums import OrgStatus, ProductSubscriptionStatus, Status
from .enums import OrgStatus, OrgType, ProductSubscriptionStatus, Status


class Role(Enum):
Expand Down Expand Up @@ -57,3 +57,5 @@ class Role(Enum):
CLIENT_AUTH_ROLES = (*CLIENT_ADMIN_ROLES, USER)
ALL_ALLOWED_ROLES = (*CLIENT_AUTH_ROLES, STAFF)
EXCLUDED_FIELDS = ('status_code', 'type_code')

PREMIUM_ORG_TYPES = (OrgType.PREMIUM.value, OrgType.SBC_STAFF.value, OrgType.STAFF.value)
49 changes: 35 additions & 14 deletions auth-api/tests/unit/services/test_org.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@

import pytest
from requests import Response
from sqlalchemy import event

from auth_api.models.dataclass import Activity
from auth_api.exceptions import BusinessException
from auth_api.exceptions.errors import Error
from auth_api.models import ContactLink as ContactLinkModel
from auth_api.models import Org as OrgModel
from auth_api.models.org import receive_before_insert, receive_before_update
from auth_api.models import Task as TaskModel
from auth_api.services import ActivityLogPublisher
from auth_api.services import Affidavit as AffidavitService
Expand All @@ -49,7 +51,7 @@
factory_contact_model, factory_entity_model, factory_entity_service, factory_invitation, factory_membership_model,
factory_org_model, factory_org_service, factory_user_model, factory_user_model_with_contact,
patch_pay_account_delete, patch_pay_account_post, patch_pay_account_put, patch_token_info)

from tests.utilities.sqlalchemy import clear_event_listeners

# noqa: I005

Expand Down Expand Up @@ -403,6 +405,33 @@ def test_create_product_multiple_subscription(session, keycloak_mock, monkeypatc
if prod.get('code') == TestOrgProductsInfo.org_products2['subscriptions'][1]['productCode'])


@pytest.mark.parametrize(
'org_type', [(OrgType.STAFF.value), (OrgType.SBC_STAFF.value)]
)
def test_create_product_subscription_staff(session, keycloak_mock, org_type, monkeypatch):
"""Assert that updating product subscription works for staff."""
user = factory_user_model(TestUserInfo.user_test)
patch_token_info({'sub': user.keycloak_guid}, monkeypatch)
org = OrgService.create_org(TestOrgInfo.org1, user_id=user.id)

# Clearing the event listeners here, because we can't change the type_code.
clear_event_listeners(OrgModel)
org_db = OrgModel.find_by_id(org._model.id)
org_db.type_code = org_type
org_db.save()
event.listen(OrgModel, 'before_update', receive_before_update, raw=True)
event.listen(OrgModel, 'before_insert', receive_before_insert)

subscriptions = ProductService.create_product_subscription(org._model.id,
TestOrgProductsInfo.org_products2,
skip_auth=True)

assert next(prod for prod in subscriptions
if prod.get('code') == TestOrgProductsInfo.org_products2['subscriptions'][0]['productCode'])
assert next(prod for prod in subscriptions
if prod.get('code') == TestOrgProductsInfo.org_products2['subscriptions'][1]['productCode'])


def test_create_org_with_duplicate_name(session, monkeypatch): # pylint:disable=unused-argument
"""Assert that an Org with duplicate name cannot be created."""
user = factory_user_model()
Expand Down Expand Up @@ -639,8 +668,11 @@ def test_get_owner_count_one_owner(session, keycloak_mock, monkeypatch): # pyli
assert org.get_owner_count() == 1


def test_create_staff_org_failure(session, keycloak_mock, monkeypatch): # pylint:disable=unused-argument
"""Assert that count of owners is correct."""
@pytest.mark.parametrize(
'staff_org', [(TestOrgInfo.staff_org), (TestOrgInfo.sbc_staff_org)]
)
def test_create_staff_org_failure(session, keycloak_mock, staff_org, monkeypatch): # pylint:disable=unused-argument
"""Assert that staff org cannot be created."""
user_with_token = TestUserInfo.user_test
user_with_token['keycloak_guid'] = TestJwtClaims.public_user_role['sub']
user = factory_user_model(user_info=user_with_token)
Expand All @@ -650,17 +682,6 @@ def test_create_staff_org_failure(session, keycloak_mock, monkeypatch): # pylin
assert exception.value.code == Error.INVALID_INPUT.name


def test_create_sbc_staff_org_failure(session, keycloak_mock, monkeypatch): # pylint:disable=unused-argument
"""Assert wrong org cannot be created."""
user_with_token = TestUserInfo.user_test
user_with_token['keycloak_guid'] = TestJwtClaims.public_user_role['sub']
user = factory_user_model(user_info=user_with_token)
patch_token_info({'sub': user.keycloak_guid}, monkeypatch)
with pytest.raises(BusinessException) as exception:
OrgService.create_org(TestOrgInfo.sbc_staff_org, user.id)
assert exception.value.code == Error.INVALID_INPUT.name


def test_get_owner_count_two_owner_with_admins(session, keycloak_mock, monkeypatch): # pylint:disable=unused-argument
"""Assert wrong org cannot be created."""
user_with_token = TestUserInfo.user_test
Expand Down
26 changes: 26 additions & 0 deletions auth-api/tests/utilities/sqlalchemy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copyright © 2022 Province of British Columbia
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Utility to remove event listeners for models."""
import ctypes
from sqlalchemy import event


def clear_event_listeners(model):
"""Remove event listeners for a model."""
keys = [k for k in event.registry._key_to_collection if k[0] == id(model)]
for key in keys:
target = model
identifier = key[1]
fn = ctypes.cast(key[2], ctypes.py_object).value # get function by id
event.remove(target, identifier, fn)
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export default class Statements extends Mixins(AccountChangeMixin) {
}
private get isStatementsAllowed (): boolean {
return (this.currentOrganization?.orgType === Account.PREMIUM) &&
return [Account.PREMIUM, Account.STAFF, Account.SBC_STAFF].includes(this.currentOrganization?.orgType as Account) &&
[MembershipType.Admin, MembershipType.Coordinator].includes(this.currentMembership.membershipTypeCode)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export default class Transactions extends Mixins(AccountChangeMixin) {
}
private get isTransactionsAllowed (): boolean {
return (this.currentOrganization?.orgType === Account.PREMIUM) &&
return [Account.PREMIUM, Account.STAFF, Account.SBC_STAFF].includes(this.currentOrganization?.orgType as Account) &&
[MembershipType.Admin, MembershipType.Coordinator].includes(this.currentMembership.membershipTypeCode)
}
}
Expand Down

0 comments on commit bde3d71

Please sign in to comment.