From a0ecbc3dc95db0aa4411b6c0e59ba22bb72d85b5 Mon Sep 17 00:00:00 2001 From: James Sandford Date: Tue, 14 Jul 2026 13:57:25 +0100 Subject: [PATCH 1/3] Add ADR for adding fine-grained auth support to Storage Backends --- docs/README.md | 1 + ...0053-fine-grained-auth-storage-backends.md | 84 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 docs/adr/0053-fine-grained-auth-storage-backends.md diff --git a/docs/README.md b/docs/README.md index c6719ba7..f38b0ec9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -87,5 +87,6 @@ For more information on how we use ADRs, see [here](./adr/README.md). | [0045](./adr/0045-flow-init-segments.md) | Support for init Segments in Flows | | [0046](./adr/0046-governance.md) | Governance | | [0048](./adr/0048-media-integrity.md) | Integrity model for media in TAMS, and when interacting with other systems | +| [0053](./adr/0053-fine-grained-auth-storage-backends.md) | Support for Fine-Grained Authorisation on Storage Backends | \* Note: ADR 0004a was the unintended result of a number clash in the early development of TAMS which wasn't caught before publication diff --git a/docs/adr/0053-fine-grained-auth-storage-backends.md b/docs/adr/0053-fine-grained-auth-storage-backends.md new file mode 100644 index 00000000..bca32c31 --- /dev/null +++ b/docs/adr/0053-fine-grained-auth-storage-backends.md @@ -0,0 +1,84 @@ +--- +status: "proposed" +--- +# Support for Fine-Grained Authorisation on Storage Backends + +## Context and Problem Statement + +[ADR0035](../adr/0035-fine-grained-auth.md) defined our approach to fine-grained authorisation in TAMS. +[AppNote0016](../appnotes/0016-authorisation-in-tams-workflows.md) describes how this may be implemented in practice. +The AppNote0016 approach relies upon the use of tags in key base resources. +[ADR0032](../adr/0032-specifying-storage-backend.md) added support for multiple Storage Backends. +But the lack of tags, or other appropriate metadata, limits the ability to apply AppNote0016 fine-grained auth to Storage Backends. + +Deployers/implementers of TAMS have expressed desire for the ability to use fine-grained auth approaches with Storage Backends. +Use cases include cost allocation, cost management, and read only Storage Backends. + +This ADR discusses the options for supporting fine-grained auth on Storage Backends. + +## Considered Options + +* Option 1: No change +* Option 2: Advise out-of-band authorisation for Storage Backends +* Option 3: Add read-only tags to Storage Backends +* Option 4: Add read-write tags to Storage Backends +* Option 5: Add an auth-specific parameter to Storage Backends + +## Decision Outcome + +Chosen Option 3: Add read-only tags to Storage Backends, because it matches existing patterns for fine-grained Auth and Storage Backend endpoints. + +### Implementation + +Implemented in [PR #229](https://github.com/bbc/tams/pull/229) + +## Pros and Cons of the Options + +### Option 1: No change + +This option would maintain the current position with no changes to the API specification, or Application Notes. + +* Good, because no changes are required in the specification +* Bad, because fine-grained auth would not be explicitly supported for Storage Backends +* Bad, because fragmented, non-standardised approaches to fine-grained auth for Storage Backends could emerge + +### Option 2: Advise out-of-band authorisation for Storage Backends + +This option would see no changes to the API specification, but an explicit recommendation in AppNote0016 that out-of-band methods should be used to implement/signal Storage Backend authorisation + +* Good, because no changes are required in the specification +* Good, because fine-grained auth would be explicitly supported for Storage Backends +* Bad, because fragmented, non-standardised approaches to fine-grained auth for Storage Backends could emerge +* Bad, because it doesn't match existing patterns for auth + +### Option 3: Add read-only tags to Storage Backends + +This option would see tags added to Storage Backends, but no PUT/POST methods would be provided to set them via the API. +Instead, they would be set at deploy-time as with other existing Storage Backend parameters. +AppNote0016 would be updated to use the same patterns used for other resource types. + +* Good, because fine-grained auth would be explicitly supported for Storage Backends +* Good, because it would produce a well-defined standard approach to Storage Backend auth +* Good, because it matches existing patterns for auth +* Neutral, because non-breaking changes are required in the specification +* Neutral, because changing of auth classes (or other tags) would require a re-deploy of the TAMS Service Implementation + * Though use cases for frequent updating of these parameters are unclear + +### Option 4: Add read-write tags to Storage Backends + +As with Option 3, but with additional HTTP methods allowing CRUD operations on Storage Endpoint tags. + +Good/Neutral/Bad as per Option 3, with the last point replaced by: + +* Neutral, because changing of auth classes (or other tags) wouldn't require a re-deploy of the TAMS Service Implementation + * Though use cases for frequent updating of these parameters are unclear + +### Option 5: Add an auth-specific parameter to Storage Backends + +This option would see an auth-specific parameter added to Storage Backends. +AppNote0016 would be updated to use the same patterns used for other resource types but referencing this new parameter instead of tags. + +* Good, because fine-grained auth would be explicitly supported for Storage Backends +* Good, because it would produce a well-defined standard approach to Storage Backend auth +* Neutral, because non-breaking changes are required in the specification +* Bad, because it doesn't match existing patterns for auth From 6c883f9b69e1469d6351c08d17317effdbcf1dd1 Mon Sep 17 00:00:00 2001 From: James Sandford Date: Tue, 14 Jul 2026 14:08:42 +0100 Subject: [PATCH 2/3] Update spec and application notes based on ADR0053 --- api/TimeAddressableMediaStore.yaml | 90 +++++++++++++++++++ api/schemas/storage-backend.json | 4 + docs/appnotes/0003-tag-names.md | 9 ++ .../0016-authorisation-in-tams-workflows.md | 19 ++-- 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/api/TimeAddressableMediaStore.yaml b/api/TimeAddressableMediaStore.yaml index 12333c38..73cbf039 100644 --- a/api/TimeAddressableMediaStore.yaml +++ b/api/TimeAddressableMediaStore.yaml @@ -128,6 +128,27 @@ paths: operationId: HEAD_storage-backends tags: - Service + parameters: + - name: tag.{name} + in: query + description: | + Filter on Storage Backends that have a tag named {name} with a value in the given comma-seperated list of values. + The {name} and the value MUST be URL encoded where special characters are present. + Where the tag's value is a string, at least one of the given values will match. + Where the tag's value is an array, at least one value in the array will match at least one of the given values. + Partial string matches of the values are not valid. + schema: + $ref: 'schemas/url-tag-list.json' + - name: tag_exists.{name} + in: query + description: | + Filter on Storage Backends that have a tag named {name} regardless of value. + The {name} MUST be URL encoded where special characters are present. + If set to true then the presence of the tag is filtered for. + If set to false then its absence is. + If left out then no filtering on tag presence is performed. + schema: + type: boolean responses: "200": $ref: '#/components/responses/trait_resource_listing_head_200' @@ -137,6 +158,27 @@ paths: operationId: GET_storage-backends tags: - Service + parameters: + - name: tag.{name} + in: query + description: | + Filter on Storage Backends that have a tag named {name} with a value in the given comma-seperated list of values. + The {name} and the value MUST be URL encoded where special characters are present. + Where the tag's value is a string, at least one of the given values will match. + Where the tag's value is an array, at least one value in the array will match at least one of the given values. + Partial string matches of the values are not valid. + schema: + $ref: 'schemas/url-tag-list.json' + - name: tag_exists.{name} + in: query + description: | + Filter on Storage Backends that have a tag named {name} regardless of value. + The {name} MUST be URL encoded where special characters are present. + If set to true then the presence of the tag is filtered for. + If set to false then its absence is. + If left out then no filtering on tag presence is performed. + schema: + type: boolean responses: "200": description: "" @@ -1804,6 +1846,18 @@ paths: Where multiple filter query parameters are provided, the returned `get_urls` and `init_object.get_urls` will match all filters. schema: type: boolean + - name: storage_backend_tag.{name} + in: query + description: | + Filter Flow Segment `get_urls` and `init_object.get_urls` on tag values. This option is the same as the `tag.{name}` query parameter on the `/service/storage-backends` API endpoint. + schema: + type: string + - name: storage_backend_tag_exists.{name} + in: query + description: | + Filter Flow Segment `get_urls` and `init_object.get_urls` on tag names. This option is the same as the `tag_exists.{name}` query parameter on the `/service/storage-backends` API endpoint. + schema: + type: boolean - name: include_object_timerange in: query description: | @@ -1939,6 +1993,18 @@ paths: Where multiple filter query parameters are provided, the returned `get_urls` and `init_object.get_urls` will match all filters. schema: type: boolean + - name: storage_backend_tag.{name} + in: query + description: | + Filter Flow Segment `get_urls` and `init_object.get_urls` on tag values. This option is the same as the `tag.{name}` query parameter on the `/service/storage-backends` API endpoint. + schema: + type: string + - name: storage_backend_tag_exists.{name} + in: query + description: | + Filter Flow Segment `get_urls` and `init_object.get_urls` on tag names. This option is the same as the `tag_exists.{name}` query parameter on the `/service/storage-backends` API endpoint. + schema: + type: boolean - name: include_object_timerange in: query description: | @@ -2262,6 +2328,18 @@ paths: Filter `referenced_by_flows` on tag names. This option is the same as the `tag_exists.{name}` query parameter on the `/flows/` API endpoint. schema: type: boolean + - name: storage_backend_tag.{name} + in: query + description: | + Filter `get_urls` and `init_object.get_urls` on tag values. This option is the same as the `tag.{name}` query parameter on the `/service/storage-backends` API endpoint. + schema: + type: string + - name: storage_backend_tag_exists.{name} + in: query + description: | + Filter `get_urls` and `init_object.get_urls` on tag names. This option is the same as the `tag_exists.{name}` query parameter on the `/service/storage-backends` API endpoint. + schema: + type: boolean - $ref: '#/components/parameters/trait_resource_paged_key' - $ref: '#/components/parameters/trait_paged_limit' responses: @@ -2359,6 +2437,18 @@ paths: Filter `referenced_by_flows` on tag names. This option is the same as the `tag_exists.{name}` query parameter on the `/flows/` API endpoint. schema: type: boolean + - name: storage_backend_tag.{name} + in: query + description: | + Filter `get_urls` and `init_object.get_urls` on tag values. This option is the same as the `tag.{name}` query parameter on the `/service/storage-backends` API endpoint. + schema: + type: string + - name: storage_backend_tag_exists.{name} + in: query + description: | + Filter `get_urls` and `init_object.get_urls` on tag names. This option is the same as the `tag_exists.{name}` query parameter on the `/service/storage-backends` API endpoint. + schema: + type: boolean - $ref: '#/components/parameters/trait_resource_paged_key' - $ref: '#/components/parameters/trait_paged_limit' responses: diff --git a/api/schemas/storage-backend.json b/api/schemas/storage-backend.json index 9f30c101..9e829b9d 100644 --- a/api/schemas/storage-backend.json +++ b/api/schemas/storage-backend.json @@ -25,6 +25,10 @@ "store_product": { "description": "The storage product name.", "type": "string" + }, + "tags": { + "description": "Key value is a freeform string.", + "$ref": "tags.json" } } } diff --git a/docs/appnotes/0003-tag-names.md b/docs/appnotes/0003-tag-names.md index e68b421c..7bbee79e 100644 --- a/docs/appnotes/0003-tag-names.md +++ b/docs/appnotes/0003-tag-names.md @@ -295,3 +295,12 @@ Examples: * [`fra`](https://iso639-3.sil.org/code/fra) for audio containing French speech * [`deu`](https://iso639-3.sil.org/code/deu) for German captions * `["eng", "fra"]` for audio where both English and French are spoken + +## Known Storage Backend Tags + +### auth_classes + +Status: **Experimental** + +Suggested as a way to build lightweight Attribute-based Access Control in [AppNote0016: Authorisation in TAMS workflows](./0016-authorisation-in-tams-workflows.md). +A comma seperated list of auth classes used to derive permissions on the Storage Backend. diff --git a/docs/appnotes/0016-authorisation-in-tams-workflows.md b/docs/appnotes/0016-authorisation-in-tams-workflows.md index 1fc8e860..3ff2e479 100644 --- a/docs/appnotes/0016-authorisation-in-tams-workflows.md +++ b/docs/appnotes/0016-authorisation-in-tams-workflows.md @@ -138,7 +138,7 @@ For example - hiding collection relationships may result in clients deciding to | `/` | `HEAD`/`GET` | Available to all | | `/service` | `HEAD`/`GET` | Available to all | | | `POST` | Request must have admin permissions. | -| `/service/storage-backends` | `HEAD`/`GET` | Available to all | +| `/service/storage-backends` | `HEAD`/`GET` | Restrict returned data to only the Storage Backends that the request has any permission on. | | `/service/webhooks` | `HEAD`/`GET` | Restrict returned data to only the webhooks that the request has read permission on. | | | `POST` | If the request includes Source or Flow filters, the request must have read permissions on all Source or Flow IDs requested. Otherwise, reject. Note that this endpoint only allows creation, not modification, of webhooks. | | `/service/webhooks/{webhookId}` | `HEAD`/`GET` | Request must have read permissions on {webhookId}. | @@ -181,13 +181,13 @@ For example - hiding collection relationships may result in clients deciding to | `/flows/{flowId}/avg_bit_rate` | `HEAD`/`GET` | Request must have read permissions on {flowId}. | | | `PUT` | Request must have write permissions on {flowId}. | | | `DELETE` | Request must have write permissions on {flowId}. | -| `/flows/{flowId}/segments` | `HEAD`/`GET` | Request must have read permissions on {flowId}. | +| `/flows/{flowId}/segments` | `HEAD`/`GET` | Request must have read permissions on {flowId}. Restrict returned `get_urls` to only the Storage Backends that the request has read permission on. If the request has access but all of the Object Instances have been filtered out, return the response with an empty `get_urls` and/or `init_object.get_urls` list. | | | `POST` | Request must have write permissions on {flowId}, and either this must be the first registration of the Media Object(s) (i.e. `/objects/{objectId}` returns 404) or the request must have read access to the Media Object(s) being written. Otherwise reject. | | | `DELETE` | Request must have delete permissions on {flowId}. | -| `/flows/{flowId}/storage` | `POST` | Request must have write permissions on {flowId}. | -| `/objects/{objectId}` | `HEAD`/`GET` | Restrict returned data in `referenced_by_flows` property to only the Flows that the request has read access to. If the request has read access to no Flows of this object, return 404, however if the request has access but all of the Flows have been filtered out, return the response with an empty `referenced_by_flows` list. | -| `/objects/{objectId}/instances` | `POST` | Request must have write permissions on {objectId}. | -| | `DELETE` | Request must have write permissions on {objectId}. | +| `/flows/{flowId}/storage` | `POST` | Request must have write permissions on {flowId}, and the Storage Backend requested. | +| `/objects/{objectId}` | `HEAD`/`GET` | Restrict returned data in `referenced_by_flows` property to only the Flows that the request has read access to. Restrict returned `get_urls` to only the Storage Backends that the request has read permission on. If the request has read access to no Flows of this object, return 404. However if the request has access but all of the Flows have been filtered out, return the response with an empty `referenced_by_flows` list. If the request has access but all of the Object Instances have been filtered out, return the response with an empty `get_urls` and/or `init_object.get_urls` list. | +| `/objects/{objectId}/instances` | `POST` | Request must have write permissions on {objectId}, and the Storage Backend requested. | +| | `DELETE` | Request must have write permissions on {objectId}, and the Storage Backend requested. | | `/flow-delete-requests` | `HEAD`/`GET` | Request must have admin permissions. | | `/flow-delete-requests/{request-id}` | `HEAD`/`GET` | Request must have delete permissions on the Delete Request's Flow ID. | @@ -256,6 +256,13 @@ Propagation should also be triggered when a new Source/Flow is added to a Source Implementations may wish to support auth classes and related auth logic that explicitly denies permissions against resources. In these cases, a matching "deny" class takes precedent over an "allow" class. +### Storage Backend Delete permission + +This Application Note only makes use of Read and Write permissions on Storage Backends. +Implementations may wish to additionally support specific Delete permissions on Storage Backends. +This permission would allow/deny deletion of Flows/Segments/Object Instances on specific Storage Backends, even if they have Delete permissions on the given resource. +This mode of operation may be useful for use cases such as Records of Transmission, or archive copies of Flows. + ## Where to Enforce Authorisation Some consideration should be given for where to apply the authorisation step, depending on how TAMS is deployed and integrated. From a4c19d4523d1f1fd4adf2c244b0841c98f229026 Mon Sep 17 00:00:00 2001 From: James Sandford Date: Tue, 14 Jul 2026 14:39:53 +0100 Subject: [PATCH 3/3] Update authz_proxy example to support fine-grained auth on Storage Backends. Fix incorrect query param name in `filtered_object()` --- examples/authz_proxy/api.py | 55 ++++++++++-- examples/authz_proxy/resources.py | 138 +++++++++++++++++++++++++++--- 2 files changed, 175 insertions(+), 18 deletions(-) diff --git a/examples/authz_proxy/api.py b/examples/authz_proxy/api.py index d09c1aff..0c812f75 100644 --- a/examples/authz_proxy/api.py +++ b/examples/authz_proxy/api.py @@ -1,4 +1,5 @@ import os +import json from functools import wraps from uuid import UUID @@ -84,8 +85,8 @@ async def post_service(request: Request, groups: list[str]): @app.route('/service/storage-backends', methods=['GET', 'HEAD']) @handle_token() async def storage_backends(request: Request, groups: list[str]): - # Available to all - return await app.ctx.tams.passthrough_request(request) + # Restrict returned data to only the storage backends that the request has any permission on. + return await app.ctx.tams.filtered_storage_backends(request, groups) @app.route('/service/webhooks', methods=['GET', 'HEAD']) @@ -481,8 +482,12 @@ async def put_del_flow_avg_bit_rate(request: Request, groups: list[str], flow_id @passthrough_if_admin() async def flow_segments(request: Request, groups: list[str], flow_id: UUID): # Request must have read permissions on {flowID}. + # Restrict returned `get_urls` to only the Storage Backends that the request has read permission on. + # If the request has access but all of the Object Instances have been filtered out, + # return the response with an empty `get_urls` and/or `init_object.get_urls` list. (await app.ctx.tams.Flow(request, flow_id)).has_read(groups, throw=True) - return await app.ctx.tams.passthrough_request(request) + filtered_groups = filter_read(groups) + return await app.ctx.tams.filtered_segments(request, filtered_groups) @app.route('/flows//segments', methods=['POST']) @@ -532,8 +537,21 @@ async def del_flow_segments(request: Request, groups: list[str], flow_id: UUID): @handle_token() @passthrough_if_admin() async def post_flow_storage(request: Request, groups: list[str], flow_id: UUID): - # Request must have write permissions on {flowId}. + # Request must have write permissions on {flowId}, and the Storage Backend requested. (await app.ctx.tams.Flow(request, flow_id)).has_write(groups, throw=True) + + storage_id = request.json.get("storage_id", None) + if not storage_id: + # If `storage_id` isn't provided, get the default + storage_backends = (await app.ctx.tams.get_upstream(request, "/service/storage-backends")).json() + for storage_backend in storage_backends: + if storage_backend.get("default_storage", False): + storage_id = storage_backend["id"] + break + assert (storage_id) + + (await app.ctx.tams.StorageBackend(request, storage_id)).has_write(groups, throw=True) + return await app.ctx.tams.passthrough_request(request) @@ -542,19 +560,40 @@ async def post_flow_storage(request: Request, groups: list[str], flow_id: UUID): @passthrough_if_admin() async def object(request: Request, groups: list[str], object_id: UUID): # Restrict returned data in referenced_by_flows property to only the Flows that the request has read access to. + # Restrict returned `get_urls` to only the Storage Backends that the request has read permission on. # If the request has read access to no Flows of this object, return 404, # however if the request has access but all of the Flows have been filtered out, # return the response with an empty referenced_by_flows list. + # If the request has access but all of the Object Instances have been filtered out, + # return the response with an empty `get_urls` and/or `init_object.get_urls` list. await (await app.ctx.tams.MediaObject(request, object_id)).has_read(groups, throw=True) - return await app.ctx.tams.filtered_object(request, object_id, groups) + filtered_groups = filter_read(groups) + return await app.ctx.tams.filtered_object(request, filtered_groups) + + +@app.route('/objects//instances', methods=['POST']) +@handle_token() +@passthrough_if_admin() +async def post_object_instances(request: Request, groups: list[str], object_id: UUID): + # Request must have write permissions on {objectId}, and the Storage Backend requested. + await (await app.ctx.tams.MediaObject(request, object_id)).has_write(groups, throw=True) + assert request.body + json_body = json.loads(request.body) + if json_body.get("storage_id", None): + storage_id = json_body["storage_id"] + await (await app.ctx.tams.StorageBackend(request, storage_id)).has_write(groups, throw=True) + return await app.ctx.tams.passthrough_request(request) -@app.route('/objects//instances', methods=['POST', 'DELETE']) +@app.route('/objects//instances', methods=['DELETE']) @handle_token() @passthrough_if_admin() -async def post_del_object_instances(request: Request, groups: list[str], object_id: UUID): - # Request must have write permissions on {objectId}. +async def del_object_instances(request: Request, groups: list[str], object_id: UUID): + # Request must have write permissions on {objectId}, and the Storage Backend requested. await (await app.ctx.tams.MediaObject(request, object_id)).has_write(groups, throw=True) + if request.args.get("storage_id", None): + storage_id = request.args["storage_id"] + await (await app.ctx.tams.StorageBackend(request, storage_id)).has_write(groups, throw=True) return await app.ctx.tams.passthrough_request(request) diff --git a/examples/authz_proxy/resources.py b/examples/authz_proxy/resources.py index 802831ac..962f149e 100644 --- a/examples/authz_proxy/resources.py +++ b/examples/authz_proxy/resources.py @@ -21,6 +21,9 @@ class Tams(object): logger: Logger api_url: str + async def StorageBackend(self, request: Request, storage_id: UUID): + return await StorageBackend(self.client, self.api_url, request, storage_id)._async_init() + async def Flow(self, request: Request, flow_id: UUID): return await Flow(self.client, self.api_url, request, flow_id)._async_init() @@ -103,6 +106,9 @@ async def _filtered_resource_listing(self, request: Request, groups: list[str]) return json_resp(filtered_body, res.status, cast(dict[str, str], res.headers)) + async def filtered_storage_backends(self, request: Request, groups: list[str]) -> HTTPResponse: + return await self._filtered_resource_listing(request, groups) + async def filtered_sources(self, request: Request, groups: list[str]) -> HTTPResponse: return await self._filtered_resource_listing(request, groups) @@ -112,32 +118,126 @@ async def filtered_flows(self, request: Request, groups: list[str]) -> HTTPRespo async def filtered_webhooks(self, request: Request, groups: list[str]) -> HTTPResponse: return await self._filtered_resource_listing(request, groups) - async def filtered_object(self, request: Request, object_id: UUID, groups: list[str]) -> HTTPResponse: - orig_auth_classes_str = request.args.get("tag.auth_classes", "") - orig_auth_classes = orig_auth_classes_str.split(",") if orig_auth_classes_str else [] + async def filtered_object(self, request: Request, groups: list[str]) -> HTTPResponse: + # Initially filter by request's permissions in upstream request + # Returned results will then have the user-specified filters applied + orig_flow_auth_classes_str = request.args.get("flow_tag.auth_classes", "") + orig_flow_auth_classes = orig_flow_auth_classes_str.split(",") if orig_flow_auth_classes_str else [] + request.args["flow_tag.auth_classes"] = ",".join(groups) - request.args["tag.auth_classes"] = ",".join(groups) + orig_storage_backend_auth_classes_str = request.args.get("storage_backend_tag.auth_classes", "") + orig_storage_backend_auth_classes = ( + orig_storage_backend_auth_classes_str.split(",") if orig_storage_backend_auth_classes_str else []) + request.args["storage_backend_tag.auth_classes"] = ",".join(groups) - res = await self.passthrough_request(request) + # Set `verbose_storage` to True to include `storage_id` alongside `get_url`s + # If originally false, verbose data will be filtered out before returning + orig_verbose_storage_str = request.args.get("verbose_storage", "false") + orig_verbose_storage_bool = (orig_verbose_storage_str.lower() == "true") + request.args["verbose_storage"] = "true" + res = await self.passthrough_request(request) assert res.body json_body = json.loads(res.body) + # Handle user specified filters on `auth_classes` filtered_referenced_by_flows = [] - - # Handle user specified filter on `auth_classes` - if len(orig_auth_classes) > 0: + if len(orig_flow_auth_classes) > 0: for flow in json_body["referenced_by_flows"]: flow = await self.Flow(request, flow) - if flow.has_any(orig_auth_classes): + if flow.has_any(orig_flow_auth_classes): filtered_referenced_by_flows.append(flow) else: filtered_referenced_by_flows = json_body["referenced_by_flows"] - json_body["referenced_by_flows"] = filtered_referenced_by_flows + json_body["get_urls"] = await self.filtered_get_urls( + request, + json_body["get_urls"], + orig_storage_backend_auth_classes, + orig_verbose_storage_bool) + + if json_body.get("init_object", None): + json_body["init_object"]["get_urls"] = await self.filtered_get_urls( + request, + json_body["init_object"]["get_urls"], + orig_storage_backend_auth_classes, + orig_verbose_storage_bool) + return json_resp(json_body, res.status, cast(dict[str, str], res.headers)) + async def filtered_segments(self, request: Request, groups: list[str]) -> HTTPResponse: + # Initially filter by request's permissions in upstream request + # Returned results will then have the user-specified filters applied + orig_storage_backend_auth_classes_str = request.args.get("storage_backend_tag.auth_classes", "") + orig_storage_backend_auth_classes = ( + orig_storage_backend_auth_classes_str.split(",") if orig_storage_backend_auth_classes_str else []) + request.args["storage_backend_tag.auth_classes"] = ",".join(groups) + + # Set `verbose_storage` to True to include `storage_id` alongside `get_url`s + # If originally false, verbose data will be filtered out before returning + orig_verbose_storage_str = request.args.get("verbose_storage", "false") + orig_verbose_storage_bool = (orig_verbose_storage_str.lower() == "true") + request.args["verbose_storage"] = "true" + + res = await self.passthrough_request(request) + + assert res.body + json_body = json.loads(res.body) + + # Handle user specified filters on `auth_classes` + # + # Note: The following for-loop is quite inefficient and potentially slow. + # While the use of asyncio in this implementation allows for requests to be + # processed in parallel, the items in this for-loop will be processed sequentially + # but without blocking. A complete implementation may wish to parallelise this + # loop. This hasn't been done here to help with readability. + filtered_body = [] + for segment in json_body: + filtered_segment = deepcopy(segment) + filtered_segment["get_urls"] = await self.filtered_get_urls( + request, + segment["get_urls"], + orig_storage_backend_auth_classes, + orig_verbose_storage_bool) + + if filtered_segment.get("init_object", None): + filtered_segment["init_object"]["get_urls"] = await self.filtered_get_urls( + request, + filtered_segment["init_object"]["get_urls"], + orig_storage_backend_auth_classes, + orig_verbose_storage_bool) + + filtered_body.append(filtered_segment) + + return json_resp(filtered_body, res.status, cast(dict[str, str], res.headers)) + + async def filtered_get_urls(self, request: Request, get_urls: dict, groups: list[str], verbose_storage: bool): + if len(groups) > 0: + filtered_get_urls = [] + + # Note: The following for-loop is quite inefficient and potentially slow. + # While the use of asyncio in this implementation allows for requests to be + # processed in parallel, the items in this for-loop will be processed sequentially + # but without blocking. A complete implementation may wish to parallelise this + # loop. This hasn't been done here to help with readability. + for get_url in get_urls: + storage_backend = await self.StorageBackend(request, get_url["storage_id"]) + if storage_backend.has_read(groups): + filtered_get_url = {} + if verbose_storage: + filtered_get_urls.append(get_url) + else: + filtered_get_url = {"url": get_url["url"]} + if "presigned" in get_url: + filtered_get_url["presigned"] = get_url["presigned"] + if "label" in get_url: + filtered_get_url["label"] = get_url["label"] + filtered_get_urls.append(filtered_get_url) + return filtered_get_urls + else: + return get_urls + async def validate_webhook(self, request: Request, groups: list[str]): # Validates webhook based on permissions @@ -369,6 +469,24 @@ def validate_modify_auth_classes(self, new_auth_classes: list[str], groups: list self.has_delete(groups, throw=True) +class StorageBackend(Resource): + def __init__(self, client: AsyncClient, api_url: str, request: Request, storage_id: UUID) -> None: + super().__init__(client, api_url, request) + self.storage_id = storage_id + + async def _async_init(self): + _storage_backends = await self._get_upstream("service/storage-backends") + + self.exists = False + + for _storage_backend in _storage_backends: + if _storage_backend["id"] == self.storage_id: + self.classes = _storage_backend.get("tags", {}).get("auth_classes", []) + break + + return self + + class Flow(Resource): def __init__(self, client: AsyncClient, api_url: str, request: Request, flow_id: UUID) -> None: super().__init__(client, api_url, request)