From d4887b70d560cb735f14a0d2a4d81849ca288a32 Mon Sep 17 00:00:00 2001 From: Erwin Junge Date: Tue, 24 May 2022 12:07:29 +0200 Subject: [PATCH 1/4] Add public access to collections --- opentaxii/cli/persistence.py | 3 + opentaxii/persistence/sqldb/api.py | 7 +- opentaxii/persistence/sqldb/taxii2models.py | 1 + opentaxii/server.py | 34 +++++++-- opentaxii/taxii2/entities.py | 8 ++- opentaxii/utils.py | 2 + tests/taxii2/test_taxii2_collection.py | 42 ++++++++++-- tests/taxii2/test_taxii2_collections.py | 8 +++ tests/taxii2/test_taxii2_manifest.py | 59 +++++++++++----- tests/taxii2/test_taxii2_object.py | 40 +++++++++-- tests/taxii2/test_taxii2_objects.py | 76 +++++++++++++++++++-- tests/taxii2/test_taxii2_sqldb.py | 2 +- tests/taxii2/test_taxii2_versions.py | 36 ++++++++-- tests/taxii2/utils.py | 38 +++++++++-- tests/test_cli.py | 20 ++++++ 15 files changed, 326 insertions(+), 50 deletions(-) diff --git a/opentaxii/cli/persistence.py b/opentaxii/cli/persistence.py index 8cfeaaae..98978381 100644 --- a/opentaxii/cli/persistence.py +++ b/opentaxii/cli/persistence.py @@ -134,6 +134,8 @@ def add_collection(): "-d", "--description", required=False, help="Description of the collection" ) parser.add_argument("-a", "--alias", required=False, help="alias of the collection") + parser.add_argument('--public', action='store_true', help="allow public read access") + parser.set_defaults(public=False) args = parser.parse_args() with app.app_context(): @@ -142,6 +144,7 @@ def add_collection(): title=args.title, description=args.description, alias=args.alias, + is_public=args.public, ) diff --git a/opentaxii/persistence/sqldb/api.py b/opentaxii/persistence/sqldb/api.py index f6e25fb5..c149730d 100644 --- a/opentaxii/persistence/sqldb/api.py +++ b/opentaxii/persistence/sqldb/api.py @@ -630,6 +630,7 @@ def get_collections(self, api_root_id: str) -> List[entities.Collection]: title=obj.title, description=obj.description, alias=obj.alias, + is_public=obj.is_public, ) for obj in query.all() ] @@ -660,6 +661,7 @@ def get_collection( title=obj.title, description=obj.description, alias=obj.alias, + is_public=obj.is_public, ) def add_collection( @@ -668,6 +670,7 @@ def add_collection( title: str, description: Optional[str] = None, alias: Optional[str] = None, + is_public: bool = False, ) -> entities.Collection: """ Add a new collection. @@ -676,11 +679,12 @@ def add_collection( :param str title: Title of the new collection :param str description: [Optional] Description of the new collection :param str alias: [Optional] Alias of the new collection + :param bool is_public: [Optional] Whether collection should be publicly readable :return: The added Collection entity. """ collection = taxii2models.Collection( - api_root_id=api_root_id, title=title, description=description, alias=alias + api_root_id=api_root_id, title=title, description=description, alias=alias, is_public=is_public ) self.db.session.add(collection) self.db.session.commit() @@ -691,6 +695,7 @@ def add_collection( title=collection.title, description=collection.description, alias=collection.alias, + is_public=collection.is_public, ) def _objects_query(self, collection_id: str, ordered: bool) -> Query: diff --git a/opentaxii/persistence/sqldb/taxii2models.py b/opentaxii/persistence/sqldb/taxii2models.py index 0afadc96..c63fbff9 100644 --- a/opentaxii/persistence/sqldb/taxii2models.py +++ b/opentaxii/persistence/sqldb/taxii2models.py @@ -118,6 +118,7 @@ class Collection(Base): title = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True) description = sqlalchemy.Column(sqlalchemy.Text) alias = sqlalchemy.Column(sqlalchemy.String(100), nullable=True) + is_public = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False) api_root = relationship("ApiRoot", back_populates="collections") objects = relationship("STIXObject", back_populates="collection") diff --git a/opentaxii/server.py b/opentaxii/server.py index e7afb997..06ad3754 100644 --- a/opentaxii/server.py +++ b/opentaxii/server.py @@ -427,7 +427,10 @@ def get_endpoint(self, relative_path: str) -> Optional[Callable[[], Response]]: if endpoint: return functools.partial(self.handle_request, endpoint) - def check_authentication(self): + def check_authentication(self, endpoint: Callable[[], Response]): + if endpoint.func.handles_own_auth: + # Endpoint will handle auth checks itself + return if context.account is None: raise Unauthorized() @@ -458,7 +461,7 @@ def check_allowed_methods(self, endpoint: Callable[[], Response]): raise MethodNotAllowed(valid_methods=endpoint.func.registered_valid_methods) def handle_request(self, endpoint: Callable[[], Response]): - self.check_authentication() + self.check_authentication(endpoint) self.check_content_length() self.check_allowed_methods(endpoint) self.check_headers(endpoint) @@ -547,7 +550,8 @@ def collections_handler(self, api_root_id): return make_taxii2_response(response) @register_handler( - r"^/(?P[^/]+)/collections/(?P[^/]+)/$" + r"^/(?P[^/]+)/collections/(?P[^/]+)/$", + handles_own_auth=True, ) def collection_handler(self, api_root_id, collection_id_or_alias): try: @@ -555,7 +559,11 @@ def collection_handler(self, api_root_id, collection_id_or_alias): api_root_id=api_root_id, collection_id_or_alias=collection_id_or_alias ) except DoesNotExistError: + if context.account is None: + raise Unauthorized() raise NotFound() + if context.account is None and not collection.can_read(context.account): + raise Unauthorized() response = { "id": collection.id, "title": collection.title, @@ -570,7 +578,8 @@ def collection_handler(self, api_root_id, collection_id_or_alias): return make_taxii2_response(response) @register_handler( - r"^/(?P[^/]+)/collections/(?P[^/]+)/manifest/$" + r"^/(?P[^/]+)/collections/(?P[^/]+)/manifest/$", + handles_own_auth=True, ) def manifest_handler(self, api_root_id, collection_id_or_alias): filter_params = validate_list_filter_params(request.args, self.persistence.api) @@ -581,6 +590,8 @@ def manifest_handler(self, api_root_id, collection_id_or_alias): **filter_params, ) except (DoesNotExistError, NoReadPermission): + if context.account is None: + raise Unauthorized() raise NotFound() if manifest: response = { @@ -615,6 +626,7 @@ def manifest_handler(self, api_root_id, collection_id_or_alias): r"^/(?P[^/]+)/collections/(?P[^/]+)/objects/$", ("GET", "POST"), valid_content_types=("application/taxii+json;version=2.1",), + handles_own_auth=True, ) def objects_handler(self, api_root_id, collection_id_or_alias): if request.method == "GET": @@ -631,6 +643,8 @@ def objects_get_handler(self, api_root_id, collection_id_or_alias): **filter_params, ) except (DoesNotExistError, NoReadPermission): + if context.account is None: + raise Unauthorized() raise NotFound() if objects: response = { @@ -672,6 +686,8 @@ def objects_post_handler(self, api_root_id, collection_id_or_alias): data=request.get_json(), ) except (DoesNotExistError, NoWritePermission): + if context.account is None: + raise Unauthorized() raise NotFound() response = { "id": job.id, @@ -701,6 +717,7 @@ def objects_post_handler(self, api_root_id, collection_id_or_alias): @register_handler( r"^/(?P[^/]+)/collections/(?P[^/]+)/objects/(?P[^/]+)/$", ("GET", "DELETE"), + handles_own_auth=True, ) def object_handler(self, api_root_id, collection_id_or_alias, object_id): if request.method == "GET": @@ -722,6 +739,8 @@ def object_get_handler(self, api_root_id, collection_id_or_alias, object_id): **filter_params, ) except (DoesNotExistError, NoReadPermission): + if context.account is None: + raise Unauthorized() raise NotFound() if versions: response = { @@ -764,8 +783,12 @@ def object_delete_handler(self, api_root_id, collection_id_or_alias, object_id): **filter_params, ) except (DoesNotExistError, NoReadNoWritePermission): + if context.account is None: + raise Unauthorized() raise NotFound() except (NoReadPermission, NoWritePermission): + if context.account is None: + raise Unauthorized() raise Forbidden() return make_taxii2_response("") @@ -774,6 +797,7 @@ def object_delete_handler(self, api_root_id, collection_id_or_alias, object_id): r"^/(?P[^/]+)/collections/(?P[^/]+)" r"/objects/(?P[^/]+)/versions/$" ), + handles_own_auth=True, ) def versions_handler(self, api_root_id, collection_id_or_alias, object_id): filter_params = validate_versions_filter_params(request.args, self.persistence.api) @@ -785,6 +809,8 @@ def versions_handler(self, api_root_id, collection_id_or_alias, object_id): **filter_params, ) except (DoesNotExistError, NoReadPermission): + if context.account is None: + raise Unauthorized() raise NotFound() if versions: response = { diff --git a/opentaxii/taxii2/entities.py b/opentaxii/taxii2/entities.py index 9cb5036d..8d409758 100644 --- a/opentaxii/taxii2/entities.py +++ b/opentaxii/taxii2/entities.py @@ -34,10 +34,11 @@ class Collection(Entity): :param str title: human readable plain text name used to identify this collection :param str description: human readable plain text description for this collection :param str alias: human readable collection name that can be used on systems to alias a collection id + :param bool is_public: whether this is a publicly readable collection """ def __init__( - self, id: str, api_root_id: str, title: str, description: str, alias: str + self, id: str, api_root_id: str, title: str, description: str, alias: str, is_public: bool ): """Initialize Collection.""" self.id = id @@ -45,12 +46,13 @@ def __init__( self.title = title self.description = description self.alias = alias + self.is_public = is_public def can_read(self, account: Optional[Account]): """Determine if `account` is allowed to read from this collection.""" - return account and ( + return self.is_public or ( account and ( account.is_admin or "read" in set(account.permissions.get(self.id, [])) - ) + ) ) def can_write(self, account: Optional[Account]): """Determine if `account` is allowed to write to this collection.""" diff --git a/opentaxii/utils.py b/opentaxii/utils.py index 7d1d3c89..43e2e45a 100644 --- a/opentaxii/utils.py +++ b/opentaxii/utils.py @@ -316,6 +316,7 @@ def register_handler( valid_methods: Optional[Tuple[str]] = None, valid_accept_mimetypes: Optional[Tuple[str]] = None, valid_content_types: Optional[Tuple[str]] = None, + handles_own_auth: bool = False, ): """ Register decorated method as handler function for `url_re`. @@ -345,6 +346,7 @@ def inner(*args, **kwargs): inner.registered_valid_methods = valid_methods inner.registered_valid_accept_mimetypes = valid_accept_mimetypes inner.registered_valid_content_types = valid_content_types + inner.handles_own_auth = handles_own_auth return inner return inner_decorator diff --git a/tests/taxii2/test_taxii2_collection.py b/tests/taxii2/test_taxii2_collection.py index bbdcf187..43edf661 100644 --- a/tests/taxii2/test_taxii2_collection.py +++ b/tests/taxii2/test_taxii2_collection.py @@ -204,24 +204,51 @@ def test_collection( assert content == expected_content +@pytest.mark.parametrize("is_public", [True, False]) @pytest.mark.parametrize("method", ["get", "post", "delete"]) def test_collection_unauthenticated( client, method, + is_public, ): - func = getattr(client, method) - response = func(f"/{API_ROOTS[0].id}/collections/{COLLECTIONS[0].id}/") - assert response.status_code == 401 + if is_public: + collection_id = COLLECTIONS[6].id + if method == "get": + expected_status_code = 200 + else: + expected_status_code = 405 + else: + collection_id = COLLECTIONS[0].id + if method == "get": + expected_status_code = 401 + else: + expected_status_code = 405 + with patch.object( + client.application.taxii_server.servers.taxii2.persistence.api, + "get_api_root", + side_effect=GET_API_ROOT_MOCK, + ), patch.object( + client.application.taxii_server.servers.taxii2.persistence.api, + "get_collection", + side_effect=GET_COLLECTION_MOCK, + ): + func = getattr(client, method) + response = func( + f"/{API_ROOTS[0].id}/collections/{collection_id}/", + headers={"Accept": "application/taxii+json;version=2.1"}, + ) + assert response.status_code == expected_status_code @pytest.mark.parametrize( - ["api_root_id", "title", "description", "alias"], + ["api_root_id", "title", "description", "alias", "is_public"], [ pytest.param( API_ROOTS[0].id, # api_root_id "my new collection", # title None, # description None, # alias + False, # is_public id="api_root_id, title", ), pytest.param( @@ -229,6 +256,7 @@ def test_collection_unauthenticated( "my new collection", # title "my description", # description None, # alias + True, # is_public id="api_root_id, title, description", ), pytest.param( @@ -236,24 +264,27 @@ def test_collection_unauthenticated( "my new collection", # title "my description", # description "my-alias", # alias + False, # is_public id="api_root_id, title, description, alias", ), ], ) def test_add_collection( - app, api_root_id, title, description, alias, db_api_roots, db_collections + app, api_root_id, title, description, alias, is_public, db_api_roots, db_collections ): collection = app.taxii_server.servers.taxii2.persistence.api.add_collection( api_root_id=api_root_id, title=title, description=description, alias=alias, + is_public=is_public, ) assert collection.id is not None assert str(collection.api_root_id) == api_root_id assert collection.title == title assert collection.description == description assert collection.alias == alias + assert collection.is_public == is_public db_collection = ( app.taxii_server.servers.taxii2.persistence.api.db.session.query( taxii2models.Collection @@ -265,3 +296,4 @@ def test_add_collection( assert db_collection.title == title assert db_collection.description == description assert db_collection.alias == alias + assert db_collection.is_public == is_public diff --git a/tests/taxii2/test_taxii2_collections.py b/tests/taxii2/test_taxii2_collections.py index ff01c95c..13f04c8d 100644 --- a/tests/taxii2/test_taxii2_collections.py +++ b/tests/taxii2/test_taxii2_collections.py @@ -79,6 +79,14 @@ "can_write": True, "media_types": ["application/stix+json;version=2.1"], }, + { + "id": COLLECTIONS[6].id, + "title": "6Public", + "description": "public description", + "can_read": True, + "can_write": False, + "media_types": ["application/stix+json;version=2.1"], + }, ] }, id="good, first", diff --git a/tests/taxii2/test_taxii2_manifest.py b/tests/taxii2/test_taxii2_manifest.py index f7565b7c..fad8f07c 100644 --- a/tests/taxii2/test_taxii2_manifest.py +++ b/tests/taxii2/test_taxii2_manifest.py @@ -234,7 +234,11 @@ {"Accept": "application/taxii+json;version=2.1"}, API_ROOTS[0].id, COLLECTIONS[5].id, - {"next": GET_NEXT_PARAM({"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added})}, + { + "next": GET_NEXT_PARAM( + {"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added} + ) + }, 200, { "Content-Type": "application/taxii+json;version=2.1", @@ -286,9 +290,7 @@ { "Content-Type": "application/taxii+json;version=2.1", "X-TAXII-Date-Added-First": taxii2_datetimeformat(NOW), - "X-TAXII-Date-Added-Last": taxii2_datetimeformat( - NOW - ), + "X-TAXII-Date-Added-Last": taxii2_datetimeformat(NOW), }, { "more": False, @@ -342,9 +344,7 @@ { "Content-Type": "application/taxii+json;version=2.1", "X-TAXII-Date-Added-First": taxii2_datetimeformat(NOW), - "X-TAXII-Date-Added-Last": taxii2_datetimeformat( - NOW - ), + "X-TAXII-Date-Added-Last": taxii2_datetimeformat(NOW), }, { "more": False, @@ -451,8 +451,12 @@ 200, { "Content-Type": "application/taxii+json;version=2.1", - "X-TAXII-Date-Added-First": taxii2_datetimeformat(NOW + datetime.timedelta(seconds=2)), - "X-TAXII-Date-Added-Last": taxii2_datetimeformat(NOW + datetime.timedelta(seconds=3)), + "X-TAXII-Date-Added-First": taxii2_datetimeformat( + NOW + datetime.timedelta(seconds=2) + ), + "X-TAXII-Date-Added-Last": taxii2_datetimeformat( + NOW + datetime.timedelta(seconds=3) + ), }, { "more": False, @@ -739,14 +743,37 @@ def test_manifest( assert content == expected_content -@pytest.mark.parametrize( - "method", - ["get", "post", "delete"] -) +@pytest.mark.parametrize("is_public", [True, False]) +@pytest.mark.parametrize("method", ["get", "post", "delete"]) def test_manifest_unauthenticated( client, method, + is_public, ): - func = getattr(client, method) - response = func(f"/{API_ROOTS[0].id}/collections/{COLLECTIONS[5].id}/manifest/") - assert response.status_code == 401 + if is_public: + collection_id = COLLECTIONS[6].id + if method == "get": + expected_status_code = 200 + else: + expected_status_code = 405 + else: + collection_id = COLLECTIONS[0].id + if method == "get": + expected_status_code = 401 + else: + expected_status_code = 405 + with patch.object( + client.application.taxii_server.servers.taxii2.persistence.api, + "get_manifest", + side_effect=GET_MANIFEST_MOCK, + ), patch.object( + client.application.taxii_server.servers.taxii2.persistence.api, + "get_collection", + side_effect=GET_COLLECTION_MOCK, + ): + func = getattr(client, method) + response = func( + f"/{API_ROOTS[0].id}/collections/{collection_id}/manifest/", + headers={"Accept": "application/taxii+json;version=2.1"}, + ) + assert response.status_code == expected_status_code diff --git a/tests/taxii2/test_taxii2_object.py b/tests/taxii2/test_taxii2_object.py index 2c5833ea..b0e86a1f 100644 --- a/tests/taxii2/test_taxii2_object.py +++ b/tests/taxii2/test_taxii2_object.py @@ -853,13 +853,43 @@ def test_object( assert content == expected_content +@pytest.mark.parametrize("is_public", [True, False]) @pytest.mark.parametrize("method", ["get", "post", "delete"]) def test_object_unauthenticated( client, method, + is_public, ): - func = getattr(client, method) - response = func( - f"/{API_ROOTS[0].id}/collections/{COLLECTIONS[5].id}/objects/{STIX_OBJECTS[0].id}/" - ) - assert response.status_code == 401 + if is_public: + collection_id = COLLECTIONS[6].id + stix_id = STIX_OBJECTS[4].id + if method == "get": + expected_status_code = 200 + elif method == "delete": + expected_status_code = 401 + else: + expected_status_code = 405 + else: + collection_id = COLLECTIONS[0].id + stix_id = STIX_OBJECTS[0].id + if method == "get": + expected_status_code = 401 + elif method == "delete": + expected_status_code = 401 + else: + expected_status_code = 405 + with patch.object( + client.application.taxii_server.servers.taxii2.persistence.api, + "get_object", + side_effect=GET_OBJECT_MOCK, + ), patch.object( + client.application.taxii_server.servers.taxii2.persistence.api, + "get_collection", + side_effect=GET_COLLECTION_MOCK, + ): + func = getattr(client, method) + response = func( + f"/{API_ROOTS[0].id}/collections/{collection_id}/objects/{stix_id}/", + headers={"Accept": "application/taxii+json;version=2.1"}, + ) + assert response.status_code == expected_status_code diff --git a/tests/taxii2/test_taxii2_objects.py b/tests/taxii2/test_taxii2_objects.py index c968f605..e652fc8c 100644 --- a/tests/taxii2/test_taxii2_objects.py +++ b/tests/taxii2/test_taxii2_objects.py @@ -161,7 +161,9 @@ }, { "more": True, - "next": GET_NEXT_PARAM({"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added}), + "next": GET_NEXT_PARAM( + {"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added} + ), "objects": [ { "id": obj.id, @@ -255,7 +257,11 @@ {"Accept": "application/taxii+json;version=2.1"}, API_ROOTS[0].id, COLLECTIONS[5].id, - {"next": GET_NEXT_PARAM({"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added})}, + { + "next": GET_NEXT_PARAM( + {"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added} + ) + }, {}, 200, { @@ -1062,7 +1068,9 @@ def test_objects( assert response.status_code == expected_status if method == "post" and expected_status == 202: add_objects_mock.assert_called_once_with( - api_root_id=API_ROOTS[0].id, collection_id=COLLECTIONS[5].id, objects=post_data["objects"] + api_root_id=API_ROOTS[0].id, + collection_id=COLLECTIONS[5].id, + objects=post_data["objects"], ) else: add_objects_mock.assert_not_called() @@ -1079,11 +1087,67 @@ def test_objects( assert content == expected_content +@pytest.mark.parametrize("is_public", [True, False]) @pytest.mark.parametrize("method", ["get", "post", "delete"]) def test_objects_unauthenticated( client, method, + is_public, ): - func = getattr(client, method) - response = func(f"/{API_ROOTS[0].id}/collections/{COLLECTIONS[5].id}/objects/") - assert response.status_code == 401 + if is_public: + collection_id = COLLECTIONS[6].id + if method == "get": + expected_status_code = 200 + elif method == "post": + expected_status_code = 401 + else: + expected_status_code = 405 + else: + collection_id = COLLECTIONS[0].id + if method == "get": + expected_status_code = 401 + elif method == "post": + expected_status_code = 401 + else: + expected_status_code = 405 + with patch.object( + client.application.taxii_server.servers.taxii2.persistence.api, + "get_objects", + side_effect=GET_OBJECTS_MOCK, + ), patch.object( + client.application.taxii_server.servers.taxii2.persistence.api, + "get_collection", + side_effect=GET_COLLECTION_MOCK, + ): + kwargs = { + 'headers':{ + "Accept": "application/taxii+json;version=2.1", + "Content-Type": "application/taxii+json;version=2.1", + } + } + if method == "post": + kwargs["json"] = { + "objects": [ + { + "type": "indicator", + "spec_version": "2.1", + "id": "indicator--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f", + "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff", + "created": "2016-04-06T20:03:48.000Z", + "modified": "2016-04-06T20:03:48.000Z", + "indicator_types": ["malicious-activity"], + "name": "Poison Ivy Malware", + "description": "This file is part of Poison Ivy", + "pattern": "[ file:hashes.'SHA-256' = " + "'4bac27393bdd9777ce02453256c5577cd02275510b2227f473d03f533924f877' ]", + "pattern_type": "stix", + "valid_from": "2016-01-01T00:00:00Z", + } + ] + } + func = getattr(client, method) + response = func( + f"/{API_ROOTS[0].id}/collections/{collection_id}/objects/", + **kwargs, + ) + assert response.status_code == expected_status_code diff --git a/tests/taxii2/test_taxii2_sqldb.py b/tests/taxii2/test_taxii2_sqldb.py index 98788ce3..deb82e6a 100644 --- a/tests/taxii2/test_taxii2_sqldb.py +++ b/tests/taxii2/test_taxii2_sqldb.py @@ -1181,7 +1181,7 @@ def test_get_object( STIX_OBJECTS[0].id, # object_id None, # match_version None, # match_spec_version - [STIX_OBJECTS[1], STIX_OBJECTS[3]], # expected_objects + [STIX_OBJECTS[1], STIX_OBJECTS[3], STIX_OBJECTS[4]], # expected_objects id="default", ), pytest.param( diff --git a/tests/taxii2/test_taxii2_versions.py b/tests/taxii2/test_taxii2_versions.py index 737d4332..a15dcb93 100644 --- a/tests/taxii2/test_taxii2_versions.py +++ b/tests/taxii2/test_taxii2_versions.py @@ -420,13 +420,39 @@ def test_versions( assert content == expected_content +@pytest.mark.parametrize("is_public", [True, False]) @pytest.mark.parametrize("method", ["get", "post", "delete"]) def test_versions_unauthenticated( client, method, + is_public, ): - func = getattr(client, method) - response = func( - f"/{API_ROOTS[0].id}/collections/{COLLECTIONS[5].id}/objects/{STIX_OBJECTS[0].id}/versions/" - ) - assert response.status_code == 401 + if is_public: + collection_id = COLLECTIONS[6].id + stix_id = STIX_OBJECTS[4].id + if method == "get": + expected_status_code = 200 + else: + expected_status_code = 405 + else: + collection_id = COLLECTIONS[0].id + stix_id = STIX_OBJECTS[0].id + if method == "get": + expected_status_code = 401 + else: + expected_status_code = 405 + with patch.object( + client.application.taxii_server.servers.taxii2.persistence.api, + "get_versions", + side_effect=GET_VERSIONS_MOCK, + ), patch.object( + client.application.taxii_server.servers.taxii2.persistence.api, + "get_collection", + side_effect=GET_COLLECTION_MOCK, + ): + func = getattr(client, method) + response = func( + f"/{API_ROOTS[0].id}/collections/{collection_id}/objects/{stix_id}/versions/", + headers={"Accept": "application/taxii+json;version=2.1"}, + ) + assert response.status_code == expected_status_code diff --git a/tests/taxii2/utils.py b/tests/taxii2/utils.py index d2b48898..142db07f 100644 --- a/tests/taxii2/utils.py +++ b/tests/taxii2/utils.py @@ -76,13 +76,13 @@ ) COLLECTIONS = ( Collection( - str(uuid4()), API_ROOTS[0].id, "0Read only", "Read only description", None + str(uuid4()), API_ROOTS[0].id, "0Read only", "Read only description", None, False ), Collection( - str(uuid4()), API_ROOTS[0].id, "1Write only", "Write only description", None + str(uuid4()), API_ROOTS[0].id, "1Write only", "Write only description", None, False ), Collection( - str(uuid4()), API_ROOTS[0].id, "2Read/Write", "Read/Write description", None + str(uuid4()), API_ROOTS[0].id, "2Read/Write", "Read/Write description", None, False ), Collection( str(uuid4()), @@ -90,14 +90,24 @@ "3No permissions", "No permissions description", None, + False, ), - Collection(str(uuid4()), API_ROOTS[0].id, "4No description", "", None), + Collection(str(uuid4()), API_ROOTS[0].id, "4No description", "", None, False), Collection( str(uuid4()), API_ROOTS[0].id, "5With alias", "With alias description", "this-is-an-alias", + False, + ), + Collection( + str(uuid4()), + API_ROOTS[0].id, + "6Public", + "public description", + "", + True, ), ) STIX_OBJECTS = ( @@ -179,6 +189,26 @@ "valid_from": "2016-01-01T00:00:00Z", }, ), + STIXObject( + f"indicator--{str(uuid4())}", + COLLECTIONS[6].id, + "indicator", + "2.0", + NOW, + NOW + datetime.timedelta(seconds=1), + { + "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff", + "created": "2016-04-06T20:03:48.000Z", + "modified": taxii2_datetimeformat(NOW + datetime.timedelta(seconds=1)), + "indicator_types": ["malicious-activity"], + "name": "Poison Ivy Malware", + "description": "This file is part of Poison Ivy", + "pattern": "[ file:hashes.'SHA-256' = " + "'4bac27393bdd9777ce02453256c5577cd02275510b2227f473d03f533924f877' ]", + "pattern_type": "stix", + "valid_from": "2016-01-01T00:00:00Z", + }, + ), ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7686afa9..7d4c3028 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -351,6 +351,7 @@ def test_add_api_root( "title": "my new collection", "description": None, "alias": None, + "is_public": False, }, # expected_call id="rootid, title only", ), @@ -372,6 +373,7 @@ def test_add_api_root( "title": "my new collection", "description": "my description", "alias": None, + "is_public": False, }, # expected_call id="rootid, title, description", ), @@ -395,9 +397,25 @@ def test_add_api_root( "title": "my new collection", "description": "my description", "alias": "my-alias", + "is_public": False, }, # expected_call id="rootid, title, description, alias", ), + pytest.param( + ["-r", API_ROOTS[0].id, "-t", "my new collection", "--public"], # argv + False, # raises + None, # message + "", # stdout + "", # stderr + { + "api_root_id": API_ROOTS[0].id, + "title": "my new collection", + "description": None, + "alias": None, + "is_public": True, + }, # expected_call + id="rootid, titlei, public", + ), pytest.param( ["-r", "fake-uuid", "-t", "my new collection"], # argv SystemExit, # raises @@ -411,6 +429,7 @@ def test_add_api_root( "-t TITLE", "[-d DESCRIPTION]", "[-a ALIAS]", + "[--public]", ": error: argument -r/--rootid: invalid choice: 'fake-uuid'", "(choose from WRAPPED_ROOTIDS)", ] @@ -431,6 +450,7 @@ def test_add_api_root( "-t TITLE", "[-d DESCRIPTION]", "[-a ALIAS]", + "[--public]", ": error: the following arguments are required: -r/--rootid, -t/--title", ] ), From a4708ced2fb6533d97115c7488a2fdf2d58df463 Mon Sep 17 00:00:00 2001 From: Erwin Junge Date: Tue, 24 May 2022 12:08:07 +0200 Subject: [PATCH 2/4] Codestyle --- opentaxii/cli/persistence.py | 4 ++- opentaxii/persistence/sqldb/api.py | 12 +++++-- opentaxii/persistence/sqldb/taxii2models.py | 6 ++-- opentaxii/server.py | 8 +++-- opentaxii/taxii2/entities.py | 17 ++++++--- tests/taxii2/test_taxii2_object.py | 14 ++++++-- tests/taxii2/test_taxii2_objects.py | 2 +- tests/taxii2/test_taxii2_sqldb.py | 39 ++++++++++++++++----- tests/taxii2/test_taxii2_versions.py | 6 +++- tests/taxii2/utils.py | 29 ++++++++++++--- 10 files changed, 107 insertions(+), 30 deletions(-) diff --git a/opentaxii/cli/persistence.py b/opentaxii/cli/persistence.py index 98978381..25670349 100644 --- a/opentaxii/cli/persistence.py +++ b/opentaxii/cli/persistence.py @@ -134,7 +134,9 @@ def add_collection(): "-d", "--description", required=False, help="Description of the collection" ) parser.add_argument("-a", "--alias", required=False, help="alias of the collection") - parser.add_argument('--public', action='store_true', help="allow public read access") + parser.add_argument( + "--public", action="store_true", help="allow public read access" + ) parser.set_defaults(public=False) args = parser.parse_args() diff --git a/opentaxii/persistence/sqldb/api.py b/opentaxii/persistence/sqldb/api.py index c149730d..0d4e1e21 100644 --- a/opentaxii/persistence/sqldb/api.py +++ b/opentaxii/persistence/sqldb/api.py @@ -504,9 +504,11 @@ def parse_next_param(next_param: str) -> Dict: """ Parse provided `next_param` into kwargs to be used to filter stix objects. """ - date_added_str, obj_id = base64.b64decode(next_param.encode()).decode().split("|") + date_added_str, obj_id = ( + base64.b64decode(next_param.encode()).decode().split("|") + ) date_added = datetime.datetime.strptime( - date_added_str.split('+')[0], "%Y-%m-%dT%H:%M:%S.%f" + date_added_str.split("+")[0], "%Y-%m-%dT%H:%M:%S.%f" ).replace(tzinfo=datetime.timezone.utc) return {"id": obj_id, "date_added": date_added} @@ -684,7 +686,11 @@ def add_collection( :return: The added Collection entity. """ collection = taxii2models.Collection( - api_root_id=api_root_id, title=title, description=description, alias=alias, is_public=is_public + api_root_id=api_root_id, + title=title, + description=description, + alias=alias, + is_public=is_public, ) self.db.session.add(collection) self.db.session.commit() diff --git a/opentaxii/persistence/sqldb/taxii2models.py b/opentaxii/persistence/sqldb/taxii2models.py index c63fbff9..51ee1fb7 100644 --- a/opentaxii/persistence/sqldb/taxii2models.py +++ b/opentaxii/persistence/sqldb/taxii2models.py @@ -57,7 +57,7 @@ class Job(Base): details = relationship("JobDetail", back_populates="job") __table_args__ = ( - sqlalchemy.Index('ix_opentaxii_job_api_root_id_id', api_root_id, id), + sqlalchemy.Index("ix_opentaxii_job_api_root_id_id", api_root_id, id), ) @classmethod @@ -139,7 +139,9 @@ class STIXObject(Base): pk = sqlalchemy.Column(GUID, primary_key=True, default=uuid.uuid4) id = sqlalchemy.Column(sqlalchemy.String(100), index=True) collection_id = sqlalchemy.Column( - GUID, sqlalchemy.ForeignKey("opentaxii_collection.id", ondelete="CASCADE"), index=True, + GUID, + sqlalchemy.ForeignKey("opentaxii_collection.id", ondelete="CASCADE"), + index=True, ) type = sqlalchemy.Column(sqlalchemy.String(50), index=True) spec_version = sqlalchemy.Column(sqlalchemy.String(10), index=True) # STIX version diff --git a/opentaxii/server.py b/opentaxii/server.py index 06ad3754..94b92141 100644 --- a/opentaxii/server.py +++ b/opentaxii/server.py @@ -730,7 +730,9 @@ def object_handler(self, api_root_id, collection_id_or_alias, object_id): ) def object_get_handler(self, api_root_id, collection_id_or_alias, object_id): - filter_params = validate_object_filter_params(request.args, self.persistence.api) + filter_params = validate_object_filter_params( + request.args, self.persistence.api + ) try: versions, more, next_param = self.persistence.get_object( api_root_id=api_root_id, @@ -800,7 +802,9 @@ def object_delete_handler(self, api_root_id, collection_id_or_alias, object_id): handles_own_auth=True, ) def versions_handler(self, api_root_id, collection_id_or_alias, object_id): - filter_params = validate_versions_filter_params(request.args, self.persistence.api) + filter_params = validate_versions_filter_params( + request.args, self.persistence.api + ) try: versions, more = self.persistence.get_versions( api_root_id=api_root_id, diff --git a/opentaxii/taxii2/entities.py b/opentaxii/taxii2/entities.py index 8d409758..1f6494e8 100644 --- a/opentaxii/taxii2/entities.py +++ b/opentaxii/taxii2/entities.py @@ -38,7 +38,13 @@ class Collection(Entity): """ def __init__( - self, id: str, api_root_id: str, title: str, description: str, alias: str, is_public: bool + self, + id: str, + api_root_id: str, + title: str, + description: str, + alias: str, + is_public: bool, ): """Initialize Collection.""" self.id = id @@ -50,9 +56,12 @@ def __init__( def can_read(self, account: Optional[Account]): """Determine if `account` is allowed to read from this collection.""" - return self.is_public or ( account and ( - account.is_admin or "read" in set(account.permissions.get(self.id, [])) - ) ) + return self.is_public or ( + account + and ( + account.is_admin or "read" in set(account.permissions.get(self.id, [])) + ) + ) def can_write(self, account: Optional[Account]): """Determine if `account` is allowed to write to this collection.""" diff --git a/tests/taxii2/test_taxii2_object.py b/tests/taxii2/test_taxii2_object.py index b0e86a1f..ac2dd08c 100644 --- a/tests/taxii2/test_taxii2_object.py +++ b/tests/taxii2/test_taxii2_object.py @@ -203,7 +203,9 @@ }, { "more": True, - "next": GET_NEXT_PARAM({"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added}), + "next": GET_NEXT_PARAM( + {"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added} + ), "objects": [ { "id": obj.id, @@ -299,7 +301,11 @@ API_ROOTS[0].id, COLLECTIONS[5].id, STIX_OBJECTS[0].id, - {"next": GET_NEXT_PARAM({"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added})}, + { + "next": GET_NEXT_PARAM( + {"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added} + ) + }, 200, { "Content-Type": "application/taxii+json;version=2.1", @@ -314,7 +320,9 @@ COLLECTIONS[5].id, STIX_OBJECTS[0].id, { - "next": GET_NEXT_PARAM({"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added}), + "next": GET_NEXT_PARAM( + {"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added} + ), "match[version]": "all", }, 200, diff --git a/tests/taxii2/test_taxii2_objects.py b/tests/taxii2/test_taxii2_objects.py index e652fc8c..7278b769 100644 --- a/tests/taxii2/test_taxii2_objects.py +++ b/tests/taxii2/test_taxii2_objects.py @@ -1120,7 +1120,7 @@ def test_objects_unauthenticated( side_effect=GET_COLLECTION_MOCK, ): kwargs = { - 'headers':{ + "headers": { "Accept": "application/taxii+json;version=2.1", "Content-Type": "application/taxii+json;version=2.1", } diff --git a/tests/taxii2/test_taxii2_sqldb.py b/tests/taxii2/test_taxii2_sqldb.py index deb82e6a..76076504 100644 --- a/tests/taxii2/test_taxii2_sqldb.py +++ b/tests/taxii2/test_taxii2_sqldb.py @@ -147,7 +147,9 @@ def test_get_collections(taxii2_sqldb_api, db_collections, api_root_id): ), ], ) -def test_get_collection(taxii2_sqldb_api, db_collections, api_root_id, collection_id_or_alias): +def test_get_collection( + taxii2_sqldb_api, db_collections, api_root_id, collection_id_or_alias +): response = taxii2_sqldb_api.get_collection(api_root_id, collection_id_or_alias) assert response == GET_COLLECTION_MOCK(api_root_id, collection_id_or_alias) @@ -223,7 +225,10 @@ def test_get_collection(taxii2_sqldb_api, db_collections, api_root_id, collectio COLLECTIONS[5].id, # collection_id None, # limit None, # added_after - {"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added}, # next_kwargs + { + "id": STIX_OBJECTS[0].id, + "date_added": STIX_OBJECTS[0].date_added, + }, # next_kwargs None, # match_id None, # match_type None, # match_version @@ -516,7 +521,10 @@ def test_get_manifest( COLLECTIONS[5].id, # collection_id None, # limit None, # added_after - {"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added}, # next_kwargs + { + "id": STIX_OBJECTS[0].id, + "date_added": STIX_OBJECTS[0].date_added, + }, # next_kwargs None, # match_id None, # match_type None, # match_version @@ -996,7 +1004,10 @@ def test_add_objects( STIX_OBJECTS[0].id, # object_id None, # limit None, # added_after - {"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added}, # next_kwargs + { + "id": STIX_OBJECTS[0].id, + "date_added": STIX_OBJECTS[0].date_added, + }, # next_kwargs None, # match_version None, # match_spec_version id="next", @@ -1006,7 +1017,10 @@ def test_add_objects( STIX_OBJECTS[0].id, # object_id None, # limit None, # added_after - {"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added}, # next_kwargs + { + "id": STIX_OBJECTS[0].id, + "date_added": STIX_OBJECTS[0].date_added, + }, # next_kwargs ["all"], # match_version None, # match_spec_version id="next, all", @@ -1300,7 +1314,10 @@ def test_delete_object( STIX_OBJECTS[0].id, # object_id None, # limit None, # added_after - {"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added}, # next_kwargs + { + "id": STIX_OBJECTS[0].id, + "date_added": STIX_OBJECTS[0].date_added, + }, # next_kwargs None, # match_spec_version id="next", ), @@ -1381,5 +1398,11 @@ def test_get_versions( ], ) def test_next_param(taxii2_sqldb_api, stix_id, date_added, next_param): - assert taxii2_sqldb_api.get_next_param({"id": stix_id, "date_added": date_added}) == next_param - assert taxii2_sqldb_api.parse_next_param(next_param) == {"id": stix_id, "date_added": date_added} + assert ( + taxii2_sqldb_api.get_next_param({"id": stix_id, "date_added": date_added}) + == next_param + ) + assert taxii2_sqldb_api.parse_next_param(next_param) == { + "id": stix_id, + "date_added": date_added, + } diff --git a/tests/taxii2/test_taxii2_versions.py b/tests/taxii2/test_taxii2_versions.py index a15dcb93..bd77242d 100644 --- a/tests/taxii2/test_taxii2_versions.py +++ b/tests/taxii2/test_taxii2_versions.py @@ -184,7 +184,11 @@ API_ROOTS[0].id, COLLECTIONS[5].id, STIX_OBJECTS[0].id, - {"next": GET_NEXT_PARAM({"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added})}, + { + "next": GET_NEXT_PARAM( + {"id": STIX_OBJECTS[0].id, "date_added": STIX_OBJECTS[0].date_added} + ) + }, 200, { "Content-Type": "application/taxii+json;version=2.1", diff --git a/tests/taxii2/utils.py b/tests/taxii2/utils.py index 142db07f..1f43f63f 100644 --- a/tests/taxii2/utils.py +++ b/tests/taxii2/utils.py @@ -76,13 +76,28 @@ ) COLLECTIONS = ( Collection( - str(uuid4()), API_ROOTS[0].id, "0Read only", "Read only description", None, False + str(uuid4()), + API_ROOTS[0].id, + "0Read only", + "Read only description", + None, + False, ), Collection( - str(uuid4()), API_ROOTS[0].id, "1Write only", "Write only description", None, False + str(uuid4()), + API_ROOTS[0].id, + "1Write only", + "Write only description", + None, + False, ), Collection( - str(uuid4()), API_ROOTS[0].id, "2Read/Write", "Read/Write description", None, False + str(uuid4()), + API_ROOTS[0].id, + "2Read/Write", + "Read/Write description", + None, + False, ), Collection( str(uuid4()), @@ -358,7 +373,9 @@ def GET_OBJECTS_MOCK( continue response.append(stix_object) if more: - next_param = GET_NEXT_PARAM({"id": response[-1].id, "date_added": response[-1].date_added}) + next_param = GET_NEXT_PARAM( + {"id": response[-1].id, "date_added": response[-1].date_added} + ) else: next_param = None return response, more, next_param @@ -404,7 +421,9 @@ def GET_OBJECT_MOCK( continue response.append(stix_object) if more: - next_param = GET_NEXT_PARAM({"id": response[-1].id, "date_added": response[-1].date_added}) + next_param = GET_NEXT_PARAM( + {"id": response[-1].id, "date_added": response[-1].date_added} + ) else: next_param = None if not at_least_one: From 3561573a31add552ffe8af3af675501860a22063 Mon Sep 17 00:00:00 2001 From: Erwin Junge Date: Tue, 24 May 2022 13:11:27 +0200 Subject: [PATCH 3/4] Add docstring --- opentaxii/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/opentaxii/server.py b/opentaxii/server.py index 94b92141..d64a2c13 100644 --- a/opentaxii/server.py +++ b/opentaxii/server.py @@ -428,6 +428,7 @@ def get_endpoint(self, relative_path: str) -> Optional[Callable[[], Response]]: return functools.partial(self.handle_request, endpoint) def check_authentication(self, endpoint: Callable[[], Response]): + """Check if account is authenticated, unless endpoint handles that itself.""" if endpoint.func.handles_own_auth: # Endpoint will handle auth checks itself return From 34dff027d5cfbd978fd791a39db6f87dff7118a3 Mon Sep 17 00:00:00 2001 From: Erwin Junge Date: Tue, 24 May 2022 13:19:20 +0200 Subject: [PATCH 4/4] Update changelog and bump version --- CHANGES.rst | 4 ++++ opentaxii/_version.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 8de9ffe6..34025106 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,10 @@ Changelog ========= +0.5.0 (2022-05-24) +------------------ +* Add support for publicly readable taxii 2 collections + 0.4.0 (2022-05-20) ------------------ * Move next_param handling into `OpenTAXII2PersistenceAPI` diff --git a/opentaxii/_version.py b/opentaxii/_version.py index 287b6a6d..42536e5c 100644 --- a/opentaxii/_version.py +++ b/opentaxii/_version.py @@ -3,4 +3,4 @@ This module defines the package version for use in __init__.py and setup.py. """ -__version__ = '0.4.0' +__version__ = '0.5.0'