Skip to content
Open
Show file tree
Hide file tree
Changes from 56 commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
260a1be
Redesign airlock storage account architecture
marrobi Aug 17, 2026
63e8ab0
Merge branch 'main' into copilot/copilotredesign-airlock-storage-acco…
marrobi Aug 17, 2026
d64ec28
Fix airlock v2 defaults, scan verdict persistence, and processor edge…
marrobi Aug 18, 2026
53118a7
Airlock v2: allow API-issued SAS to read import-in-progress for revie…
marrobi Aug 18, 2026
bf205c1
Consolidate airlock v2 changelog into a single enhancement with doc l…
marrobi Aug 18, 2026
0f477db
Store early airlock scan verdict on request instead of container meta…
marrobi Aug 18, 2026
6e7909e
Address PR #5048 review: per-workspace airlock DNS, request migration…
marrobi Aug 18, 2026
963df68
Restore per-workspace airlock SAS signer + version-change guard from …
marrobi Aug 18, 2026
560175f
Finish legacy-airlock guards and address review comments (#5048)
marrobi Aug 18, 2026
19609d4
Version-aware import-review: restore v1 (stalimip) connectivity behin…
marrobi Aug 18, 2026
8d5c8f7
Backfill airlock_version in the startup guard before the block check …
marrobi Aug 18, 2026
d1751f5
Address review: v2 submit file handling, version-change/patch guards,…
marrobi Aug 18, 2026
cf281ac
Address PR #5048 review #4963131058: guard/blob-created/review-worksp…
marrobi Aug 18, 2026
6fc28b6
Fix airlock review #4963547500 + live-found isPrivateLink ABAC bug
marrobi Aug 18, 2026
7a0eb3c
Address suppressed review #4964016031 (K1/K2/K3)
marrobi Aug 18, 2026
8ac8674
Make airlock CHANGELOG entry concise
marrobi Aug 18, 2026
6f9fee5
Add breaking-change note to set enable_legacy_airlock explicitly
marrobi Aug 18, 2026
9c38eba
update changelog
marrobi Aug 18, 2026
a61df5f
Create the airlock SAS signer for every workspace, including manual auth
marrobi Aug 18, 2026
9db39d4
Revert unintended terraform lock file provider bump
marrobi Aug 18, 2026
db13c7d
Correct stale airlock docs/comments after manual-auth v2 support
marrobi Aug 18, 2026
c87fe95
Make the airlock container stage update atomic and stop dead-letterin…
marrobi Aug 18, 2026
413d32d
Delete a workspace's airlock containers when the workspace is deleted
marrobi Aug 18, 2026
8fa83a1
Give core its own DNS zone for the shared global airlock account
marrobi Aug 18, 2026
a304c4f
Remove the App Gateway airlock storage proxy
marrobi Aug 18, 2026
fdc7030
Address review 4965636608: legacy-safe version defaults, retry fields…
marrobi Aug 18, 2026
61c6dc6
Check an airlock request belongs to the workspace in the path
marrobi Aug 18, 2026
0c9651b
Address review feedback: sovereign-cloud signer issuer, airlock_versi…
marrobi Aug 18, 2026
b331528
Make submission file validation authoritative over an early scan verdict
marrobi Aug 19, 2026
47e76ab
Wire ENABLE_LEGACY_AIRLOCK through the core deployment workflow
marrobi Aug 19, 2026
af61381
Refactor airlock logic and documentation for clarity and accuracy
marrobi Aug 19, 2026
53d07d9
Record the airlock scan verdict as a fact and decide the submission o…
marrobi Aug 19, 2026
ac37848
Report submission files only after the container stage is locked
marrobi Aug 19, 2026
b1de06c
Remove airlock_version backfills, the unused import-review identity a…
marrobi Aug 19, 2026
0a9db05
Remove unreachable legacy routing, unused helpers and the dead in-fli…
marrobi Aug 19, 2026
9c2e5f0
Restrict researcher access to export data once it leaves draft
marrobi Aug 19, 2026
0327329
Seal submissions by copying out of a draft container and deleting it
marrobi Aug 19, 2026
b869f0c
Preserve v1 terraform state addresses when gating legacy airlock behi…
marrobi Aug 19, 2026
ea6122c
Enumerate the draft container when validating submitted files
marrobi Aug 19, 2026
57e7613
Add e2e coverage for the draft container seal, file count validation …
marrobi Aug 19, 2026
eb18ef8
Add airlock e2e selector and /test-airlock PR command
marrobi Aug 19, 2026
c37d4f2
Assert in-progress airlock data is refused from the public internet
marrobi Aug 19, 2026
66a9329
Fix late scan verdicts stranding requests and sovereign cloud token e…
marrobi Aug 19, 2026
c9dbcdd
Only block airlock version changes for in-flight requests
marrobi Aug 19, 2026
9b8f709
Gate review on the submitted copy's scan verdict and acknowledge dupl…
marrobi Aug 19, 2026
b8d9369
Fail closed on malformed verdicts and unknown request types, remove d…
marrobi Aug 19, 2026
690162f
Default new workspaces to airlock v2 and stamp pre-v2 workspaces via …
marrobi Aug 20, 2026
a58ced4
Persist late file enumeration results instead of discarding them
marrobi Aug 20, 2026
d9a17db
Cover the malware-scanning-disabled submit path
marrobi Aug 20, 2026
55c10b1
Block airlock version downgrade and abort a copy left pending at timeout
marrobi Aug 20, 2026
ffd0b0b
Fix markdownlint line-length and code-block-style in airlock-legacy docs
marrobi Aug 20, 2026
3633fd2
Correct docs: upgrading a workspace to v2 deletes completed v1 reques…
marrobi Aug 20, 2026
303f85f
Acknowledge file-only step results as facts without republishing a st…
marrobi Aug 20, 2026
aeb69ac
Retry transient processor errors and remove dead terminal-stage skip …
marrobi Aug 20, 2026
f943efb
Tag the airlock workspace-global DNS A record with tre_id
marrobi Aug 20, 2026
21180ea
Include cancelled requests in workspace deletion airlock cleanup
marrobi Aug 20, 2026
2c0ee29
Allow cancelling a Submitted airlock request
marrobi Aug 20, 2026
b6c539b
Reject airlock request creation on v1 workspace when legacy airlock d…
marrobi Aug 20, 2026
509dbd0
Version base workspace as minor 2.11.0 so v1 workspaces can upgrade i…
marrobi Aug 20, 2026
150ff05
Guard blob/event parsing and fix malformed scan-result log statement
marrobi Aug 20, 2026
17749fe
Sync airlock docs with current code: submit seal/immutability, cancel…
marrobi Aug 20, 2026
ee7d3bd
Remove spurious v1 airlock moved blocks for already-counted resources
marrobi Aug 20, 2026
76e6233
Revert Submitted->Cancelled (async ordering race); version import-rev…
marrobi Aug 20, 2026
44f8921
Docs: revert cancel-from-submitted to match reverted transition
marrobi Aug 20, 2026
7928af7
Wrap long markdown lines to satisfy MD013 (400 char limit)
marrobi Aug 20, 2026
41f92fc
Derive workspace airlock_version from template; make legacy defaults …
marrobi Aug 20, 2026
252486a
Airlock robustness + e2e SAS log fixes from PR review
marrobi Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actions/devcontainer_run_command/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ inputs:
description: "If False, Airlock requests will skip the malware scanning stage."
required: false
default: "false"
ENABLE_LEGACY_AIRLOCK:
description: "If True, deploys legacy per-stage airlock storage accounts alongside the consolidated accounts."
required: false
default: "true"
CI_CACHE_ACR_NAME:
description: "A secondary ACR used for caching in CI environments"
required: true
Expand Down Expand Up @@ -297,6 +301,7 @@ runs:
-e TF_VAR_stateful_resources_locked=${{ inputs.STATEFUL_RESOURCES_LOCKED }} \
-e TF_VAR_kv_purge_protection_enabled="${{ inputs.KV_PURGE_PROTECTION_ENABLED }}" \
-e TF_VAR_enable_airlock_malware_scanning=${{ inputs.ENABLE_AIRLOCK_MALWARE_SCANNING }} \
-e TF_VAR_enable_legacy_airlock=${{ inputs.ENABLE_LEGACY_AIRLOCK }} \
-e CI_CACHE_ACR_NAME="${{ inputs.CI_CACHE_ACR_NAME }}" \
-e TF_VAR_core_app_service_plan_sku="${{ (inputs.CORE_APP_SERVICE_PLAN_SKU != ''
&& inputs.CORE_APP_SERVICE_PLAN_SKU) || 'P1v2' }}" \
Expand Down
3 changes: 1 addition & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,7 @@ updates:
update-types: ["version-update:semver-patch"]
open-pull-requests-limit: 0

# Dependabot's Docker manager detects Dockerfile and Containerfile substrings,
# so Dockerfile.tmpl base images are included by this repository-wide glob.
# The Docker manager includes Dockerfile.tmpl files in this repository-wide glob.
- package-ecosystem: "docker"
directories:
- "**/*"
Expand Down
9 changes: 9 additions & 0 deletions .github/scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ async function getCommandFromComment({ core, context, github }) {
break;
}

case "/test-airlock":
{
const runTests = await handleTestCommand({ core, github }, parts, "airlock tests", runId, { number: prNumber, authorUsername: prAuthorUsername, repoOwner, repoName, headSha: prHeadSha, refId: prRefId, details: pr }, { username: commentUsername, link: commentLink });
if (runTests) {
command = "run-tests-airlock";
}
break;
}

case "/test-force-approve":
{
command = "test-force-approve";
Expand Down
11 changes: 11 additions & 0 deletions .github/scripts/build.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,17 @@ describe('getCommandFromComment', () => {
});
});

describe(`for '/test-airlock'`, () => {
test(`should set command to 'run-tests-airlock'`, async () => {
const context = createCommentContext({
username: 'admin',
body: '/test-airlock',
});
await getCommandFromComment({ core, context, github });
expect(outputFor(mockCoreSetOutput, 'command')).toBe('run-tests-airlock');
});
});

describe(`for '/test-backups'`, () => {
test(`should set command to 'run-tests-backups'`, async () => {
const context = createCommentContext({
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy_tre_reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ jobs:
AUTO_GRANT_WORKSPACE_CONSENT: ${{ vars.AUTO_GRANT_WORKSPACE_CONSENT }}
ENABLE_DNS_POLICY: ${{ vars.ENABLE_DNS_POLICY }}
ALLOWED_DNS: ${{ vars.ALLOWED_DNS }}
ENABLE_LEGACY_AIRLOCK: ${{ vars.ENABLE_LEGACY_AIRLOCK || true }}

- name: API Healthcheck
uses: ./.github/actions/devcontainer_run_command
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/pr_comment_bot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ jobs:
needs.pr_comment.outputs.command == 'run-tests-extended' ||
needs.pr_comment.outputs.command == 'run-tests-extended-aad' ||
needs.pr_comment.outputs.command == 'run-tests-shared-services' ||
needs.pr_comment.outputs.command == 'run-tests-backups'
needs.pr_comment.outputs.command == 'run-tests-backups' ||
needs.pr_comment.outputs.command == 'run-tests-airlock'
name: Deploy PR
uses: ./.github/workflows/deploy_tre_reusable.yml
permissions:
Expand All @@ -176,6 +177,7 @@ jobs:
(needs.pr_comment.outputs.command == 'run-tests-extended-aad' && 'extended_aad') ||
(needs.pr_comment.outputs.command == 'run-tests-shared-services' && 'shared_services') ||
(needs.pr_comment.outputs.command == 'run-tests-backups' && 'backups') ||
(needs.pr_comment.outputs.command == 'run-tests-airlock' && 'airlock') ||
(needs.pr_comment.outputs.command == 'run-tests' && '') }}
environmentName: CICD
E2E_TESTS_NUMBER_PROCESSES: 1
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,4 @@ validation.txt

/index.html
.DS_Store
*_old.tf
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
<!-- markdownlint-disable MD041 -->
## (Unreleased)
**BREAKING CHANGES**
* Set `enable_legacy_airlock` explicitly to `true` in your `config.yaml`. It currently defaults to `true` but will default to `false` in a future release;
Setting to `false` will delete existing airlock storage accounts and must only be done once all workspaces use the v2 airlock. ([#5048](https://github.com/microsoft/AzureTRE/pull/5048))

ENHANCEMENTS:
* Redesign Airlock storage to consolidated metadata-based accounts (v2), now the default for new workspaces. Legacy per-stage storage is retained behind `enable_legacy_airlock` (default `true`; sample config sets `false`). Run `POST /migrations` after upgrading to stamp pre-v2 workspaces with `airlock_version=1` so they keep routing to legacy storage.
See [Legacy Airlock & migration](docs/azure-tre-overview/airlock.md#legacy-airlock) ([#5048](https://github.com/microsoft/AzureTRE/pull/5048))
* Add E2E airlock coverage for the draft container seal, file count validation, rejected/cancelled lifecycles and cross-workspace access, runnable via `make test-e2e-airlock` or the `/test-airlock` PR comment ([#5048](https://github.com/microsoft/AzureTRE/pull/5048))

BUG FIXES:
* Emit airlock malware scan verdicts without reading the scanned blob, so a verdict arriving after the draft container is sealed no longer strands the request in `Submitted` ([#5048](https://github.com/microsoft/AzureTRE/pull/5048))
* Use the cloud-specific workload identity token exchange audience so v2 airlock SAS signing works in sovereign clouds ([#5048](https://github.com/microsoft/AzureTRE/pull/5048))

## (0.29.0) (August 14, 2026)
**BREAKING CHANGES**
Expand Down
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,14 @@ test-e2e-backups: ## 🧪 Run E2E backup tests
$(call target_title, "Running E2E backup tests") && \
$(MAKE) test-e2e-custom SELECTOR=backups

# Description: Run E2E airlock tests
# # The E2E airlock tests include:
# # - tests marked with the `airlock` selector that verify airlock import/export flows and their access controls
# Example: make test-e2e-airlock
test-e2e-airlock: ## 🧪 Run E2E airlock tests
$(call target_title, "Running E2E airlock tests") && \
$(MAKE) test-e2e-custom SELECTOR=airlock

# Description: Run E2E tests with custom selector
# Arguments: SELECTOR - the selector to run the tests with
# Example: make test-e2e-custom SELECTOR=smoke
Expand Down
50 changes: 50 additions & 0 deletions airlock_processor/BlobCreatedTrigger/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
from shared_code.blob_operations import get_blob_info_from_topic_and_subject, get_blob_client_from_blob_info


# Only cross-account approval copies complete through BlobCreated events.
V2_STAGE_COMPLETION_MAP = {
constants.STAGE_IMPORT_APPROVED: (constants.STAGE_APPROVAL_INPROGRESS, constants.STAGE_APPROVED),
constants.STAGE_EXPORT_APPROVED: (constants.STAGE_APPROVAL_INPROGRESS, constants.STAGE_APPROVED),
}


def main(msg: func.ServiceBusMessage,
stepResultEvent: func.Out[func.EventGridOutputEvent],
dataDeletionEvent: func.Out[func.EventGridOutputEvent]):
Expand All @@ -23,6 +30,10 @@ def main(msg: func.ServiceBusMessage,
topic = json_body["topic"]
request_id = re.search(r'/blobServices/default/containers/(.*?)/blobs', json_body["subject"]).group(1)

if constants.STORAGE_ACCOUNT_NAME_AIRLOCK_CORE in topic or constants.STORAGE_ACCOUNT_NAME_AIRLOCK_WORKSPACE_GLOBAL in topic:
_handle_v2_blob_created(json_body, topic, request_id, stepResultEvent, dataDeletionEvent)
return

# message originated from in-progress blob creation
if constants.STORAGE_ACCOUNT_NAME_IMPORT_INPROGRESS in topic or constants.STORAGE_ACCOUNT_NAME_EXPORT_INPROGRESS in topic:
try:
Expand Down Expand Up @@ -55,6 +66,9 @@ def main(msg: func.ServiceBusMessage,
elif constants.STORAGE_ACCOUNT_NAME_IMPORT_BLOCKED in topic or constants.STORAGE_ACCOUNT_NAME_EXPORT_BLOCKED in topic:
completed_step = constants.STAGE_BLOCKING_INPROGRESS
new_status = constants.STAGE_BLOCKED_BY_SCAN
else:
logging.warning(f"Unknown storage account in topic: {topic}")
return

# reply with a step completed event
stepResultEvent.set(
Expand All @@ -74,6 +88,9 @@ def send_delete_event(dataDeletionEvent: func.Out[func.EventGridOutputEvent], js
blob_client = get_blob_client_from_blob_info(
*get_blob_info_from_topic_and_subject(topic=json_body["topic"], subject=json_body["subject"]))
blob_metadata = blob_client.get_blob_properties()["metadata"]
if "copied_from" not in blob_metadata:
logging.info(f"Blob for request {request_id} has no copied_from metadata; skipping data deletion event.")
return
copied_from = json.loads(blob_metadata["copied_from"])
logging.info(f"copied from history: {copied_from}")

Expand All @@ -88,3 +105,36 @@ def send_delete_event(dataDeletionEvent: func.Out[func.EventGridOutputEvent], js
data_version=constants.DATA_DELETION_EVENT_DATA_VERSION
)
)


def _handle_v2_blob_created(json_body, topic, request_id, stepResultEvent, dataDeletionEvent):
storage_account_name, _, _ = get_blob_info_from_topic_and_subject(
topic=json_body["topic"], subject=json_body["subject"])

from shared_code.blob_operations_metadata import get_container_metadata
try:
metadata = get_container_metadata(storage_account_name, request_id)
except Exception:
# Retry rather than acknowledge without a StepResult.
logging.exception(f"Could not read container metadata for request {request_id} on {storage_account_name}")
raise

stage = metadata.get('stage', '')
logging.info(f"V2 BlobCreated for request {request_id}: stage={stage}, account={storage_account_name}")

if stage in V2_STAGE_COMPLETION_MAP:
completed_step, new_status = V2_STAGE_COMPLETION_MAP[stage]
logging.info(f"V2 copy completed for request {request_id}: {completed_step} -> {new_status}")

stepResultEvent.set(
func.EventGridOutputEvent(
id=str(uuid.uuid4()),
data={"completed_step": completed_step, "new_status": new_status, "request_id": request_id},
subject=request_id,
event_type="Airlock.StepResult",
event_time=datetime.datetime.now(datetime.UTC),
data_version=constants.STEP_RESULT_EVENT_DATA_VERSION))

send_delete_event(dataDeletionEvent, json_body, request_id)
else:
logging.info(f"V2 BlobCreated for non-terminal stage '{stage}' on request {request_id}, no action needed")
31 changes: 14 additions & 17 deletions airlock_processor/ScanResultTrigger/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import uuid
import json
import os
from shared_code import constants, blob_operations, parsers
from shared_code import constants, blob_operations, parsers, airlock_storage_helper


def main(msg: func.ServiceBusMessage,
Expand All @@ -14,7 +14,6 @@ def main(msg: func.ServiceBusMessage,
logging.info("Python ServiceBus queue trigger processed message - Malware scan result arrived!")
body = msg.get_body().decode('utf-8')
logging.info(f'Python ServiceBus queue trigger processed message: {body}')
status_message = None

try:
enable_malware_scanning = parsers.parse_bool(os.environ["ENABLE_MALWARE_SCANNING"])
Expand All @@ -39,24 +38,22 @@ def main(msg: func.ServiceBusMessage,
raise e

# Extract request id
_, request_id, _ = blob_operations.get_blob_info_from_blob_url(blob_url=blob_uri)

# If clean, we can continue and move the request to the review stage
# Otherwise, move the request to the blocked stage
completed_step = constants.STAGE_SUBMITTED
if verdict == constants.NO_THREATS:
logging.info(f'No malware were found in request id {request_id}, moving to {constants.STAGE_IN_REVIEW} stage')
new_status = constants.STAGE_IN_REVIEW
else:
logging.info(f'Malware was found in request id {request_id}, moving to {constants.STAGE_BLOCKING_INPROGRESS} stage')
new_status = constants.STAGE_BLOCKING_INPROGRESS
status_message = verdict

# Send the event to indicate this step is done (and to request a new status change)
account_name, container_name, blob_name = blob_operations.get_blob_info_from_blob_url(blob_url=blob_uri)
request_id = airlock_storage_helper.get_request_id_from_container_name(container_name)

# The draft container stays writable until submission, so its verdict may describe content that
# was later replaced. Only the sealed copy's verdict describes the data actually under review.
# Only v2 creates -draft containers, so the suffix identifies them without depending on account names.
if container_name.endswith(constants.DRAFT_CONTAINER_SUFFIX):
logging.info(f'Scan result for draft blob in request {request_id} ignored; the submitted copy gates review.')
return

# The verdict is reported as a fact; the API decides the status once submission is validated.
outputEvent.set(
func.EventGridOutputEvent(
id=str(uuid.uuid4()),
data={"completed_step": completed_step, "new_status": new_status, "request_id": request_id, "status_message": status_message},
data={"completed_step": constants.STAGE_SUBMITTED, "request_id": request_id,
"scan_result": {"clean": verdict == constants.NO_THREATS, "message": None if verdict == constants.NO_THREATS else verdict}},
subject=request_id,
event_type="Airlock.StepResult",
event_time=datetime.datetime.now(datetime.UTC),
Expand Down
Loading
Loading