From b7657e75c5a8f5463fee88fcb43ee522ab598666 Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Thu, 5 Nov 2026 10:53:17 +0100 Subject: [PATCH] MPT-26341 add auth policy api --- auth/auth_server/constants.py | 2 + auth/auth_server/controllers/auth_policy.py | 147 ++++++++ auth/auth_server/exceptions.py | 3 + auth/auth_server/handlers/v2/__init__.py | 1 + auth/auth_server/handlers/v2/auth_policies.py | 244 ++++++++++++ auth/auth_server/server.py | 6 + .../tests/unittests/test_api_auth_policies.py | 349 ++++++++++++++++++ .../tests/unittests/test_api_scope.py | 5 +- optscale_client/auth_client/client_v2.py | 7 + 9 files changed, 763 insertions(+), 1 deletion(-) create mode 100644 auth/auth_server/controllers/auth_policy.py create mode 100644 auth/auth_server/handlers/v2/auth_policies.py create mode 100644 auth/auth_server/tests/unittests/test_api_auth_policies.py diff --git a/auth/auth_server/constants.py b/auth/auth_server/constants.py index 741df8514..2131df4bf 100644 --- a/auth/auth_server/constants.py +++ b/auth/auth_server/constants.py @@ -33,6 +33,8 @@ class Urls: 'user_options': r"%s/users/(?P[^/]+)/options/(?P[^/]+)", 'login_options': r"%s/login_options", + 'auth_policies_collection': r"%s/auth_policies", + 'auth_policies': r"%s/auth_policies/(?P[^/]+)", } def __init__(self): diff --git a/auth/auth_server/controllers/auth_policy.py b/auth/auth_server/controllers/auth_policy.py new file mode 100644 index 000000000..063455898 --- /dev/null +++ b/auth/auth_server/controllers/auth_policy.py @@ -0,0 +1,147 @@ +import logging + +from sqlalchemy import and_ +from sqlalchemy.exc import IntegrityError + +from auth.auth_server.controllers.base import BaseController +from auth.auth_server.controllers.base_async import BaseAsyncControllerWrapper +from auth.auth_server.exceptions import Err +from auth.auth_server.models.models import ( + Assignment, AuthPolicy, AuthPolicyPurpose, AuthPolicyScope, Type) +from auth.auth_server.utils import check_kwargs_is_empty +from tools.optscale_exceptions.common_exc import ( + ConflictException, NotFoundException, WrongArgumentsException) +from tools.optscale_time import utcnow_timestamp + +LOG = logging.getLogger(__name__) + +ORGANIZATION_TYPE = 'organization' +# any role on the organization (member, engineer, manager) +READ_POLICY_ACTION = 'INFO_ORGANIZATION' +# carried by the organization manager role only +MANAGE_POLICY_ACTION = 'EDIT_PARTNER' +# only organization scope is managed until SSO introduces domain policies +SUPPORTED_SCOPE_TYPES = [AuthPolicyScope.organization.value] + + +class AuthPolicyController(BaseController): + def _get_model_type(self): + return AuthPolicy + + @staticmethod + def _check_string(name, value): + if value is None: + raise WrongArgumentsException(Err.OA0032, [name]) + if not isinstance(value, str): + raise WrongArgumentsException(Err.OA0033, [name]) + if not value or value.isspace(): + raise WrongArgumentsException(Err.OA0032, [name]) + + def _check_scope(self, scope_type, scope_id): + self._check_string('scope_type', scope_type) + self._check_string('scope_id', scope_id) + if scope_type not in SUPPORTED_SCOPE_TYPES: + raise WrongArgumentsException(Err.OA0060, ['scope_type']) + + def _check_purpose(self, purpose): + self._check_string('purpose', purpose) + if purpose not in [p.value for p in AuthPolicyPurpose]: + raise WrongArgumentsException(Err.OA0060, ['purpose']) + + def _check_organization_exists(self, organization_id): + payload = ((ORGANIZATION_TYPE, organization_id),) + if organization_id not in self.get_resources_info(payload): + raise NotFoundException( + Err.OA0028, [ORGANIZATION_TYPE, organization_id]) + + def _check_access(self, token, scope_id, action): + if token: + self.check_permissions(token, ORGANIZATION_TYPE, scope_id, action) + + def _get_active_policies(self, scope_type, scope_id, purpose=None): + query = self.session.query(AuthPolicy).filter( + AuthPolicy.scope_type == AuthPolicyScope(scope_type), + AuthPolicy.scope_id == scope_id, + AuthPolicy.deleted.is_(False), + ) + if purpose is not None: + query = query.filter( + AuthPolicy.purpose == AuthPolicyPurpose(purpose)) + return query.all() + + def list(self, scope_type=None, scope_id=None, token=None, **kwargs): + check_kwargs_is_empty(**kwargs) + self._check_scope(scope_type, scope_id) + self._check_access(token, scope_id, READ_POLICY_ACTION) + return self._get_active_policies(scope_type, scope_id) + + def create(self, token=None, **kwargs): + scope_type = kwargs.pop('scope_type', None) + scope_id = kwargs.pop('scope_id', None) + purpose = kwargs.pop('purpose', None) + check_kwargs_is_empty(**kwargs) + self._check_scope(scope_type, scope_id) + self._check_purpose(purpose) + self._check_access(token, scope_id, MANAGE_POLICY_ACTION) + self._check_organization_exists(scope_id) + if self._get_active_policies(scope_type, scope_id, purpose): + raise ConflictException(Err.OA0081, []) + policy = AuthPolicy( + scope_type=AuthPolicyScope(scope_type), scope_id=scope_id, + purpose=AuthPolicyPurpose(purpose)) + self.session.add(policy) + try: + self.session.commit() + except IntegrityError: + # concurrent create of the same active policy + self.session.rollback() + raise ConflictException(Err.OA0081, []) + LOG.info('Auth policy %s (%s) created for %s %s', policy.id, + purpose, scope_type, scope_id) + return policy + + def delete(self, item_id, token=None, **kwargs): + policy = self.get(item_id) + if policy is None: + raise NotFoundException( + Err.OA0003, [AuthPolicy.__name__, item_id]) + self._check_access(token, policy.scope_id, MANAGE_POLICY_ACTION) + policy.deleted_at = utcnow_timestamp() + self.session.add(policy) + self.session.commit() + LOG.info('Auth policy %s (%s) deleted for %s %s', policy.id, + policy.purpose.value, policy.scope_type.value, + policy.scope_id) + + def get_user_organizations(self, user_id): + rows = self.session.query(Assignment.resource_id).join( + Type, and_( + Type.id == Assignment.type_id, + Type.name == ORGANIZATION_TYPE, + ) + ).filter( + Assignment.user_id == user_id, + Assignment.deleted.is_(False), + Assignment.resource_id.isnot(None), + ).distinct().all() + return [row[0] for row in rows] + + def get_enforcing_organizations(self, user_id): + organization_ids = self.get_user_organizations(user_id) + if not organization_ids: + return [] + rows = self.session.query(AuthPolicy.scope_id).filter( + AuthPolicy.scope_type == AuthPolicyScope.organization, + AuthPolicy.purpose == AuthPolicyPurpose.mfa, + AuthPolicy.scope_id.in_(organization_ids), + AuthPolicy.deleted.is_(False), + ).distinct().all() + return sorted(row[0] for row in rows) + + def is_mfa_enforced_for_user(self, user_id): + return bool(self.get_enforcing_organizations(user_id)) + + +class AuthPolicyAsyncController(BaseAsyncControllerWrapper): + def _get_controller_class(self): + return AuthPolicyController diff --git a/auth/auth_server/exceptions.py b/auth/auth_server/exceptions.py index 014035aad..43e22ee8a 100644 --- a/auth/auth_server/exceptions.py +++ b/auth/auth_server/exceptions.py @@ -208,3 +208,6 @@ class Err(enum.Enum): OA0074 = [ "Validation error: %s", ] + OA0081 = [ + "Authentication policy already exists", + ] diff --git a/auth/auth_server/handlers/v2/__init__.py b/auth/auth_server/handlers/v2/__init__.py index 04e87d2c0..2970a08e2 100644 --- a/auth/auth_server/handlers/v2/__init__.py +++ b/auth/auth_server/handlers/v2/__init__.py @@ -1,3 +1,4 @@ +import auth.auth_server.handlers.v2.auth_policies import auth.auth_server.handlers.v2.action_resources import auth.auth_server.handlers.v2.actions import auth.auth_server.handlers.v2.assignments diff --git a/auth/auth_server/handlers/v2/auth_policies.py b/auth/auth_server/handlers/v2/auth_policies.py new file mode 100644 index 000000000..6d3f422bd --- /dev/null +++ b/auth/auth_server/handlers/v2/auth_policies.py @@ -0,0 +1,244 @@ +import json + +from auth.auth_server.controllers.auth_policy import ( + AuthPolicyAsyncController) +from auth.auth_server.handlers.v1.base import ( + BaseAsyncAuthCollectionHandler, BaseAsyncAuthItemHandler, + BaseSecretHandler) +from auth.auth_server.handlers.v2.base import BaseHandler as BaseHandler_v2 +from auth.auth_server.utils import ModelEncoder, run_task + + +class AuthPolicyAuthMixin: + async def get_auth_args(self): + """Cluster secret or user token: returns the controller token arg""" + if self.secret: + self.check_cluster_secret() + return {'token': None} + await self.check_token() + return self.token + + +class AuthPolicyAsyncCollectionHandler(AuthPolicyAuthMixin, + BaseAsyncAuthCollectionHandler, + BaseSecretHandler, BaseHandler_v2): + def _get_controller_class(self): + return AuthPolicyAsyncController + + def prepare(self): + pass + + async def get(self, **url_params): + """ + --- + description: | + Returns the active authentication policies of a scope. + Required permission: INFO_ORGANIZATION or CLUSTER_SECRET + tags: [auth_policies] + summary: List authentication policies + parameters: + - name: scope_type + in: query + description: Scope type + required: true + type: string + enum: [organization] + - name: scope_id + in: query + description: Scope ID (organization ID) + required: true + type: string + responses: + 200: + description: Authentication policies list + schema: + type: object + properties: + auth_policies: + type: array + items: + type: object + properties: + id: {type: string, + description: Policy ID} + scope_type: {type: string, + enum: [organization], + description: Scope type} + scope_id: {type: string, + description: Scope ID} + purpose: {type: string, + enum: [mfa, sso], + description: Policy purpose} + created_at: {type: integer, + description: Creation timestamp} + deleted_at: {type: integer, + description: Deletion timestamp} + 400: + description: | + Wrong arguments: + - OA0022: Unexpected parameters + - OA0032: Parameter is not provided + - OA0060: Invalid parameter + 401: + description: | + Unauthorized: + - OA0010: Token not found + - OA0023: Unauthorized + - OA0062: This resource requires an authorization token + 403: + description: | + Forbidden: + - OA0006: Bad secret + - OA0012: Forbidden! + 404: + description: | + Not found: + - OA0028: Organization was not found + security: + - token: [] + - secret: [] + """ + args = await self.get_auth_args() + args.update({k: self.get_arg(k, str) for k in self.request.arguments + if k != 'access_token'}) + result = await run_task(self.controller.list, **args) + self.write(json.dumps({'auth_policies': [ + p.to_dict() for p in result]}, cls=ModelEncoder)) + + async def post(self, **url_params): + """ + --- + description: | + Creates an authentication policy. A policy with purpose `mfa` + enforces MFA for all members of the organization. + Required permission: EDIT_PARTNER or CLUSTER_SECRET + tags: [auth_policies] + summary: Create authentication policy + parameters: + - in: body + name: body + required: true + schema: + type: object + required: [scope_type, scope_id, purpose] + properties: + scope_type: {type: string, enum: [organization], + description: Scope type} + scope_id: {type: string, + description: Scope ID (organization ID)} + purpose: {type: string, enum: [mfa, sso], + description: Policy purpose} + responses: + 201: + description: Created authentication policy + schema: + type: object + properties: + id: {type: string, description: Policy ID} + scope_type: {type: string, enum: [organization], + description: Scope type} + scope_id: {type: string, description: Scope ID} + purpose: {type: string, enum: [mfa, sso], + description: Policy purpose} + created_at: {type: integer, + description: Creation timestamp} + deleted_at: {type: integer, + description: Deletion timestamp} + 400: + description: | + Wrong arguments: + - OA0022: Unexpected parameters + - OA0032: Parameter is not provided + - OA0033: Parameter should be a string + - OA0050: Incorrect request body received + - OA0060: Invalid parameter + 401: + description: | + Unauthorized: + - OA0010: Token not found + - OA0023: Unauthorized + - OA0062: This resource requires an authorization token + 403: + description: | + Forbidden: + - OA0006: Bad secret + - OA0012: Forbidden! + 404: + description: | + Not found: + - OA0028: Organization was not found + 409: + description: | + Conflict: + - OA0081: Authentication policy already exists + security: + - token: [] + - secret: [] + """ + args = await self.get_auth_args() + data = self._request_body() + data.pop('token', None) + data.update(args) + res = await run_task(self.controller.create, **data) + self.set_status(201) + self.write(res.to_json()) + + +class AuthPolicyAsyncItemHandler(AuthPolicyAuthMixin, + BaseAsyncAuthItemHandler, + BaseSecretHandler, BaseHandler_v2): + def _get_controller_class(self): + return AuthPolicyAsyncController + + def prepare(self): + pass + + async def get(self, *args, **kwargs): + self.raise405() + + async def patch(self, *args, **kwargs): + self.raise405() + + async def put(self, *args, **kwargs): + self.raise405() + + async def delete(self, policy_id, **kwargs): + """ + --- + description: | + Deletes an authentication policy (soft delete). Deleting the + `mfa` policy makes MFA optional again for the organization. + Required permission: EDIT_PARTNER or CLUSTER_SECRET + tags: [auth_policies] + summary: Delete authentication policy + parameters: + - name: policy_id + in: path + description: Policy ID + required: true + type: string + responses: + 204: + description: Success + 401: + description: | + Unauthorized: + - OA0010: Token not found + - OA0023: Unauthorized + - OA0062: This resource requires an authorization token + 403: + description: | + Forbidden: + - OA0006: Bad secret + - OA0012: Forbidden! + 404: + description: | + Not found: + - OA0003: AuthPolicy not found + security: + - token: [] + - secret: [] + """ + args = await self.get_auth_args() + await run_task(self.controller.delete, policy_id, **args) + self.set_status(204) diff --git a/auth/auth_server/server.py b/auth/auth_server/server.py index dddbdb6a3..6494c91bf 100644 --- a/auth/auth_server/server.py +++ b/auth/auth_server/server.py @@ -98,6 +98,12 @@ def get_handlers(handler_kwargs): handler_kwargs), (urls_v2.login_options, get_handler_version( h_v2, "login_options").LoginOptionsAsyncHandler, handler_kwargs), + (urls_v2.auth_policies_collection, get_handler_version( + h_v2, "auth_policies").AuthPolicyAsyncCollectionHandler, + handler_kwargs), + (urls_v2.auth_policies, get_handler_version( + h_v2, "auth_policies").AuthPolicyAsyncItemHandler, + handler_kwargs), ] diff --git a/auth/auth_server/tests/unittests/test_api_auth_policies.py b/auth/auth_server/tests/unittests/test_api_auth_policies.py new file mode 100644 index 000000000..20d65f31e --- /dev/null +++ b/auth/auth_server/tests/unittests/test_api_auth_policies.py @@ -0,0 +1,349 @@ +import uuid +from unittest.mock import patch + +from auth.auth_server.controllers.auth_policy import AuthPolicyController +from auth.auth_server.models.models import ( + Action, ActionGroup, Assignment, AuthPolicy, Role, Type, User, gen_salt) +from auth.auth_server.tests.unittests.test_api_base import TestAuthBase +from auth.auth_server.utils import hash_password + +CONTEXT_URL = 'auth.auth_server.controllers.base.BaseController.get_context' +RES_INFO_URL = ('auth.auth_server.controllers.base.' + 'BaseController.get_resources_info') +PASSWORD = 'pass' + + +class TestAuthPolicies(TestAuthBase): + def setUp(self, version='v2'): + super().setUp(version) + self.cluster_secret = self.client.secret + self.org_id = str(uuid.uuid4()) + self.org2_id = str(uuid.uuid4()) + self.org3_id = str(uuid.uuid4()) + self.known_orgs = {self.org_id, self.org2_id, self.org3_id} + session = self.db_session + root_type = self.create_root_type() + self.type_org = Type(id_=10, name='organization', parent=root_type) + group = ActionGroup(name='organization') + info_action = Action(name='INFO_ORGANIZATION', type_=self.type_org, + action_group=group) + edit_action = Action(name='EDIT_PARTNER', type_=self.type_org, + action_group=group) + self.member_role = Role(name='Member', type_=self.type_org, + lvl=self.type_org, description='member') + self.manager_role = Role(name='Manager', type_=self.type_org, + lvl=self.type_org, description='manager') + for obj in [self.type_org, group, info_action, edit_action, + self.member_role, self.manager_role]: + session.add(obj) + self.member_role.assign_action(info_action) + self.manager_role.assign_action(info_action) + self.manager_role.assign_action(edit_action) + session.commit() + + self.manager = self._create_user('manager@example.com') + self.member = self._create_user('member@example.com') + self._assign(self.manager, self.manager_role, self.org_id) + self._assign(self.member, self.member_role, self.org_id) + + self.context_mock = patch(CONTEXT_URL).start() + self.context_mock.side_effect = lambda res_type, uuid_: { + 'organization': uuid_} + self.res_info_mock = patch(RES_INFO_URL).start() + self.res_info_mock.side_effect = lambda payload: { + res_id: {'name': 'org', 'type': res_type} + for res_type, res_id in payload if res_id in self.known_orgs} + + def _create_user(self, email): + salt = gen_salt() + user = User(email, type_=self.type_org, + password=hash_password(PASSWORD, salt), salt=salt, + display_name=email, scope_id=self.org_id) + self.db_session.add(user) + self.db_session.commit() + return user + + def _assign(self, user, role, org_id, type_=None): + assignment = Assignment(user, role, type_ or self.type_org, org_id) + self.db_session.add(assignment) + self.db_session.commit() + return assignment + + def _login(self, user): + self.client.token = self.get_token(user.email, PASSWORD) + self.client.secret = None + + def _use_secret(self): + self.client.token = None + self.client.secret = self.cluster_secret + + def _create_policy(self, org_id=None, purpose='mfa', + scope_type='organization'): + return self.client.post(self.client.auth_policies_url(), { + 'scope_type': scope_type, + 'scope_id': org_id or self.org_id, + 'purpose': purpose, + }) + + def _list_policies(self, org_id=None, scope_type='organization'): + return self.client.get(self.client.auth_policies_url(), { + 'scope_type': scope_type, 'scope_id': org_id or self.org_id}) + + # --- create --- + + def test_create_by_manager(self): + self._login(self.manager) + code, resp = self._create_policy() + self.assertEqual(code, 201) + self.assertEqual(resp['scope_type'], 'organization') + self.assertEqual(resp['scope_id'], self.org_id) + self.assertEqual(resp['purpose'], 'mfa') + self.assertEqual(resp['deleted_at'], 0) + self.assertIn('id', resp) + + def test_create_by_member_forbidden(self): + self._login(self.member) + code, resp = self._create_policy() + self.assertEqual(code, 403) + self.assertEqual(resp['error']['error_code'], 'OA0012') + + def test_create_other_organization_forbidden(self): + self._login(self.manager) + code, resp = self._create_policy(org_id=self.org2_id) + self.assertEqual(code, 403) + self.assertEqual(resp['error']['error_code'], 'OA0012') + + def test_create_by_cluster_secret(self): + self._use_secret() + code, resp = self._create_policy() + self.assertEqual(code, 201) + self.assertEqual(resp['scope_id'], self.org_id) + + def test_create_bad_secret(self): + self.client.token = None + self.client.secret = 'wrong' + code, resp = self._create_policy() + self.assertEqual(code, 403) + self.assertEqual(resp['error']['error_code'], 'OA0006') + + def test_create_without_auth(self): + self.client.token = None + self.client.secret = None + code, resp = self._create_policy() + self.assertEqual(code, 401) + self.assertEqual(resp['error']['error_code'], 'OA0062') + + def test_create_duplicate(self): + self._use_secret() + code, _ = self._create_policy() + self.assertEqual(code, 201) + code, resp = self._create_policy() + self.assertEqual(code, 409) + self.assertEqual(resp['error']['error_code'], 'OA0081') + + def test_create_after_delete(self): + self._use_secret() + _, policy = self._create_policy() + code, _ = self.client.delete( + self.client.auth_policies_url(policy['id'])) + self.assertEqual(code, 204) + code, resp = self._create_policy() + self.assertEqual(code, 201) + self.assertNotEqual(resp['id'], policy['id']) + + def test_create_unknown_organization(self): + self._use_secret() + code, resp = self._create_policy(org_id=str(uuid.uuid4())) + self.assertEqual(code, 404) + self.assertEqual(resp['error']['error_code'], 'OA0028') + + def test_create_invalid_params(self): + self._use_secret() + for body, error_code in [ + ({'scope_id': self.org_id, 'purpose': 'mfa'}, 'OA0032'), + ({'scope_type': 'organization', 'purpose': 'mfa'}, 'OA0032'), + ({'scope_type': 'organization', 'scope_id': self.org_id}, + 'OA0032'), + ({'scope_type': 'organization', 'scope_id': self.org_id, + 'purpose': 'unknown'}, 'OA0060'), + ({'scope_type': 'domain', 'scope_id': 'example.com', + 'purpose': 'mfa'}, 'OA0060'), + ({'scope_type': 'organization', 'scope_id': 123, + 'purpose': 'mfa'}, 'OA0033'), + ({'scope_type': 'organization', 'scope_id': self.org_id, + 'purpose': 'mfa', 'extra': 1}, 'OA0022'), + ]: + code, resp = self.client.post( + self.client.auth_policies_url(), body) + self.assertEqual(code, 400, body) + self.assertEqual(resp['error']['error_code'], error_code, body) + + # --- list --- + + def test_list_by_member(self): + self._use_secret() + self._create_policy() + self._create_policy(org_id=self.org2_id) + self._login(self.member) + code, resp = self._list_policies() + self.assertEqual(code, 200) + self.assertEqual(len(resp['auth_policies']), 1) + self.assertEqual(resp['auth_policies'][0]['scope_id'], self.org_id) + + def test_list_empty(self): + self._login(self.member) + code, resp = self._list_policies() + self.assertEqual(code, 200) + self.assertEqual(resp['auth_policies'], []) + + def test_list_other_organization_forbidden(self): + self._login(self.member) + code, resp = self._list_policies(org_id=self.org2_id) + self.assertEqual(code, 403) + self.assertEqual(resp['error']['error_code'], 'OA0012') + + def test_list_by_cluster_secret(self): + self._use_secret() + self._create_policy(org_id=self.org2_id) + code, resp = self._list_policies(org_id=self.org2_id) + self.assertEqual(code, 200) + self.assertEqual(len(resp['auth_policies']), 1) + + def test_list_excludes_deleted(self): + self._use_secret() + _, policy = self._create_policy() + self.client.delete(self.client.auth_policies_url(policy['id'])) + code, resp = self._list_policies() + self.assertEqual(code, 200) + self.assertEqual(resp['auth_policies'], []) + + def test_list_invalid_params(self): + self._use_secret() + code, resp = self.client.get(self.client.auth_policies_url(), + {'scope_type': 'organization'}) + self.assertEqual(code, 400) + self.assertEqual(resp['error']['error_code'], 'OA0032') + code, resp = self._list_policies(scope_type='domain') + self.assertEqual(code, 400) + self.assertEqual(resp['error']['error_code'], 'OA0060') + + # --- delete --- + + def test_delete_by_manager(self): + self._use_secret() + _, policy = self._create_policy() + self._login(self.manager) + code, _ = self.client.delete( + self.client.auth_policies_url(policy['id'])) + self.assertEqual(code, 204) + db_policy = self.db_session.query(AuthPolicy).get(policy['id']) + self.db_session.refresh(db_policy) + self.assertNotEqual(db_policy.deleted_at, 0) + + def test_delete_by_member_forbidden(self): + self._use_secret() + _, policy = self._create_policy() + self._login(self.member) + code, resp = self.client.delete( + self.client.auth_policies_url(policy['id'])) + self.assertEqual(code, 403) + self.assertEqual(resp['error']['error_code'], 'OA0012') + + def test_delete_not_found(self): + self._use_secret() + code, resp = self.client.delete( + self.client.auth_policies_url(str(uuid.uuid4()))) + self.assertEqual(code, 404) + self.assertEqual(resp['error']['error_code'], 'OA0003') + + def test_delete_twice(self): + self._use_secret() + _, policy = self._create_policy() + url = self.client.auth_policies_url(policy['id']) + code, _ = self.client.delete(url) + self.assertEqual(code, 204) + code, _ = self.client.delete(url) + self.assertEqual(code, 404) + + def test_item_methods_not_allowed(self): + self._use_secret() + _, policy = self._create_policy() + url = self.client.auth_policies_url(policy['id']) + code, _ = self.client.get(url) + self.assertEqual(code, 405) + code, _ = self.client.patch(url, {'purpose': 'sso'}) + self.assertEqual(code, 405) + + # --- enforcement helpers --- + + @property + def controller(self): + return AuthPolicyController(self.db_session) + + def test_not_enforced_without_policy(self): + self.assertFalse(self.controller.is_mfa_enforced_for_user( + self.member.id)) + self.assertEqual(self.controller.get_enforcing_organizations( + self.member.id), []) + + def test_multi_org_user_enforced_by_one(self): + user = self._create_user('multi@example.com') + self._assign(user, self.member_role, self.org_id) + self._assign(user, self.member_role, self.org2_id) + self._assign(user, self.manager_role, self.org3_id) + self._use_secret() + self._create_policy(org_id=self.org2_id) + self.assertTrue(self.controller.is_mfa_enforced_for_user(user.id)) + self.assertEqual(self.controller.get_enforcing_organizations( + user.id), [self.org2_id]) + # member is only in org_id, which does not enforce + self.assertFalse(self.controller.is_mfa_enforced_for_user( + self.member.id)) + + def test_multiple_enforcing_organizations(self): + user = self._create_user('multi@example.com') + self._assign(user, self.member_role, self.org_id) + self._assign(user, self.manager_role, self.org_id) + self._assign(user, self.member_role, self.org2_id) + self._use_secret() + self._create_policy(org_id=self.org_id) + self._create_policy(org_id=self.org2_id) + self.assertEqual(self.controller.get_enforcing_organizations( + user.id), sorted([self.org_id, self.org2_id])) + + def test_deleted_policy_not_counted(self): + self._use_secret() + _, policy = self._create_policy() + self.assertTrue(self.controller.is_mfa_enforced_for_user( + self.member.id)) + self.client.delete(self.client.auth_policies_url(policy['id'])) + self.assertFalse(self.controller.is_mfa_enforced_for_user( + self.member.id)) + + def test_sso_policy_does_not_enforce_mfa(self): + self._use_secret() + code, _ = self._create_policy(purpose='sso') + self.assertEqual(code, 201) + self.assertFalse(self.controller.is_mfa_enforced_for_user( + self.member.id)) + + def test_deleted_assignment_not_counted(self): + user = self._create_user('former@example.com') + assignment = self._assign(user, self.member_role, self.org2_id) + self._use_secret() + self._create_policy(org_id=self.org2_id) + self.assertTrue(self.controller.is_mfa_enforced_for_user(user.id)) + assignment.deleted_at = 1 + self.db_session.commit() + self.assertFalse(self.controller.is_mfa_enforced_for_user(user.id)) + + def test_non_organization_assignment_not_counted(self): + user = self._create_user('pool@example.com') + type_pool = Type(id_=20, name='pool', parent=self.type_org) + self.db_session.add(type_pool) + self.db_session.commit() + # pool id equal to an enforcing organization id must not match + self._assign(user, self.member_role, self.org2_id, type_=type_pool) + self._use_secret() + self._create_policy(org_id=self.org2_id) + self.assertFalse(self.controller.is_mfa_enforced_for_user(user.id)) diff --git a/auth/auth_server/tests/unittests/test_api_scope.py b/auth/auth_server/tests/unittests/test_api_scope.py index bdd6ff9ec..79bf4989b 100644 --- a/auth/auth_server/tests/unittests/test_api_scope.py +++ b/auth/auth_server/tests/unittests/test_api_scope.py @@ -1,5 +1,5 @@ import uuid -from unittest.mock import patch +from unittest.mock import patch, PropertyMock from auth.auth_server.models.models import (Type, User, Action, Role, Assignment, ActionGroup) @@ -126,6 +126,9 @@ def setUp(self, version="v2"): self.user_partner_password) patch('auth.auth_server.controllers.user.UserController.' 'domain_blacklist').start() + patch('auth.auth_server.controllers.user.UserController.' + 'domain_whitelist', new_callable=PropertyMock, + return_value=[]).start() def _create_role(self, name, lvl_id, type_id, scope_id=None, is_active=True, shared=False, description=None): diff --git a/optscale_client/auth_client/client_v2.py b/optscale_client/auth_client/client_v2.py index df2e43033..be09f6232 100644 --- a/optscale_client/auth_client/client_v2.py +++ b/optscale_client/auth_client/client_v2.py @@ -61,6 +61,13 @@ def login_options_url(): url = 'login_options' return url + @staticmethod + def auth_policies_url(policy_id=None): + url = 'auth_policies' + if policy_id is not None: + url = '%s/%s' % (url, policy_id) + return url + @staticmethod def query_url(**query): query = {