Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c5c994d
docs(spec): identity-only blob access
jQuinRivero Oct 27, 2026
3e78fd2
fix(blob): let download and range helpers use the managed identity
jQuinRivero Oct 27, 2026
d6d0a10
feat(blob): add per-blob read SAS helper
jQuinRivero Oct 27, 2026
96b1145
fix(storage): scope data-layer read tokens to one blob
jQuinRivero Oct 27, 2026
e44bbd0
fix(storage): scope artifact read tokens to one blob
jQuinRivero Oct 27, 2026
9e1570c
chore(infra): stop provisioning BLOB_CONNECTION_STRING on function apps
jQuinRivero Oct 27, 2026
3bbd001
feat(blob): sign internal blob URLs at response time
jQuinRivero Oct 27, 2026
33f10c3
fix(storage): keep stored blob URLs unsigned and sign when served
jQuinRivero Oct 27, 2026
68317cc
docs(spec): describe unsigned stored URLs signed at response time
jQuinRivero Oct 27, 2026
1ab624d
fix(infra): delete legacy blob setting only after publish
jQuinRivero Oct 29, 2026
42303ac
fix(storage): sign project-details URLs before the ETag is computed
jQuinRivero Oct 29, 2026
a251a5e
feat(api): return tile credential expiry with labeling data
jQuinRivero Oct 29, 2026
0230550
feat(ui): add tile credential refresh helper
jQuinRivero Oct 29, 2026
6e1d198
feat(ui): refresh tile credentials in labeling surfaces
jQuinRivero Oct 29, 2026
de46900
docs(spec): describe tile credential refresh for long sessions
jQuinRivero Oct 29, 2026
fccc0ba
feat(api): visualizer results return tileCredentialExpiresAt
jQuinRivero Oct 29, 2026
9f9f6c1
feat(ui): refresh visualizer tile credentials before expiry
jQuinRivero Oct 29, 2026
c375d50
docs(spec): visualizer tile credential refresh
jQuinRivero Oct 29, 2026
8e2fac1
fix(ui): drop visualizer tile override when results are replaced
jQuinRivero Oct 29, 2026
f6cd7b6
fix(api): sign remaining download links and keep external raster cred…
jQuinRivero Oct 29, 2026
76315f0
fix(ui): apply visualizer tile refresh to live layers and on version …
jQuinRivero Oct 29, 2026
ecdfef0
ci: allow BLOB_CONNECTION_STRING as a local-only setting in the drift…
jQuinRivero Oct 30, 2026
13e94f0
fix(api): keep signed URLs for blobs outside the configured container
jQuinRivero Oct 30, 2026
2ccf8a9
fix(ui): keep null raster overrides and scope refresh errors to a sna…
jQuinRivero Oct 30, 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
4 changes: 4 additions & 0 deletions .github/scripts/check_env_drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@
# be emitted by both deploy paths.
ALLOWLIST = {
"AzureFunctionsWebHost__hostId": "consumed by the Functions host runtime",
# Local/emulator fallback. Azure deployments authenticate with the app
# identity through BLOB_ACCOUNT_URL and deploy_apps.sh removes any
# leftover setting.
"BLOB_CONNECTION_STRING": "local/emulator only",
# Alternative metadata/storage backends. Azure deployments use blob storage
# (METADATA_STORAGE_TYPE=blob), so these are never read there.
"COSMOS_ENDPOINT": "cosmos backend only",
Expand Down
17 changes: 9 additions & 8 deletions .github/scripts/deploy_apps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -307,13 +307,6 @@ deploy_function() {
size_settings+=("HASTE_MAX_IMAGERY_DOWNLOAD_BYTES=$HASTE_MAX_IMAGERY_DOWNLOAD_BYTES")
fi

# Get storage account connection string with error handling
echo "Retrieving storage account connection string..."
BLOB_CONNECTION_STRING=$(az storage account show-connection-string --name "$STORAGE_ACCOUNT" --resource-group "$RESOURCE_GROUP" --query connectionString -o tsv) || {
echo "ERROR: Failed to retrieve storage account connection string. Ensure service principal has 'Storage Account Key Operator Service Role' or 'Reader and Data Access' role on storage account '$STORAGE_ACCOUNT'." >&2
exit 1
}

# Get batch account key with error handling
echo "Retrieving batch account key..."
AZURE_BATCH_ACCOUNT_KEY=$(az batch account keys list --name "$BATCH_ACCOUNT" --resource-group "$SHARED_RESOURCE_GROUP" --query "primary" -o tsv) || {
Expand All @@ -340,7 +333,6 @@ deploy_function() {
"QUEUE_ACCOUNT_URL=https://${STORAGE_ACCOUNT}.queue.core.windows.net" \
"BLOB_ACCOUNT_URL=https://${STORAGE_ACCOUNT}.blob.core.windows.net" \
"BLOB_CONTAINER=data" \
"BLOB_CONNECTION_STRING=$BLOB_CONNECTION_STRING" \
"AZURE_BATCH_ACCOUNT_NAME=$BATCH_ACCOUNT" \
"AZURE_BATCH_URL=https://${BATCH_ACCOUNT}.${LOCATION}.batch.azure.com" \
"AZURE_BATCH_ACCOUNT_KEY=$AZURE_BATCH_ACCOUNT_KEY" \
Expand Down Expand Up @@ -415,6 +407,15 @@ deploy_function() {
close_deploy_access "$FUNCTION_NAME"
echo "Function deployment completed successfully."

# Blob and queue access is identity-based. Drop the account-key
# connection string left behind by earlier deployments, only once code
# that no longer needs it is running.
if [ "${SET_APPSETTINGS:-true}" = "true" ]; then
az functionapp config appsettings delete --name "$FUNCTION_NAME" --resource-group "$RESOURCE_GROUP" \
--setting-names BLOB_CONNECTION_STRING \
--output none
fi

# Record the wheel this app actually installed. Nothing else on the app
# says which hastegeo it is running -- the wheel is pinned into
# requirements.txt at deploy time and then forgotten -- so a blank
Expand Down
22 changes: 20 additions & 2 deletions api/hastefuncapi/function_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,10 @@
from hastegeo.core.utils.blob import (
download_blob_to_tempfile,
parse_byte_range,
sign_label_project_urls,
sign_project_details_urls,
)
from hastegeo.core.utils.blob_sas import read_token_expires_at
from hastegeo.core.utils.data import convert_json_to_geojson, filter_roles
from hastegeo.core.utils.errors import exception_diagnostics
from hastegeo.core.utils.gdal_security import (
Expand Down Expand Up @@ -973,6 +976,9 @@ async def _load_response():
project = await ProjectDetailsProcessor(
project_id=project_id, config=config
).load(include_models=include_models)
# Signed inside the cached load so the ETag covers the tokens the
# client holds and a 304 can never revive an expired one.
await asyncio.to_thread(sign_project_details_urls, project)
payload = json.dumps(project)
return {
"payload": payload,
Expand Down Expand Up @@ -1755,6 +1761,12 @@ async def GetLayerLabelingToolData(req: func.HttpRequest) -> func.HttpResponse:
"Label project not found.", status_code=404
)

match_label_projects = await asyncio.to_thread(
sign_label_project_urls, match_label_projects
)
match_label_projects["tileCredentialExpiresAt"] = (
read_token_expires_at()
)
return func.HttpResponse(
json.dumps(match_label_projects), status_code=200
)
Expand Down Expand Up @@ -1796,8 +1808,13 @@ async def GetLabelingWorkspace(req: func.HttpRequest) -> func.HttpResponse:
image_layer_id=image_layer_id,
config=config,
).load()
payload = workspace.model_dump(mode="json")
payload["labelProject"] = await asyncio.to_thread(
sign_label_project_urls, payload["labelProject"]
)
Comment thread
jQuinRivero marked this conversation as resolved.
Resolved
payload["tileCredentialExpiresAt"] = read_token_expires_at()
return func.HttpResponse(
json.dumps(workspace.model_dump(mode="json")),
json.dumps(payload),
status_code=200,
mimetype="application/json",
)
Expand Down Expand Up @@ -3808,7 +3825,8 @@ async def GetBuildingFootprintsGeoJSON(
)

# Download via the helper that handles both Azurite and real-Azure
# URL shapes and routes through BLOB_CONNECTION_STRING.
# URL shapes, using the app's own credential rather than the SAS in
# the URL.
tmp_path = await download_blob_to_tempfile(
footprints_url, suffix=".gpkg"
)
Expand Down
1 change: 1 addition & 0 deletions api/hastefuncapi/tests/test_loading_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ async def test_returns_minimum_workspace_response(self) -> None:
)

self.assertEqual(response.status_code, 200)
self.assertIn("tileCredentialExpiresAt", response_json(response))
self.assertEqual(response_json(response)["eventTypes"], ["Wildfire"])
self.assertEqual(
response_json(response)["imageLayer"]["imageLayerId"], LAYER_ID
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@
from azure.storage.blob import (
BlobSasPermissions,
generate_blob_sas,
generate_container_sas,
)
from hastegeo.core.utils.blob import (
get_blob_service_client,
get_cached_user_delegation_key,
split_blob_url,
)
from hastegeo.core.utils.blob_sas import DELEGATION_KEY_MARGIN, blob_read_sas
from hastegeo.core.utils.logs import Logger
from hastegeo.core.utils.parallel import configured_worker_count, parallel_map

from ..utils.blob_access_policy import ensure_container_read_policy
from .abstract_artifact_storage import AbstractArtifactStorage

_INITIALIZED_CONTAINERS = set()
Expand All @@ -47,8 +47,8 @@ def __init__(
super().__init__(partition_key)
self.logger = Logger.get_logger(__name__)
# A write-only target (e.g. the PC publish container) doesn't mint read
# SAS, so it skips the user-delegation key and the stored read policy —
# which need Storage Blob Delegator / Owner. Write access alone suffices.
# SAS, so it needs no delegation key (Storage Blob Delegator / Owner).
# Write access alone suffices.
self.serves_read_sas = serves_read_sas
if connection_string:
self.blob_service_client = get_blob_service_client(
Expand All @@ -69,17 +69,14 @@ def __init__(
self.user_delegation_key = None
self.account_key = None

# Kept for call compatibility; no container policy is maintained, so
# deleting the legacy one revokes old tokens for good.
self.container_read_policy = container_read_policy_name
self.blob_read_policy = blob_read_policy_name
self.sas_expiration_days = 90
self.container_client = self.blob_service_client.get_container_client(
container
)
cache_key = (
self.blob_service_client.url,
container,
self.container_read_policy,
)
cache_key = (self.blob_service_client.url, container)
with _INITIALIZED_CONTAINERS_LOCK:
if cache_key not in _INITIALIZED_CONTAINERS:
try:
Expand All @@ -91,17 +88,8 @@ def __init__(
self.logger.info(
f"Container '{container}' already exists."
)
if self.serves_read_sas:
self._create_or_update_managed_access_policy()
_INITIALIZED_CONTAINERS.add(cache_key)

def _create_or_update_managed_access_policy(self) -> None:
ensure_container_read_policy(
self.container_client,
self.container_read_policy,
expiration_days=self.sas_expiration_days,
)

def get_file_path(
self, identifier: str, extra_partition_keys: list | str = None
) -> str:
Expand Down Expand Up @@ -142,28 +130,34 @@ def get_download_url(
# and use the container_client and blob name to get the blob client
# It's a cleaner separation of metaphors
blob_client = BlobClient.from_blob_url(artifact_path)
container_name, blob_name = split_blob_url(artifact_path)
blob_name = unquote(blob_name)
else:
blob_name = self.get_file_path(
identifier, extra_partition_keys=extra_partition_keys
)
blob_client = self.container_client.get_blob_client(blob_name)
container_name = self.container_client.container_name

if not include_sas:
return str(blob_client.url)

# Otherwise generate SAS token

# One blob, read-only, one hour: these URLs reach clients, so the
# token must be worth no more than the object it points at.
account_key = self.account_key
user_delegation_key = self.user_delegation_key
if self.account_key is None:
if self.identity_blob_service_client is not None:
account_key = None
user_delegation_key = get_cached_user_delegation_key(
self.blob_service_client
self.identity_blob_service_client,
min_remaining=DELEGATION_KEY_MARGIN,
)

sas_token = generate_container_sas(
sas_token = blob_read_sas(
account_name=self.container_client.account_name,
container_name=self.container_client.container_name,
policy_id=self.container_read_policy,
account_key=self.account_key,
container_name=container_name,
blob_name=blob_name,
account_key=account_key,
user_delegation_key=user_delegation_key,
)
return str(f"{blob_client.url}?{sas_token}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@
import yaml
from azure.core.exceptions import ResourceExistsError, ResourceNotFoundError
from azure.storage.blob import BlobBlock # type: ignore
from azure.storage.blob import generate_container_sas

from ..utils.blob import (
get_blob_service_client,
get_cached_user_delegation_key,
)
from ..utils.blob_access_policy import ensure_container_read_policy
from ..utils.blob_sas import DELEGATION_KEY_MARGIN, blob_read_sas
from ..utils.metadata import matches_metadata_type
from ..utils.parallel import parallel_map
from .abstract_data_layer import AbstractDataLayer
Expand All @@ -41,22 +40,28 @@ def __init__(
)
self.user_delegation_key = None
self.account_key = self.blob_service_client.credential.account_key
# On real Azure, sign with the app identity so the account key is
# never needed to mint tokens. Azurite keeps the account key.
self.identity_blob_service_client = (
get_blob_service_client(account_url=account_url)
if str(account_url or "").startswith("https://")
else None
)
else:
self.blob_service_client = get_blob_service_client(
account_url=account_url
)
self.user_delegation_key = None
self.account_key = None
self.identity_blob_service_client = self.blob_service_client

# Kept for call compatibility; no container policy is maintained, so
# deleting the legacy one revokes old tokens for good.
self.container_read_policy = container_read_policy_name
self.container_client = self.blob_service_client.get_container_client(
container
)
cache_key = (
self.blob_service_client.url,
container,
self.container_read_policy,
)
cache_key = (self.blob_service_client.url, container)
with _INITIALIZED_CONTAINERS_LOCK:
if cache_key not in _INITIALIZED_CONTAINERS:
try:
Expand All @@ -66,16 +71,8 @@ def __init__(
)
except ResourceExistsError:
logging.info(f"Container '{container}' already exists.")
self._create_or_update_managed_access_policy()
_INITIALIZED_CONTAINERS.add(cache_key)

def _create_or_update_managed_access_policy(self) -> None:
ensure_container_read_policy(
self.container_client,
self.container_read_policy,
expiration_days=90,
)

def get_file_path(
self,
identifier,
Expand Down Expand Up @@ -121,21 +118,23 @@ def get_file_remote_path(

# ``check_exists=False`` skips the per-blob existence HEAD request. Use
# it when the caller has already confirmed presence in bulk (e.g. via
# ``list_identifiers``) — the container read SAS is identical for every
# blob, so building the URL is a purely local operation.
# ``list_identifiers``) — the token is signed locally for this blob,
# so building the URL needs no request.
if not check_exists or blob_client.exists():
account_key = self.account_key
user_delegation_key = self.user_delegation_key
if self.account_key is None:
if self.identity_blob_service_client is not None:
account_key = None
user_delegation_key = get_cached_user_delegation_key(
self.blob_service_client
self.identity_blob_service_client,
min_remaining=DELEGATION_KEY_MARGIN,
)
# Generate SAS token with the policy
sas_token = generate_container_sas(
sas_token = blob_read_sas(
account_name=self.container_client.account_name,
container_name=self.container_client.container_name,
policy_id=self.container_read_policy,
blob_name=blob_name,
account_key=account_key,
user_delegation_key=user_delegation_key,
account_key=self.account_key,
)

sas_url = f"{blob_client.url}?{sas_token}"
Expand Down
1 change: 1 addition & 0 deletions hastelib/src/hastegeo/core/models/visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ class Visualizer(BaseModel):
sourceTypePostEvent: Optional[str] = Field(default=None)
imageryCaptureDatePreEvent: Optional[str] = Field(default=None)
imageryCaptureDatePostEvent: Optional[str] = Field(default=None)
tileCredentialExpiresAt: Optional[str] = Field(default=None)
6 changes: 5 additions & 1 deletion hastelib/src/hastegeo/core/processors/labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
LabelingImagery,
LabelProject,
)
from ..utils.blob import sign_internal_blob_url, unsigned_internal_blob_url
from ..utils.gdal_security import harden_gdal
from ..utils.metadata import MetadataUtils

Expand Down Expand Up @@ -76,6 +77,7 @@ def generate_task_file(self, geom: dict[str, any]) -> None:
def get_xyz_url(self, imagery_url: str) -> str:
"""Generate XYZ URL for the url of a TIF file."""
xyz_url = ""
imagery_url = unsigned_internal_blob_url(imagery_url)
if imagery_url:
urlencoded_imagery_url = requests.utils.quote(imagery_url, safe="")
xyz_url = f"{self.titiler_endpoint}cog/tiles/WebMercatorQuad/{{z}}/{{x}}/{{y}}?scale=1&url={urlencoded_imagery_url}"
Expand All @@ -84,7 +86,9 @@ def get_xyz_url(self, imagery_url: str) -> str:
def generate_task_files(self):
"""Generate task files."""
self.logger.info("Generating task file")
with rasterio.open(self.input_image_fn_post_event) as f:
with rasterio.open(
sign_internal_blob_url(self.input_image_fn_post_event)
) as f:
bounds = shapely.geometry.box(*f.bounds)
geom = shapely.geometry.mapping(bounds)
if f.crs.to_epsg() != 4326:
Expand Down
Loading
Loading