Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion requirements/SHR-APPLICATION-4.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ Requirement type: ENVIRONMENT
## Description

Users shall be able to share access to application runs with other authenticated platform users, and shall be able to
manage (list and revoke) the access grants they have created.
manage (list and revoke) the access grants they have created. Users with whom a run has been shared shall be able to
read that run — its status, results, and metadata — using the access granted to them.
10 changes: 10 additions & 0 deletions requirements/SWR-APPLICATION-4-3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
itemId: SWR-APPLICATION-4-3
itemTitle: Read a Shared Application Run via Share Token
itemHasParent: SHR-APPLICATION-4
itemType: Requirement
Requirement type: FUNCTIONAL
Layer: System (backend logic)
---

System shall enable an authenticated user who holds a share token secret to read an application run shared with them through the CLI, without requiring the run to have been granted to their account directly. The user shall be able to retrieve run status and details, dump run and item custom metadata, and download run results by supplying the share token secret; OAuth authentication remains required and the share token elevates the authenticated user's access to the shared run. When access is denied because the token is invalid, expired, or revoked, the system shall report a clear access-denied message and exit with code 1; when the run does not exist, the system shall exit with code 2. When no share token is supplied, the commands shall behave exactly as for a normal authenticated read.
10 changes: 7 additions & 3 deletions specifications/SPEC-APPLICATION-SERVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
itemId: SPEC-APPLICATION-SERVICE
itemTitle: Application Module Specification
itemType: Software Item Spec
itemFulfills: SWR-APPLICATION-1-1, SWR-APPLICATION-1-2, SWR-APPLICATION-1-3, SWR-APPLICATION-2-3, SWR-APPLICATION-2-4, SHR-APPLICATION-3, SWR-APPLICATION-2-12, SWR-APPLICATION-2-11, SWR-APPLICATION-2-13, SWR-APPLICATION-2-14, SWR-APPLICATION-2-15, SWR-APPLICATION-2-16, SWR-APPLICATION-2-17, SWR-APPLICATION-2-5, SWR-APPLICATION-2-7, SWR-APPLICATION-2-8, SWR-APPLICATION-2-9, SWR-APPLICATION-3-3
itemFulfills: SWR-APPLICATION-1-1, SWR-APPLICATION-1-2, SWR-APPLICATION-1-3, SWR-APPLICATION-2-3, SWR-APPLICATION-2-4, SHR-APPLICATION-3, SWR-APPLICATION-2-12, SWR-APPLICATION-2-11, SWR-APPLICATION-2-13, SWR-APPLICATION-2-14, SWR-APPLICATION-2-15, SWR-APPLICATION-2-16, SWR-APPLICATION-2-17, SWR-APPLICATION-2-5, SWR-APPLICATION-2-7, SWR-APPLICATION-2-8, SWR-APPLICATION-2-9, SWR-APPLICATION-3-3, SWR-APPLICATION-4-3
Module: Application
Layer: Domain Service
Version: 0.2.107
Date: 2026-04-29
Date: 2026-08-06
---

## 1. Description
Expand All @@ -28,6 +28,7 @@ The Application Module shall:
- **FR-05** **Result Download**: Progressive download of analysis results with resumable operations and organized directory hierarchies
- **FR-06** **QuPath Integration**: Automatic QuPath project creation with downloaded results for pathology analysis
- **FR-07** **Multi-Modal Interface**: Provide CLI, GUI, and programmatic interfaces for different user workflows
- **FR-08** **Shared Run Access via Share Token**: Read a run shared with you via `--share-token` (status, metadata, result download); denied token exits 1, missing run exits 2, no token behaves as before

### 1.3 Non-Functional Requirements

Expand Down Expand Up @@ -481,6 +482,9 @@ uvx aignostics application [subcommand] [options]
`--checksum` guards the write with optimistic concurrency control (exit code 3 on conflict);
`--enrich-sdk-metadata / --no-enrich-sdk-metadata` (default enrich) controls whether the SDK
merges auto-generated tracking context into the `sdk` field or forwards it verbatim
- `--share-token <secret>` (on `run describe`, `run dump-metadata`, `run dump-item-metadata`,
`run result download`): read a run shared with you; OAuth login still required. Denied token
exits 1, missing run exits 2; omitted behaves as before

### 4.3 GUI Interface

Expand Down Expand Up @@ -558,7 +562,7 @@ Configuration is managed through environment variables with the prefix `AIGNOSTI
| `NotFoundException` | Missing runs or applications | Graceful rejection with info | Clear resource not found info |
| `FileNotFoundError` | Missing input files | File validation before upload | File path verification help |
| `ApiException` | Platform API failures | Retry mechanism with recovery | API error details and guidance |
| `ForbiddenException` | Caller not authorized for the requested org | Caught in CLI; exit 2 with access-denied message | User informed they lack permission |
| `ForbiddenException` | Not authorized for the org, or share-token read denied | Caught in CLI; org denial exits 2, share-token denial exits 1 (token never echoed) | Lacks permission / token may be invalid, expired, or revoked |
| `ConcurrencyConflictError` | Custom-metadata update rejected (HTTP 412): metadata modified since the checksum was read | `ValueError` subclass; caught in CLI, exit 3 | User told to re-read and retry the update |

### 7.2 Input Validation
Expand Down
10 changes: 7 additions & 3 deletions specifications/SPEC_PLATFORM_SERVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
itemId: SPEC-PLATFORM-SERVICE
itemTitle: Platform Module Specification
itemType: Software Item Spec
itemFulfills: SWR-APPLICATION-1-1, SWR-APPLICATION-1-2, SWR-APPLICATION-1-3, SWR-APPLICATION-2-1, SWR-APPLICATION-2-5, SWR-APPLICATION-2-6, SWR-APPLICATION-2-7, SWR-APPLICATION-2-9, SWR-APPLICATION-2-14, SWR-APPLICATION-2-15, SWR-APPLICATION-2-16, SWR-APPLICATION-2-17, SWR-APPLICATION-3-1, SWR-APPLICATION-3-2, SWR-APPLICATION-3-3, SWR-APPLICATION-4-1, SWR-APPLICATION-4-2
itemFulfills: SWR-APPLICATION-1-1, SWR-APPLICATION-1-2, SWR-APPLICATION-1-3, SWR-APPLICATION-2-1, SWR-APPLICATION-2-5, SWR-APPLICATION-2-6, SWR-APPLICATION-2-7, SWR-APPLICATION-2-9, SWR-APPLICATION-2-14, SWR-APPLICATION-2-15, SWR-APPLICATION-2-16, SWR-APPLICATION-2-17, SWR-APPLICATION-3-1, SWR-APPLICATION-3-2, SWR-APPLICATION-3-3, SWR-APPLICATION-4-1, SWR-APPLICATION-4-2, SWR-APPLICATION-4-3
Module: Platform
Layer: Platform Service
Version: 1.2.0
Date: 2026-06-09
Date: 2026-08-06
---

## 1. Description
Expand All @@ -33,6 +33,7 @@ The Platform Module shall:
- **[FR-12]** Generate signed URLs for secure Google Cloud Storage access
- **[FR-13]** Provide user and organization information retrieval with sensitive data masking options
- **[FR-14]** Support external token providers to bypass internal OAuth 2.0 flows for machine-to-machine, service account, or custom token lifecycle scenarios.
- **[FR-15]** Forward an optional share token (percent-encoded) as the `share_token` query parameter on the run read endpoints while always sending the OAuth Bearer, isolate it in the operation-cache key, and propagate `ForbiddenException` unchanged for caller-side handling

### 1.3 Non-Functional Requirements

Expand Down Expand Up @@ -717,6 +718,9 @@ class Artifact:
``allow_redirects=False`` and returns the presigned URL from the redirect
``Location`` header. The presigned URL is short-lived; resolve immediately
before downloading.

A share token, when present, is appended as the URL-encoded ``share_token``
query parameter (Bearer still sent).
"""
```

Expand Down Expand Up @@ -846,7 +850,7 @@ The Platform module provides foundational services but does not directly expose
| `NetworkError` | Connection timeouts or proxy issues | Retry with backoff; fallback to device flow | Automatic retry or alternative auth flow |
| `TokenExpiredError` | JWT token past expiration | Automatic refresh using refresh token | Transparent token renewal |
| `ValidationError` | Invalid input parameters or file formats | Input sanitization and validation | Clear validation error messages |
| `ForbiddenException` | Caller not authorized for the requested org | Caught in CLI; exit 2 with access-denied message | User informed they lack permission |
| `ForbiddenException` | Not authorized for the org, or share-token read denied | Propagated unchanged; org denial exits 2, share-token denial exits 1 | Lacks permission / token may be invalid, expired, or revoked |

### 7.2 Input Validation

Expand Down
37 changes: 34 additions & 3 deletions src/aignostics/application/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
print_runs_verbose,
read_metadata_csv_to_dict,
retrieve_and_print_run_details,
run_access_denied_message,
validate_mappings,
write_metadata_dict_to_csv,
)
Expand Down Expand Up @@ -121,6 +122,10 @@
int,
typer.Option(help="Timeout for acquiring compute nodes in minutes (1-3600).", min=1, max=3600),
]
ShareTokenOption = Annotated[
str | None,
typer.Option(help="Share token secret for link-based access. OAuth login is still required."),
]


cli = typer.Typer(name="application", help="List and inspect applications on Aignostics Platform.")
Expand Down Expand Up @@ -970,13 +975,15 @@
help="Show only run and item status summary (external ID, state, error message)",
),
] = False,
share_token: ShareTokenOption = None,
) -> None:
"""Describe run."""
logger.trace("Describing run with ID '{}'", run_id)

try:
user_info = PlatformService.get_user_info()
run = Service().application_run(run_id)
run = Service().application_run(run_id, share_token=share_token)

if format == "json":
# Get run details and items, output as JSON
run_details = run.details(hide_platform_queue_position=not user_info.is_internal_user)
Expand All @@ -995,6 +1002,14 @@
else:
console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.")
sys.exit(2)
except ForbiddenException:
logger.warning("Access denied for run '{}'", run_id)

Check failure on line 1006 in src/aignostics/application/_cli.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Access denied for run '{}'" 4 times.

See more on https://sonarcloud.io/project/issues?id=aignostics_python-sdk&issues=AZ_RYErIuknv4EOZ12jQ&open=AZ_RYErIuknv4EOZ12jQ&pullRequest=688
msg = run_access_denied_message(run_id, share_token)
if format == "json":
print(json.dumps({"error": "access_denied", "message": msg}), file=sys.stderr)
else:
console.print(f"[error]Error:[/error] {msg}")
sys.exit(1)
except Exception as e:
logger.exception(f"Failed to retrieve and print run details for ID '{run_id}'")
if format == "json":
Expand All @@ -1018,12 +1033,13 @@
),
),
] = False,
share_token: ShareTokenOption = None,
) -> None:
"""Dump custom metadata of a run as JSON to stdout."""
logger.trace("Dumping custom metadata for run with ID '{}'", run_id)

try:
run = Service().application_run(run_id).details()
run = Service().application_run(run_id, share_token=share_token).details()
custom_metadata = run.custom_metadata if hasattr(run, "custom_metadata") else {}
output: dict[str, Any] | Any = custom_metadata
if show_checksum:
Expand All @@ -1043,6 +1059,10 @@
logger.warning(f"Run with ID '{run_id}' not found.")
console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.")
sys.exit(2)
except ForbiddenException:
logger.warning("Access denied for run '{}'", run_id)
console.print(f"[error]Error:[/error] {run_access_denied_message(run_id, share_token)}")
sys.exit(1)
except Exception as e:
logger.exception(f"Failed to dump custom metadata for run with ID '{run_id}'")
console.print(f"[error]Error:[/error] Failed to dump custom metadata for run with ID '{run_id}': {e}")
Expand All @@ -1054,6 +1074,7 @@
run_id: Annotated[str, typer.Argument(help="Id of the run containing the item")],
external_id: Annotated[str, typer.Argument(help="External ID of the item to dump custom metadata for")],
pretty: Annotated[bool, typer.Option(help="Pretty print JSON output with indentation")] = False,
share_token: ShareTokenOption = None,
show_checksum: Annotated[
bool,
typer.Option(
Expand All @@ -1070,7 +1091,7 @@
logger.trace("Dumping custom metadata for item '{}' in run with ID '{}'", external_id, run_id)

try:
run = Service().application_run(run_id)
run = Service().application_run(run_id, share_token=share_token)

# Find the item with the matching external_id in the results
item = None
Expand Down Expand Up @@ -1106,6 +1127,10 @@
logger.warning(f"Run with ID '{run_id}' not found.")
print(f"Warning: Run with ID '{run_id}' not found.", file=sys.stderr)
sys.exit(2)
except ForbiddenException:
logger.warning("Access denied for run '{}'", run_id)
print(f"Error: {run_access_denied_message(run_id, share_token)}", file=sys.stderr)
sys.exit(1)
except Exception as e:
logger.exception(f"Failed to dump custom metadata for item '{external_id}' in run with ID '{run_id}'")
print(
Expand Down Expand Up @@ -1661,6 +1686,7 @@
'Run uvx --with "aignostics[qupath]" aignostics qupath install'
),
] = False,
share_token: ShareTokenOption = None,
) -> None:
"""Download results of a run."""
logger.trace(
Expand Down Expand Up @@ -1808,6 +1834,7 @@
wait_for_completion=wait_for_completion,
qupath_project=qupath_project,
download_progress_callable=update_progress,
share_token=share_token,
)

main_download_progress_ui.update(main_task, completed=100, total=100)
Expand All @@ -1823,6 +1850,10 @@
logger.warning(f"Bad input to download results of run with ID '{run_id}': {e}")
console.print(f"[warning]Warning:[/warning] Bad input to download results of run with ID '{run_id}': {e}")
sys.exit(2)
except ForbiddenException:
logger.warning("Access denied for run '{}'", run_id)
console.print(f"[error]Error:[/error] {run_access_denied_message(run_id, share_token)}")
sys.exit(1)
except Exception as e:
logger.exception(f"Failed to download results of run with ID '{run_id}'")
console.print(
Expand Down
23 changes: 20 additions & 3 deletions src/aignostics/application/_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -800,19 +800,28 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0915
logger.exception(message)
raise RuntimeError(message) from e

def application_run(self, run_id: str) -> Run:
def application_run(self, run_id: str, share_token: str | None = None) -> Run:
"""Select a run by its ID.

Args:
run_id (str): The ID of the run to find
run_id (str): The ID of the run to find.
share_token (str | None): Optional share token secret. When provided the run is
accessed with the ``share_token`` forwarded as a query parameter, elevating
the calling (OAuth-authenticated) user's access to a run shared with them.
The caller must still be authenticated. An empty string is treated as absent.

Returns:
Run: The run that can be fetched using the .details() call.

Raises:
RuntimeError: If initializing the client fails or the run cannot be retrieved.
"""
# Treat an empty --share-token the same as "not supplied" so a blank value falls back
# to the normal authenticated read instead of forwarding an empty share_token query param.
share_token = share_token or None
try:
if share_token is not None:
return Run.for_run_id(run_id, share_token=share_token)
return self._get_platform_client().run(run_id)
except Exception as e:
message = f"Failed to retrieve application run with ID '{run_id}': {e}"
Expand Down Expand Up @@ -1675,6 +1684,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0915
qupath_project: bool = False,
download_progress_queue: Any | None = None, # noqa: ANN401
download_progress_callable: Callable | None = None, # type: ignore[type-arg]
share_token: str | None = None,
) -> Path:
"""Download application run results with progress tracking.

Expand All @@ -1691,6 +1701,9 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0915
of the destination directory.
download_progress_queue (Queue | None): Queue for GUI progress updates.
download_progress_callable (Callable | None): Callback for CLI progress updates.
share_token (str | None): Optional share token secret forwarded as the
``share_token`` query parameter, elevating the authenticated caller's access
to a run shared with them. OAuth authentication is still required.

Returns:
Path: The directory containing downloaded results.
Expand Down Expand Up @@ -1721,14 +1734,18 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0915
progress = DownloadProgress()
update_progress(progress, download_progress_callable, download_progress_queue)

application_run = self.application_run(run_id)
application_run = self.application_run(run_id, share_token=share_token)
final_destination_directory = destination_directory
try:
details = application_run.details()
except NotFoundException as e:
message = f"Application run with ID '{run_id}' not found: {e}"
logger.warning(message)
raise NotFoundException(message) from e
except ForbiddenException:
# Propagate 403 unchanged so the CLI can surface a share-token-specific
# "access denied" message; do not wrap it into RuntimeError below.
raise
except ApiException as e:
if e.status == HTTPStatus.UNPROCESSABLE_ENTITY:
message = f"Run ID '{run_id}' invalid: {e!s}."
Expand Down
17 changes: 17 additions & 0 deletions src/aignostics/application/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,3 +599,20 @@ def get_supported_extensions_for_application(application_id: str) -> set[str]:
message = f"Unsupported application {application_id}"
logger.critical(message)
raise RuntimeError(message)


def run_access_denied_message(run_id: str, share_token: str | None) -> str:
"""Compose the operator-facing "access denied" message for a run.

Args:
run_id (str): The run access was denied for.
share_token (str | None): The share token supplied, if any. When set, a hint
that the token may be invalid, expired, or revoked is appended.

Returns:
str: The composed message.
"""
message = f"Access denied for run '{run_id}'."
if share_token is not None:
message += " The share token may be invalid, expired, or revoked."
return message
Loading
Loading