Repository: microsoft/AzureTRE (python)

Issue:
Button to revoke access to previously authorised Airlock export request.
<!--
Note: This is for requesting a new piece of functionality (feature), please use User story for smaller enhancements to existing functionality
-->

**Is your feature request related to a problem? Please describe.**
We had an instance where an Airlock request was accidentally approved due to a stray mouse click.
In response we edited Cosmos DB to change the status to rejected to prevent the data being downloaded.

**Describe the solution you'd like**
Two things can help.

*Mitigate stray mouse clicks*
A confirmation box, or a delay (~3-5s) in the GUI prior to committing the action for the Airlock reviewer to recognise a problem and correct it immediately.
In the case of the delay, a button should be available to edit the request before sending.
A confirmation box may be the easier solution.

*Mitigate unintended data egress*
A "revoke" button in the GUI for all authorised airlock requests.
This can be used by an Airlock reviewer to mitigate damage following a mistake or a change in circumstances requiring the data be pulled.
The option to revoke should exist for all time (without expiry) as mistakes may not be noticed immediately, or circumstances may change after some time.

I suggest "Revoked" (or similar) as the status, instead of re-using rejected, to be clear that the request was at some point available and may have been downloaded.

Once revoked, the URL links to download the files should be removed from the GUI and existing links invalidated.

**Describe alternatives you've considered**
All ideas have been presented.

**Additional context**
No.

Pull request:
Add revoke functionality and confirmation dialogs for Airlock requests
This PR implements functionality to revoke approved Airlock export requests and adds confirmation dialogs to prevent accidental approvals, addressing security concerns around unintended data egress.

## Changes Made

### 🛡️ Accidental Click Prevention
- **Confirmation dialogs** for approve/reject actions in the review interface
- Clear warning messages explaining consequences before committing actions
- Prevents stray mouse clicks from accidentally approving data export requests

### 🔄 Revoke Functionality  
- **New "Revoked" status** distinct from "Rejected" to clearly indicate requests were previously available
- **Revoke button** available for approved requests without expiry (mistakes may not be noticed immediately)
- **Role-based access**: Only AirlockManagers can revoke approved requests
- **API endpoint**: `POST /workspaces/{workspace_id}/requests/{airlock_request_id}/revoke`

### 🔒 Security & Data Protection
- **Link invalidation**: Revoked requests are excluded from valid download statuses
- **One-way operation**: Revoked requests cannot be re-approved or modified
- **Status validation**: Robust logic prevents invalid state transitions

## Technical Implementation

**Backend (API)**:
- Added `Revoked` status to `AirlockRequestStatus` enum
- Added `Revoke` action to `AirlockActions` enum  
- Updated status validation logic to allow `Approved → Revoked` transition only
- New `revoke_request` service function and API endpoint
- Updated `get_allowed_actions` to include revoke for AirlockManagers on approved requests

**Frontend (UI)**:
- Added confirmation dialogs in `AirlockReviewRequest.tsx` for approve/reject actions
- Added revoke button and confirmation dialog in `AirlockViewRequest.tsx`
- Updated TypeScript models with new status and action
- Revoke button only appears for approved requests when user has AirlockManager role

**Testing**:
- Updated test status change mappings to include revoked transitions
- Comprehensive validation of status transition logic
- Role-based permission verification

## Example Usage

```typescript
// Confirmation dialog prevents accidental actions
onClick={() => setShowApproveConfirmation(true)} // Instead of direct approval

// Revoke button for approved requests  
{request.allowedUserActions?.includes(AirlockRequestAction.Revoke) && (
  <DefaultButton onClick={() => setHideRevokeDialog(false)}>
    Revoke
  </DefaultButton>
)}
```

## Files Changed
- **Backend**: 8 files (domain models, repositories, services, API routes, tests)
- **Frontend**: 2 files (review and view components)

Fixes #4543.

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29e4050c..0d4eec28 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
 **BREAKING CHANGES & MIGRATIONS**:
 
 ENHANCEMENTS:
+* Add revoke functionality and confirmation dialogs for Airlock requests ([#4543](https://github.com/microsoft/AzureTRE/issues/4543))
 * Migrate Azure Firewall and Route Tables to Core Terraform ([#4342](https://github.com/microsoft/AzureTRE/pull/4342))
 * Added backup vault to base workspace & updated Azurerm provider to match core. ([[#4362](https://github.com/microsoft/AzureTRE/issues/4362)])
 
diff --git a/api_app/_version.py b/api_app/_version.py
index a304c373..2743a504 100644
--- a/api_app/_version.py
+++ b/api_app/_version.py
@@ -1,1 +1,1 @@
-__version__ = "0.24.3"
+__version__ = "0.24.4"
diff --git a/api_app/api/routes/airlock.py b/api_app/api/routes/airlock.py
index 7aefa62b..b6a6deef 100644
--- a/api_app/api/routes/airlock.py
+++ b/api_app/api/routes/airlock.py
@@ -17,7 +17,7 @@ from api.dependencies.airlock import get_airlock_request_by_id_from_path
 from models.domain.airlock_request import AirlockRequestStatus, AirlockRequestType
 from models.schemas.airlock_request_url import AirlockRequestTokenInResponse
 from models.schemas.airlock_request import AirlockRequestAndOperationInResponse, AirlockRequestInCreate, AirlockRequestWithAllowedUserActions, \
-    AirlockRequestWithAllowedUserActionsInList, AirlockReviewInCreate
+    AirlockRequestWithAllowedUserActionsInList, AirlockReviewInCreate, AirlockRevokeInCreate
 from resources import strings
 from services.authentication import get_current_workspace_owner_or_researcher_user_or_airlock_manager, \
     get_current_workspace_owner_or_researcher_user, get_current_airlock_manager_user
@@ -25,7 +25,7 @@ from services.authentication import get_current_workspace_owner_or_researcher_us
 from .resource_helpers import construct_location_header
 
 from services.airlock import create_review_vm, review_airlock_request, get_airlock_container_link, get_allowed_actions, save_and_publish_event_airlock_request, update_and_publish_event_airlock_request, \
-    enrich_requests_with_allowed_actions, get_airlock_requests_by_user_and_workspace, cancel_request
+    enrich_requests_with_allowed_actions, get_airlock_requests_by_user_and_workspace, cancel_request, revoke_request
 from services.logging import logger
 
 airlock_workspace_router = APIRouter(dependencies=[Depends(get_current_workspace_owner_or_researcher_user_or_airlock_manager)])
@@ -113,6 +113,19 @@ async def create_cancel_request(airlock_request=Depends(get_airlock_request_by_i
     return AirlockRequestWithAllowedUserActions(airlockRequest=updated_request, allowedUserActions=allowed_actions)
 
 
+@airlock_workspace_router.post("/workspaces/{workspace_id}/requests/{airlock_request_id}/revoke", status_code=status_code.HTTP_200_OK,
+                               response_model=AirlockRequestWithAllowedUserActions, name=strings.API_REVOKE_AIRLOCK_REQUEST,
+                               dependencies=[Depends(get_current_airlock_manager_user), Depends(get_workspace_by_id_from_path)])
+async def create_revoke_request(revoke_input: AirlockRevokeInCreate,
+                                airlock_request=Depends(get_airlock_request_by_id_from_path),
+                                user=Depends(get_current_airlock_manager_user),
+                                workspace=Depends(get_workspace_by_id_from_path),
+                                airlock_request_repo=Depends(get_repository(AirlockRequestRepository))) -> AirlockRequestWithAllowedUserActions:
+    updated_request = await revoke_request(airlock_request, user, workspace, airlock_request_repo, revoke_input.reason)
+    allowed_actions = get_allowed_actions(updated_request, user, airlock_request_repo)
+    return AirlockRequestWithAllowedUserActions(airlockRequest=updated_request, allowedUserActions=allowed_actions)
+
+
 @airlock_workspace_router.post("/workspaces/{workspace_id}/requests/{airlock_request_id}/review-user-resource",
                                status_code=status_code.HTTP_202_ACCEPTED, response_model=AirlockRequestAndOperationInResponse,
                                name=strings.API_CREATE_AIRLOCK_REVIEW_USER_RESOURCE,
diff --git a/api_app/db/repositories/airlock_requests.py b/api_app/db/repositories/airlock_requests.py
index 8bb8275a..b6d5cbd8 100644
--- a/api_app/db/repositories/airlock_requests.py
+++ b/api_app/db/repositories/airlock_requests.py
@@ -1,7 +1,7 @@
 import copy
 import uuid
 
-from datetime import datetime
+from datetime import datetime, timezone
 from typing import List, Optional
 from pydantic import UUID4
 from azure.cosmos.exceptions import CosmosResourceNotFoundError, CosmosAccessConditionFailedError
@@ -32,7 +32,7 @@ class AirlockRequestRepository(BaseRepository):
         return {"tre_id": config.TRE_ID}
 
     def get_timestamp(self) -> float:
-        return datetime.utcnow().timestamp()
+        return datetime.now(timezone.utc).timestamp()
 
     async def update_airlock_request_item(self, original_request: AirlockRequest, new_request: AirlockRequest, updated_by: User, request_properties: dict) -> AirlockRequest:
         history_item = AirlockRequestHistoryItem(
@@ -55,38 +55,52 @@ class AirlockRequestRepository(BaseRepository):
     def airlock_requests_query():
         return 'SELECT * FROM c'
 
-    def validate_status_update(self, current_status: AirlockRequestStatus, new_status: AirlockRequestStatus):
-        # Cannot change status from approved
-        approved_condition = current_status != AirlockRequestStatus.Approved
-        # Cannot change status from rejected
-        rejected_condition = current_status != AirlockRequestStatus.Rejected
-        # Cannot change status from blocked
-        blocked_condition = current_status != AirlockRequestStatus.Blocked
-
-        # If approved-in-progress can only be changed to approved
-        approved_in_progress_condition = current_status == AirlockRequestStatus.ApprovalInProgress and new_status == AirlockRequestStatus.Approved
-        # If rejection-in-progress can only be changed to rejected
-        rejected_in_progress_condition = current_status == AirlockRequestStatus.RejectionInProgress and new_status == AirlockRequestStatus.Rejected
-        # If blocking-in-progress can only be changed to blocked
-        blocking_in_progress_condition = current_status == AirlockRequestStatus.BlockingInProgress and new_status == AirlockRequestStatus.Blocked
-
-        # If draft can only be changed to submitted
-        draft_condition = current_status == AirlockRequestStatus.Draft and new_status == AirlockRequestStatus.Submitted
-        # If submitted needs to get scanned first, which will change the status to either in-review or blocking-in-progress. but if scanner is disabled, it can go straight to in review
-        submit_condition = current_status == AirlockRequestStatus.Submitted and (new_status == AirlockRequestStatus.InReview or new_status == AirlockRequestStatus.BlockingInProgress)
-        # If in review can only be changed to either approve in progress or rejected in progress
-        in_review_condition = current_status == AirlockRequestStatus.InReview and (new_status == AirlockRequestStatus.ApprovalInProgress or new_status == AirlockRequestStatus.RejectionInProgress)
-        # Cancel is allowed only if the request is not actively changing, i.e. it is currently in draft or in review
-        cancel_condition = (current_status == AirlockRequestStatus.Draft or current_status == AirlockRequestStatus.InReview) and new_status == AirlockRequestStatus.Cancelled
-        # Failed is allowed from any non-final status
-        failed_condition = (current_status == AirlockRequestStatus.Draft
-                            or current_status == AirlockRequestStatus.Submitted
-                            or current_status == AirlockRequestStatus.InReview
-                            or current_status == AirlockRequestStatus.ApprovalInProgress
-                            or current_status == AirlockRequestStatus.RejectionInProgress
-                            or current_status == AirlockRequestStatus.BlockingInProgress) and new_status == AirlockRequestStatus.Failed
-
-        return approved_condition and rejected_condition and blocked_condition and (approved_in_progress_condition or rejected_in_progress_condition or blocking_in_progress_condition or draft_condition or submit_condition or in_review_condition or cancel_condition or failed_condition)
+    def validate_status_update(self, current_status: AirlockRequestStatus, new_status: AirlockRequestStatus) -> bool:
+
+        # Define valid transitions
+        valid_transitions = {
+            AirlockRequestStatus.Draft: {
+                AirlockRequestStatus.Submitted,
+                AirlockRequestStatus.Cancelled,
+                AirlockRequestStatus.Failed
+            },
+            AirlockRequestStatus.Submitted: {
+                AirlockRequestStatus.InReview,
+                AirlockRequestStatus.BlockingInProgress,
+                AirlockRequestStatus.Failed
+            },
+            AirlockRequestStatus.InReview: {
+                AirlockRequestStatus.ApprovalInProgress,
+                AirlockRequestStatus.RejectionInProgress,
+                AirlockRequestStatus.Cancelled,
+                AirlockRequestStatus.Failed
+            },
+            AirlockRequestStatus.ApprovalInProgress: {
+                AirlockRequestStatus.Approved,
+                AirlockRequestStatus.Failed
+            },
+            AirlockRequestStatus.RejectionInProgress: {
+                AirlockRequestStatus.Rejected,
+                AirlockRequestStatus.Failed
+            },
+            AirlockRequestStatus.BlockingInProgress: {
+                AirlockRequestStatus.Blocked,
+                AirlockRequestStatus.Failed
+            },
+            AirlockRequestStatus.Approved: {
+                AirlockRequestStatus.Revoked
+            },
+            # Final states - no transitions allowed
+            AirlockRequestStatus.Rejected: set(),
+            AirlockRequestStatus.Blocked: set(),
+            AirlockRequestStatus.Cancelled: set(),
+            AirlockRequestStatus.Failed: set(),
+            AirlockRequestStatus.Revoked: set()
+        }
+
+        # Check if the transition is valid
+        allowed_transitions = valid_transitions.get(current_status, set())
+        return new_status in allowed_transitions
 
     def create_airlock_request_item(self, airlock_request_input: AirlockRequestInCreate, workspace_id: str, user) -> AirlockRequest:
         full_airlock_request_id = str(uuid.uuid4())
@@ -211,6 +225,19 @@ class AirlockRequestRepository(BaseRepository):
 
         return airlock_review
 
+    def create_airlock_revoke_review_item(self, revocation_reason: str, reviewer: User) -> AirlockReview:
+        full_airlock_review_id = str(uuid.uuid4())
+
+        airlock_review = AirlockReview(
+            id=full_airlock_review_id,
+            dateCreated=self.get_timestamp(),
+            reviewDecision=AirlockReviewDecision.Revoked,
+            decisionExplanation=revocation_reason,
+            reviewer=reviewer
+        )
+
+        return airlock_review
+
     def _build_updated_request(
             self,
             original_request: AirlockRequest,
diff --git a/api_app/models/domain/airlock_request.py b/api_app/models/domain/airlock_request.py
index 154dbc78..37fe67f6 100644
--- a/api_app/models/domain/airlock_request.py
+++ b/api_app/models/domain/airlock_request.py
@@ -22,6 +22,7 @@ class AirlockRequestStatus(StrEnum):
     Blocked = strings.AIRLOCK_RESOURCE_STATUS_BLOCKED
     BlockingInProgress = strings.AIRLOCK_RESOURCE_STATUS_BLOCKING_INPROGRESS
     Failed = strings.AIRLOCK_RESOURCE_STATUS_FAILED
+    Revoked = strings.AIRLOCK_RESOURCE_STATUS_REVOKED
 
 
 class AirlockRequestType(StrEnum):
@@ -33,6 +34,7 @@ class AirlockActions(StrEnum):
     Review = strings.AIRLOCK_ACTION_REVIEW
     Cancel = strings.AIRLOCK_ACTION_CANCEL
     Submit = strings.AIRLOCK_ACTION_SUBMIT
+    Revoke = strings.AIRLOCK_ACTION_REVOKE
 
 
 class AirlockFile(AzureTREModel):
@@ -43,6 +45,7 @@ class AirlockFile(AzureTREModel):
 class AirlockReviewDecision(StrEnum):
     Approved = strings.AIRLOCK_REVIEW_DECISION_APPROVED
     Rejected = strings.AIRLOCK_REVIEW_DECISION_REJECTED
+    Revoked = strings.AIRLOCK_REVIEW_DECISION_REVOKED
 
 
 class AirlockReview(AzureTREModel):
diff --git a/api_app/models/schemas/airlock_request.py b/api_app/models/schemas/airlock_request.py
index 2500b246..0cc1ccbe 100644
--- a/api_app/models/schemas/airlock_request.py
+++ b/api_app/models/schemas/airlock_request.py
@@ -1,5 +1,5 @@
 import uuid
-from datetime import datetime
+from datetime import datetime, timezone
 from typing import List
 from pydantic import BaseModel, Field
 from models.domain.operation import Operation
@@ -28,7 +28,7 @@ def get_sample_airlock_request(workspace_id: str, airlock_request_id: str) -> di
             "id": "a user id",
             "name": "a user name"
         },
-        "createdWhen": datetime.utcnow().timestamp(),
+        "createdWhen": datetime.now(timezone.utc).timestamp(),
         "reviews": [
             get_sample_airlock_review("29990431-5451-40e7-a58a-02e2b7c3d7c8"),
             get_sample_airlock_review("02dc0f29-351a-43ec-87e7-3dd2b5177b7f")]
@@ -117,3 +117,14 @@ class AirlockReviewInCreate(BaseModel):
                 "decisionExplanation": "the reason why this request was approved/rejected"
             }
         }
+
+
+class AirlockRevokeInCreate(BaseModel):
+    reason: str = Field(title="Reason for revoking the approved request")
+
+    class Config:
+        schema_extra = {
+            "example": {
+                "reason": "Request was approved in error or security concerns identified"
+            }
+        }
diff --git a/api_app/resources/strings.py b/api_app/resources/strings.py
index d6dcff73..40002385 100644
--- a/api_app/resources/strings.py
+++ b/api_app/resources/strings.py
@@ -46,6 +46,7 @@ API_LIST_AIRLOCK_REQUESTS = "Get all airlock requests for a workspace"
 API_SUBMIT_AIRLOCK_REQUEST = "Submit an airlock request"
 API_CANCEL_AIRLOCK_REQUEST = "Cancel an airlock request"
 API_REVIEW_AIRLOCK_REQUEST = "Review an airlock request"
+API_REVOKE_AIRLOCK_REQUEST = "Revoke an airlock request"
 API_AIRLOCK_REQUEST_LINK = "Get a token to access airlock request"
 API_CREATE_AIRLOCK_REVIEW_USER_RESOURCE = "Create an Airlock Review User Resource"
 
@@ -215,6 +216,7 @@ AIRLOCK_RESOURCE_STATUS_CANCELLED = "cancelled"
 AIRLOCK_RESOURCE_STATUS_BLOCKING_INPROGRESS = "blocking_in_progress"
 AIRLOCK_RESOURCE_STATUS_BLOCKED = "blocked_by_scan"
 AIRLOCK_RESOURCE_STATUS_FAILED = "failed"
+AIRLOCK_RESOURCE_STATUS_REVOKED = "revoked"
 
 # Airlock Request Types
 AIRLOCK_REQUEST_TYPE_IMPORT = "import"
@@ -236,10 +238,12 @@ AIRLOCK_NO_AIRLOCK_MANAGER_EMAIL = "There are no Airlock Managers in the workspa
 AIRLOCK_ACTION_REVIEW = "review"
 AIRLOCK_ACTION_CANCEL = "cancel"
 AIRLOCK_ACTION_SUBMIT = "submit"
+AIRLOCK_ACTION_REVOKE = "revoke"
 
 # Airlock Review Decisions
 AIRLOCK_REVIEW_DECISION_APPROVED = "approved"
 AIRLOCK_REVIEW_DECISION_REJECTED = "rejected"
+AIRLOCK_REVIEW_DECISION_REVOKED = "revoked"
 
 # Deployments
 RESOURCE_STATUS_AWAITING_DEPLOYMENT_MESSAGE = "This resource is waiting to be deployed"
diff --git a/api_app/services/airlock.py b/api_app/services/airlock.py
index ab4c6a98..36988f9d 100644
--- a/api_app/services/airlock.py
+++ b/api_app/services/airlock.py
@@ -86,7 +86,7 @@ def validate_request_status(airlock_request: AirlockRequest):
                                   AirlockRequestStatus.RejectionInProgress,
                                   AirlockRequestStatus.BlockingInProgress]:
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=strings.AIRLOCK_REQUEST_IN_PROGRESS)
-    elif airlock_request.status == AirlockRequestStatus.Cancelled:
+    elif airlock_request.status in [AirlockRequestStatus.Cancelled, AirlockRequestStatus.Revoked]:
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=strings.AIRLOCK_REQUEST_IS_CANCELED)
     elif airlock_request.status in [AirlockRequestStatus.Failed,
                                     AirlockRequestStatus.Rejected,
@@ -366,6 +366,7 @@ def get_allowed_actions(request: AirlockRequest, user: User, airlock_request_rep
     can_review_request = airlock_request_repo.validate_status_update(request.status, AirlockRequestStatus.ApprovalInProgress)
     can_cancel_request = airlock_request_repo.validate_status_update(request.status, AirlockRequestStatus.Cancelled)
     can_submit_request = airlock_request_repo.validate_status_update(request.status, AirlockRequestStatus.Submitted)
+    can_revoke_request = airlock_request_repo.validate_status_update(request.status, AirlockRequestStatus.Revoked)
 
     if can_review_request and "AirlockManager" in user.roles:
         allowed_actions.append(AirlockActions.Review)
@@ -376,6 +377,9 @@ def get_allowed_actions(request: AirlockRequest, user: User, airlock_request_rep
     if can_submit_request and ("WorkspaceOwner" in user.roles or "WorkspaceResearcher" in user.roles):
         allowed_actions.append(AirlockActions.Submit)
 
+    if can_revoke_request and "AirlockManager" in user.roles:
+        allowed_actions.append(AirlockActions.Revoke)
+
     return allowed_actions
 
 
@@ -470,5 +474,24 @@ async def cancel_request(airlock_request: AirlockRequest, user: User, workspace:
     return updated_request
 
 
+async def revoke_request(airlock_request: AirlockRequest, user: User, workspace: Workspace,
+                         airlock_request_repo: AirlockRequestRepository, revocation_reason: str) -> AirlockRequest:
+    """
+    Revoke an approved airlock request. This prevents new download links from being generated.
+    """
+    # Create a review entry for the revocation
+    revoke_review = airlock_request_repo.create_airlock_revoke_review_item(revocation_reason, user)
+
+    updated_request = await update_and_publish_event_airlock_request(
+        airlock_request=airlock_request,
+        airlock_request_repo=airlock_request_repo,
+        updated_by=user,
+        workspace=workspace,
+        new_status=AirlockRequestStatus.Revoked,
+        airlock_review=revoke_review
+    )
+    return updated_request
+
+
 def _user_has_one_of_roles(user: User, roles) -> bool:
     return any(role in roles for role in user.roles)
diff --git a/ui/app/package.json b/ui/app/package.json
index 4f1be1b0..e861898f 100644
--- a/ui/app/package.json
+++ b/ui/app/package.json
@@ -1,6 +1,6 @@
 {
   "name": "tre-ui",
-  "version": "0.8.10",
+  "version": "0.8.11",
   "private": true,
   "type": "module",
   "dependencies": {
diff --git a/ui/app/src/components/shared/airlock/AirlockReviewRequest.tsx b/ui/app/src/components/shared/airlock/AirlockReviewRequest.tsx
index 4dc62213..51ca61d8 100644
--- a/ui/app/src/components/shared/airlock/AirlockReviewRequest.tsx
+++ b/ui/app/src/components/shared/airlock/AirlockReviewRequest.tsx
@@ -1,5 +1,6 @@
 import {
   DefaultButton,
+  Dialog,
   DialogFooter,
   FontWeights,
   getTheme,
@@ -71,6 +72,8 @@ export const AirlockReviewRequest: React.FunctionComponent<
   const [reviewResource, setReviewResource] = useState<UserResource>();
   const [reviewWorkspaceScope, setReviewWorkspaceScope] = useState<string>();
   const [otherReviewers, setOtherReviewers] = useState<Array<string>>();
+  const [showApproveConfirmation, setShowApproveConfirmation] = useState(false);
+  const [showRejectConfirmation, setShowRejectConfirmation] = useState(false);
   const workspaceCtx = useContext(WorkspaceContext);
   const apiCall = useAuthApiCall();
   const dispatch = useAppDispatch();
@@ -416,14 +419,14 @@ export const AirlockReviewRequest: React.FunctionComponent<
           />
           <DefaultButton
             iconProps={{ iconName: "Cancel" }}
-            onClick={() => reviewRequest(false)}
+            onClick={() => setShowRejectConfirmation(true)}
             text="Reject"
             styles={destructiveButtonStyles}
             disabled={reviewExplanation.length <= 0}
           />
           <DefaultButton
             iconProps={{ iconName: "Accept" }}
-            onClick={() => reviewRequest(true)}
+            onClick={() => setShowApproveConfirmation(true)}
             text="Approve"
             styles={successButtonStyles}
             disabled={reviewExplanation.length <= 0}
@@ -445,6 +448,56 @@ export const AirlockReviewRequest: React.FunctionComponent<
         />
       </div>
       <div className={contentStyles.body}>{currentStep}</div>
+      
+      {/* Approve Confirmation Dialog */}
+      <Dialog
+        hidden={!showApproveConfirmation}
+        onDismiss={() => setShowApproveConfirmation(false)}
+        dialogContentProps={{
+          title: "Approve Airlock Request?",
+          subText: `Are you sure you want to approve "${request?.title}"? This will allow the data to be downloaded.`,
+        }}
+      >
+        <DialogFooter>
+          <PrimaryButton
+            onClick={() => {
+              setShowApproveConfirmation(false);
+              reviewRequest(true);
+            }}
+            text="Yes, Approve"
+            styles={successButtonStyles}
+          />
+          <DefaultButton
+            onClick={() => setShowApproveConfirmation(false)}
+            text="Cancel"
+          />
+        </DialogFooter>
+      </Dialog>
+
+      {/* Reject Confirmation Dialog */}
+      <Dialog
+        hidden={!showRejectConfirmation}
+        onDismiss={() => setShowRejectConfirmation(false)}
+        dialogContentProps={{
+          title: "Reject Airlock Request?",
+          subText: `Are you sure you want to reject "${request?.title}"?`,
+        }}
+      >
+        <DialogFooter>
+          <PrimaryButton
+            onClick={() => {
+              setShowRejectConfirmation(false);
+              reviewRequest(false);
+            }}
+            text="Yes, Reject"
+            styles={destructiveButtonStyles}
+          />
+          <DefaultButton
+            onClick={() => setShowRejectConfirmation(false)}
+            text="Cancel"
+          />
+        </DialogFooter>
+      </Dialog>
     </>
   );
 };
diff --git a/ui/app/src/components/shared/airlock/AirlockViewRequest.tsx b/ui/app/src/components/shared/airlock/AirlockViewRequest.tsx
index e31e3ec7..6c350bb1 100644
--- a/ui/app/src/components/shared/airlock/AirlockViewRequest.tsx
+++ b/ui/app/src/components/shared/airlock/AirlockViewRequest.tsx
@@ -24,6 +24,7 @@ import {
   Spinner,
   SpinnerSize,
   Stack,
+  TextField,
 } from "@fluentui/react";
 import moment from "moment";
 import React, { useCallback, useContext, useEffect, useState } from "react";
@@ -59,6 +60,10 @@ export const AirlockViewRequest: React.FunctionComponent<
   const [submitting, setSubmitting] = useState(false);
   const [submitError, setSubmitError] = useState(false);
   const [hideCancelDialog, setHideCancelDialog] = useState(true);
+  const [hideRevokeDialog, setHideRevokeDialog] = useState(true);
+  const [revoking, setRevoking] = useState(false);
+  const [revokeError, setRevokeError] = useState(false);
+  const [revokeReason, setRevokeReason] = useState("");
   const [apiError, setApiError] = useState({} as APIError);
   const workspaceCtx = useContext(WorkspaceContext);
   const apiCall = useAuthApiCall();
@@ -137,6 +142,30 @@ export const AirlockViewRequest: React.FunctionComponent<
     }
   }, [apiCall, request, props, workspaceCtx.workspaceApplicationIdURI]);
 
+  // Revoke an airlock request
+  const revokeRequest = useCallback(async () => {
+    if (request && request.workspaceId && revokeReason.trim()) {
+      setRevoking(true);
+      setRevokeError(false);
+      try {
+        const response = await apiCall(
+          `${ApiEndpoint.Workspaces}/${request.workspaceId}/${ApiEndpoint.AirlockRequests}/${request.id}/${ApiEndpoint.AirlockRevoke}`,
+          HttpMethod.Post,
+          workspaceCtx.workspaceApplicationIdURI,
+          { reason: revokeReason }
+        );
+        props.onUpdateRequest(response.airlockRequest);
+        setHideRevokeDialog(true);
+        setRevokeReason("");
+      } catch (err: any) {
+        err.userMessage = "Error revoking airlock request";
+        setApiError(err);
+        setRevokeError(true);
+      }
+      setRevoking(false);
+    }
+  }, [apiCall, request, props, workspaceCtx.workspaceApplicationIdURI, revokeReason]);
+
   // Render the panel footer along with buttons that the signed-in user is allowed to see according to the API
   const renderFooter = useCallback(() => {
     let footer = <></>;
@@ -191,6 +220,20 @@ export const AirlockViewRequest: React.FunctionComponent<
                 Review
               </PrimaryButton>
             )}
+            {request.allowedUserActions?.includes(
+              AirlockRequestAction.Revoke,
+            ) && (
+              <DefaultButton
+                onClick={() => {
+                  setRevokeError(false);
+                  setRevokeReason("");
+                  setHideRevokeDialog(false);
+                }}
+                styles={destructiveButtonStyles}
+              >
+                Revoke
+              </DefaultButton>
+            )}
           </div>
         </>
       );
@@ -396,6 +439,17 @@ export const AirlockViewRequest: React.FunctionComponent<
                               Rejected
                             </>
                           )}
+                          {review.reviewDecision ===
+                            AirlockReviewDecision.Revoked && (
+                            <>
+                              <FontIcon
+                                aria-label="Revoked"
+                                iconName="StatusCircleBlock2"
+                                className={revokedIcon}
+                              />
+                              Revoked
+                            </>
+                          )}
                         </div>
                       </DocumentCard>
                     );
@@ -483,6 +537,56 @@ export const AirlockViewRequest: React.FunctionComponent<
             </DialogFooter>
           )}
         </Dialog>
+        <Dialog
+          hidden={hideRevokeDialog}
+          onDismiss={() => {
+            setHideRevokeDialog(true);
+            setRevokeError(false);
+            setRevokeReason("");
+          }}
+          dialogContentProps={{
+            title: "Revoke Airlock Request?",
+            subText: "Are you sure you want to revoke this approved airlock request? This will prevent new download links from being generated, but existing links will remain valid until they expire.",
+          }}
+        >
+          {revokeError && <ExceptionLayout e={apiError} />}
+          {revoking ? (
+            <Spinner
+              label="Revoking..."
+              ariaLive="assertive"
+              labelPosition="top"
+              size={SpinnerSize.large}
+            />
+          ) : (
+            <>
+              <TextField
+                label="Reason for revocation (required)"
+                multiline
+                rows={3}
+                value={revokeReason}
+                onChange={(_, newValue) => setRevokeReason(newValue || "")}
+                placeholder="Please provide a reason for revoking this approved request (e.g., approved in error, security concerns identified)"
+                required
+              />
+              <DialogFooter>
+                <DefaultButton
+                  onClick={revokeRequest}
+                  text="Revoke Request"
+                  styles={destructiveButtonStyles}
+                  disabled={!revokeReason.trim()}
+                />
+                <DefaultButton
+                  onClick={() => {
+                    setHideRevokeDialog(true);
+                    setRevokeError(false);
+                    setRevokeReason("");
+                  }}
+                  text="Cancel"
+                />
+              </DialogFooter>
+            </>
+          )}
+        </Dialog>
         <Modal
           titleAriaId={`title-${request?.id}`}
           isOpen={reviewIsOpen}
@@ -534,6 +638,12 @@ const rejectedIcon = mergeStyles({
   fontSize: 12,
 });
 
+const revokedIcon = mergeStyles({
+  color: palette.orange,
+  marginRight: 5,
+  fontSize: 12,
+});
+
 const modalStyles = mergeStyles({
   display: "flex",
   flexFlow: "column nowrap",
diff --git a/ui/app/src/models/airlock.ts b/ui/app/src/models/airlock.ts
index 338f4158..a23c0b2a 100644
--- a/ui/app/src/models/airlock.ts
+++ b/ui/app/src/models/airlock.ts
@@ -45,6 +45,7 @@ export enum AirlockRequestStatus {
   Submitted = "submitted",
   Cancelled = "cancelled",
   Failed = "failed",
+  Revoked = "revoked",
 }
 
 export interface NewAirlockRequest {
@@ -57,6 +58,7 @@ export enum AirlockRequestAction {
   Cancel = "cancel",
   Submit = "submit",
   Review = "review",
+  Revoke = "revoke",
 }
 
 export const AirlockFilesLinkValidStatus = [
@@ -67,6 +69,7 @@ export const AirlockFilesLinkValidStatus = [
 export enum AirlockReviewDecision {
   Approved = "approved",
   Rejected = "rejected",
+  Revoked = "revoked",
 }
 
 export interface AirlockReview {
diff --git a/ui/app/src/models/apiEndpoints.ts b/ui/app/src/models/apiEndpoints.ts
index c863d2e3..c154a114 100644
--- a/ui/app/src/models/apiEndpoints.ts
+++ b/ui/app/src/models/apiEndpoints.ts
@@ -8,6 +8,7 @@ export enum ApiEndpoint {
   AirlockLink = "link",
   AirlockSubmit = "submit",
   AirlockCancel = "cancel",
+  AirlockRevoke = "revoke",
   AirlockReview = "review",
   AirlockCreateReviewResource = "review-user-resource",
   WorkspaceTemplates = "workspace-templates",


Test patch:
diff --git a/api_app/tests_ma/test_api/test_routes/test_airlock.py b/api_app/tests_ma/test_api/test_routes/test_airlock.py
index 05b2ad91..b96b05b4 100644
--- a/api_app/tests_ma/test_api/test_routes/test_airlock.py
+++ b/api_app/tests_ma/test_api/test_routes/test_airlock.py
@@ -283,6 +283,13 @@ class TestAirlockRoutesThatRequireOwnerOrResearcherRights():
                                                      airlock_request_id=AIRLOCK_REQUEST_ID))
         assert response.status_code == status.HTTP_400_BAD_REQUEST
 
+    @patch("api.routes.airlock.AirlockRequestRepository.read_item_by_id",
+           return_value=sample_airlock_request_object(status=AirlockRequestStatus.Revoked))
+    async def test_get_airlock_container_link_revoked_request_returns_400(self, _, app, client):
+        response = await client.get(app.url_path_for(strings.API_AIRLOCK_REQUEST_LINK, workspace_id=WORKSPACE_ID,
+                                                     airlock_request_id=AIRLOCK_REQUEST_ID))
+        assert response.status_code == status.HTTP_400_BAD_REQUEST
+
     @patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id",
            return_value=sample_workspace(WORKSPACE_ID))
     @patch("api.routes.airlock.AirlockRequestRepository.read_item_by_id", return_value=sample_airlock_request_object(status=AirlockRequestStatus.Approved))
@@ -416,6 +423,43 @@ class TestAirlockRoutesThatRequireAirlockManagerRights():
         assert delete_review_resource_mock.call_count == 1
         assert deploy_resource_mock.call_count == 1
 
+    # [POST] /workspaces/{workspace_id}/requests/{airlock_request_id}/revoke
+    @patch("services.airlock.AirlockRequestRepository.read_item_by_id", return_value=sample_airlock_request_object(status=AirlockRequestStatus.Approved))
+    @patch("services.airlock.update_and_publish_event_airlock_request", return_value=sample_airlock_request_object(status=AirlockRequestStatus.Revoked))
+    async def test_post_revoke_airlock_request_revokes_request_returns_200(self, _, __, app, client):
+        reason_data = {"reason": "Test revocation reason"}
+        response = await client.post(app.url_path_for(strings.API_REVOKE_AIRLOCK_REQUEST, workspace_id=WORKSPACE_ID, airlock_request_id=AIRLOCK_REQUEST_ID), json=reason_data)
+        assert response.status_code == status.HTTP_200_OK
+        assert response.json()["airlockRequest"]["id"] == AIRLOCK_REQUEST_ID
+        assert response.json()["airlockRequest"]["status"] == AirlockRequestStatus.Revoked
+
+    @patch("services.airlock.AirlockRequestRepository.read_item_by_id", side_effect=EntityDoesNotExist)
+    async def test_post_revoke_airlock_request_if_request_not_found_returns_404(self, _, app, client):
+        reason_data = {"reason": "Test revocation reason"}
+        response = await client.post(app.url_path_for(strings.API_REVOKE_AIRLOCK_REQUEST, workspace_id=WORKSPACE_ID, airlock_request_id=AIRLOCK_REQUEST_ID), json=reason_data)
+        assert response.status_code == status.HTTP_404_NOT_FOUND
+
+    @patch("services.airlock.AirlockRequestRepository.read_item_by_id", return_value=sample_airlock_request_object(status=AirlockRequestStatus.Approved))
+    @patch("services.airlock.AirlockRequestRepository.validate_status_update", return_value=False)
+    async def test_post_revoke_airlock_request_with_illegal_status_change_returns_400(self, _, __, app, client):
+        reason_data = {"reason": "Test revocation reason"}
+        response = await client.post(app.url_path_for(strings.API_REVOKE_AIRLOCK_REQUEST, workspace_id=WORKSPACE_ID, airlock_request_id=AIRLOCK_REQUEST_ID), json=reason_data)
+        assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+    @patch("services.airlock.AirlockRequestRepository.read_item_by_id", return_value=sample_airlock_request_object(status=AirlockRequestStatus.Approved))
+    @patch("services.airlock.AirlockRequestRepository.update_airlock_request")
+    @patch("services.airlock.AirlockRequestRepository.delete_item")
+    @patch("event_grid.event_sender.send_status_changed_event", side_effect=HttpResponseError)
+    async def test_post_revoke_airlock_request_with_event_grid_not_responding_returns_503(self, _, __, ___, ____, app, client):
+        reason_data = {"reason": "Test revocation reason"}
+        response = await client.post(app.url_path_for(strings.API_REVOKE_AIRLOCK_REQUEST, workspace_id=WORKSPACE_ID, airlock_request_id=AIRLOCK_REQUEST_ID), json=reason_data)
+        assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
+
+    @patch("services.airlock.AirlockRequestRepository.read_item_by_id", return_value=sample_airlock_request_object(status=AirlockRequestStatus.Approved))
+    async def test_post_revoke_airlock_request_missing_reason_returns_422(self, _, app, client):
+        response = await client.post(app.url_path_for(strings.API_REVOKE_AIRLOCK_REQUEST, workspace_id=WORKSPACE_ID, airlock_request_id=AIRLOCK_REQUEST_ID), json={})
+        assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+
 
 class TestAirlockRoutesPermissions():
 
diff --git a/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py b/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py
index 92ae8b7a..c12fd882 100644
--- a/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py
+++ b/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py
@@ -28,6 +28,7 @@ CANCELLED = AirlockRequestStatus.Cancelled
 BLOCKING_IN_PROGRESS = AirlockRequestStatus.BlockingInProgress
 BLOCKED = AirlockRequestStatus.Blocked
 FAILED = AirlockRequestStatus.Failed
+REVOKED = AirlockRequestStatus.Revoked
 
 ALL_STATUSES = [enum.value for enum in AirlockRequestStatus]
 
@@ -36,13 +37,14 @@ ALLOWED_STATUS_CHANGES = {
     SUBMITTED: [IN_REVIEW, BLOCKING_IN_PROGRESS, FAILED],
     IN_REVIEW: [APPROVED_IN_PROGRESS, REJECTION_IN_PROGRESS, CANCELLED, FAILED],
     APPROVED_IN_PROGRESS: [APPROVED, FAILED],
-    APPROVED: [],
+    APPROVED: [REVOKED],
     REJECTION_IN_PROGRESS: [REJECTED, FAILED],
     REJECTED: [],
     CANCELLED: [],
     BLOCKING_IN_PROGRESS: [BLOCKED, FAILED],
     BLOCKED: [],
     FAILED: [],
+    REVOKED: [],
 }
 
 
diff --git a/api_app/tests_ma/test_services/test_airlock.py b/api_app/tests_ma/test_services/test_airlock.py
index 71d9d074..31cb6a00 100644
--- a/api_app/tests_ma/test_services/test_airlock.py
+++ b/api_app/tests_ma/test_services/test_airlock.py
@@ -4,7 +4,7 @@ import pytest_asyncio
 import time
 from resources import strings
 from services.airlock import validate_user_allowed_to_access_storage_account, get_required_permission, \
-    validate_request_status, cancel_request, delete_review_user_resource, check_email_exists
+    validate_request_status, cancel_request, delete_review_user_resource, check_email_exists, revoke_request
 from models.domain.airlock_request import AirlockRequest, AirlockRequestStatus, AirlockRequestType, AirlockReview, AirlockReviewDecision, AirlockActions, AirlockReviewUserResource
 from tests_ma.test_api.conftest import create_workspace_owner_user, create_workspace_researcher_user, get_required_roles
 from mock import AsyncMock, patch, MagicMock
@@ -16,7 +16,7 @@ from db.repositories.airlock_requests import AirlockRequestRepository
 from models.domain.workspace import Workspace
 from tests_ma.test_api.conftest import create_test_user, create_workspace_airlock_manager_user
 from azure.eventgrid import EventGridEvent
-from api.routes.airlock import create_airlock_review, create_cancel_request, create_submit_request
+from api.routes.airlock import create_airlock_review, create_cancel_request, create_submit_request, create_revoke_request
 from services.aad_authentication import AzureADAuthorization
 
 WORKSPACE_ID = "abc000d3-82da-4bfc-b6e9-9a7853ef753e"
@@ -493,7 +493,8 @@ async def test_get_airlock_requests_by_user_and_workspace_with_status_filter_cal
 @pytest.mark.parametrize("action, required_roles, airlock_request_repo_mock", [
     (AirlockActions.Review, get_required_roles(endpoint=create_airlock_review), airlock_request_repo_mock),
     (AirlockActions.Cancel, get_required_roles(endpoint=create_cancel_request), airlock_request_repo_mock),
-    (AirlockActions.Submit, get_required_roles(endpoint=create_submit_request), airlock_request_repo_mock)])
+    (AirlockActions.Submit, get_required_roles(endpoint=create_submit_request), airlock_request_repo_mock),
+    (AirlockActions.Revoke, get_required_roles(endpoint=create_revoke_request), airlock_request_repo_mock)])
 async def test_get_allowed_actions_requires_same_roles_as_endpoint(action, required_roles, airlock_request_repo_mock):
     airlock_request_repo_mock.validate_status_update = MagicMock(return_value=True)
     user = create_test_user()
@@ -507,7 +508,8 @@ async def test_get_allowed_actions_requires_same_roles_as_endpoint(action, requi
 @pytest.mark.parametrize("action, endpoint_roles, airlock_request_repo_mock", [
     (AirlockActions.Review, get_required_roles(endpoint=create_airlock_review), airlock_request_repo_mock),
     (AirlockActions.Cancel, get_required_roles(endpoint=create_cancel_request), airlock_request_repo_mock),
-    (AirlockActions.Submit, get_required_roles(endpoint=create_submit_request), airlock_request_repo_mock)])
+    (AirlockActions.Submit, get_required_roles(endpoint=create_submit_request), airlock_request_repo_mock),
+    (AirlockActions.Revoke, get_required_roles(endpoint=create_revoke_request), airlock_request_repo_mock)])
 async def test_get_allowed_actions_does_not_return_actions_that_are_forbidden_to_the_user_role(action, endpoint_roles, airlock_request_repo_mock):
     airlock_request_repo_mock.validate_status_update = MagicMock(return_value=True)
     user = create_test_user()
@@ -518,6 +520,41 @@ async def test_get_allowed_actions_does_not_return_actions_that_are_forbidden_to
         assert action not in allowed_actions
 
 
+@pytest.mark.asyncio
+@patch("services.airlock.update_and_publish_event_airlock_request")
+async def test_revoke_request_calls_update_with_revoked_status(update_mock, airlock_request_repo_mock):
+    user = create_test_user()
+    workspace = sample_workspace()
+    airlock_request = sample_airlock_request(status=AirlockRequestStatus.Approved)
+    revocation_reason = "Test revocation reason"
+
+    update_mock.return_value = sample_airlock_request(status=AirlockRequestStatus.Revoked)
+    airlock_request_repo_mock.create_airlock_revoke_review_item = MagicMock(return_value=sample_airlock_review())
+
+    result = await revoke_request(
+        airlock_request=airlock_request,
+        user=user,
+        workspace=workspace,
+        airlock_request_repo=airlock_request_repo_mock,
+        revocation_reason=revocation_reason
+    )
+
+    # Verify that a revoke review is created
+    airlock_request_repo_mock.create_airlock_revoke_review_item.assert_called_once_with(revocation_reason, user)
+
+    # Verify update is called with review and status
+    update_mock.assert_called_once()
+    args, kwargs = update_mock.call_args
+    assert kwargs['airlock_request'] == airlock_request
+    assert kwargs['airlock_request_repo'] == airlock_request_repo_mock
+    assert kwargs['updated_by'] == user
+    assert kwargs['workspace'] == workspace
+    assert kwargs['new_status'] == AirlockRequestStatus.Revoked
+    assert kwargs['airlock_review'] is not None
+
+    assert result.status == AirlockRequestStatus.Revoked
+
+
 @pytest.mark.asyncio
 @patch("services.airlock.delete_review_user_resource")
 @patch("services.airlock.update_and_publish_event_airlock_request")