Scoping Object Storage Credentials Per Tile Job
A fan-out multiplies whatever credential it was given by its width, so the question is not whether four hundred tasks are trustworthy but what one of them can reach if it is not. An inline session policy answers it precisely: take the pipeline’s role, narrow it for this job to one readable source prefix and one writable output prefix, expire it in thirty minutes, and hand that to every task. It requires no per-job administration, cannot accidentally widen anything, and turns a compromised worker from an estate-wide problem into a thirty-minute one.
When to Use This Pattern
- A mapped fan-out reads and writes object storage, which describes every tiling pipeline.
- One bucket holds several products with different sensitivity, so “read the bucket” is too much.
- Workers are shared or interruptible, which makes the credential’s lifetime a real control.
- A long-lived key is currently injected everywhere because it was the simplest thing that worked.
Complete Working Example
One helper issues the session; the flow acquires it once and the tasks receive it.
from __future__ import annotations
import json
from contextlib import contextmanager
from datetime import timedelta
import boto3
from prefect import flow, task, get_run_logger
PIPELINE_ROLE = "arn:aws:iam::123456789012:role/tile-pipeline"
def _session_policy(source_prefix: str, output_prefix: str) -> str:
"""Narrows the role. An inline policy can only ever remove permissions."""
return json.dumps({
"Version": "2012-10-17",
"Statement": [
{"Sid": "ReadOneSource", "Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": [f"arn:aws:s3:::geo-sources/{source_prefix}/*"]},
{"Sid": "ListThatPrefixOnly", "Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::geo-sources"],
"Condition": {"StringLike": {"s3:prefix": [f"{source_prefix}/*"]}}},
{"Sid": "WriteOneOutput", "Effect": "Allow",
"Action": ["s3:PutObject", "s3:AbortMultipartUpload"],
"Resource": [f"arn:aws:s3:::geo-products/{output_prefix}/*"]},
],
})
@contextmanager
def job_session(job_id: str, source_prefix: str, output_prefix: str,
ttl: timedelta = timedelta(minutes=30)):
creds = boto3.client("sts").assume_role(
RoleArn=PIPELINE_ROLE,
RoleSessionName=f"tiles-{job_id}"[:64], # appears in every access log line
Policy=_session_policy(source_prefix, output_prefix),
DurationSeconds=int(ttl.total_seconds()),
Tags=[{"Key": "pipeline", "Value": "tiles"},
{"Key": "job", "Value": job_id}],
)["Credentials"]
yield boto3.Session(
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"],
region_name="eu-west-2",
)
@task(retries=2, timeout_seconds=900, tags=["tile"])
def build_tile(tile: Tile, session, source_key: str, output_prefix: str) -> str:
s3 = session.client("s3")
body = s3.get_object(Bucket="geo-sources", Key=source_key,
Range=tile.byte_range())["Body"].read()
out_key = f"{output_prefix}/{tile.path}.tif"
s3.put_object(Bucket="geo-products", Key=out_key, Body=warp(body, tile))
return out_key
@flow(name="tile-build", timeout_seconds=5400)
def tile_build(tiles: list[Tile], job_id: str, source_prefix: str, output_prefix: str):
with job_session(job_id, source_prefix, output_prefix) as session:
get_run_logger().info("session scoped to %s -> %s", source_prefix, output_prefix)
return build_tile.map(tiles, session=session,
source_key=f"{source_prefix}/scene.tif",
output_prefix=output_prefix)
The s3:prefix condition on ListBucket is the part most implementations get wrong, because listing feels harmless. It is not: a bucket listing enumerates every object key in the estate, which for a geospatial store means every layer name, every delivery date and every customer identifier that appears in a path. That is a meaningful disclosure even without read access to the objects, and it is exactly the reconnaissance an attacker with one compromised worker would want. Constraining the listing to the job’s own prefix costs one condition block.
Parameter & Option Reference
| Setting | Value | Spatial notes |
|---|---|---|
DurationSeconds |
900–3600 | Longer than the longest task, shorter than a shift. Refresh inside long tasks. |
Policy (inline) |
per job | Narrows only. Generate it from the job’s parameters; it cannot widen the role. |
RoleSessionName |
flow run or job id | Appears in every storage access log entry, which makes attribution automatic. |
s3:prefix condition |
the job’s prefix | Without it, listing enumerates the whole estate. |
| Write actions | PutObject, AbortMultipartUpload |
Not DeleteObject. A tile pipeline never needs to delete. |
| Session tags | pipeline, job | Enables condition keys and cost attribution on the same credential. |
| Prefix layout | product/date/zoom/ |
The policy can only be as narrow as the key structure allows. |
Verification & Testing
The tests worth writing assert what the session cannot do, because that is the property being bought.
import pytest
from botocore.exceptions import ClientError
def test_session_cannot_read_another_product(job_id) -> None:
with job_session(job_id, "ortho/20260807", "tiles/ortho/z12") as s:
with pytest.raises(ClientError, match="AccessDenied"):
s.client("s3").get_object(Bucket="geo-sources", Key="cadastre/2026/x.gpkg")
def test_session_cannot_list_the_whole_bucket(job_id) -> None:
with job_session(job_id, "ortho/20260807", "tiles/ortho/z12") as s:
with pytest.raises(ClientError, match="AccessDenied"):
s.client("s3").list_objects_v2(Bucket="geo-sources")
def test_session_cannot_delete(job_id) -> None:
with job_session(job_id, "ortho/20260807", "tiles/ortho/z12") as s:
with pytest.raises(ClientError, match="AccessDenied"):
s.client("s3").delete_object(Bucket="geo-products",
Key="tiles/ortho/z12/12/2147/1398.tif")
def test_session_expires(job_id, monkeypatch) -> None:
with job_session(job_id, "ortho/20260807", "tiles/ortho/z12",
ttl=timedelta(minutes=15)) as s:
creds = s.get_credentials().get_frozen_credentials()
assert expiry_of(creds) - now() <= timedelta(minutes=15)
def test_no_long_lived_key_in_the_worker_environment() -> None:
assert "AWS_SECRET_ACCESS_KEY" not in os.environ, (
"a static key is present; the scoped session is decoration"
)
The last test is the one that keeps the rest honest. A worker that still has a static key in its environment will use it whenever the scoped session is not explicitly passed — and the fallback is silent, because the SDK’s credential chain is designed to find something. Every carefully scoped session in the pipeline then coexists with an unscoped key that any new code path will pick up by default, which is the worst of both arrangements: the appearance of least privilege and none of the effect.
The session name is the other detail that pays for itself later. Storage access logs record the identity that made each request, and with a shared role every one of a night’s four million requests is attributed to the same principal — which makes the log technically complete and practically useless. Setting the session name to the flow run identifier means the access log can be grouped by run, so questions like “which run read the restricted delivery” and “what did the job that overwrote these tiles touch” become filters rather than investigations. It costs one field on a call that is already being made.
Session tags extend the same idea into policy. Because the tags travel with the credential, a bucket policy can require that a request carrying pipeline=tiles targets only the tile prefixes, which enforces at the resource what the session policy asserts at the identity. That belt-and-braces arrangement is worth having in a shared estate, where the bucket’s owner and the pipeline’s author are different people and the bucket’s owner would reasonably like a guarantee that does not depend on somebody else’s code being correct.
Common Pitfalls
- Leaving a static key in the worker environment. Any code path that forgets the session silently gets the full role.
- Granting
ListBucketwithout a prefix condition. The listing enumerates every layer, date and identifier in the estate. - Including
DeleteObject. A tile pipeline writes and overwrites; deletion belongs to a lifecycle rule or to a separate, deliberate job. - A TTL shorter than the longest task. The credential expires mid-write and the failure looks like a storage outage.
- Flat key layouts. They cap how narrow any policy can be, and the layout is chosen long before anyone thinks about it.
- Generating the policy from unvalidated input. A prefix built from a user-supplied string can escape its intended slice; validate the shape before interpolating.
Frequently Asked Questions
How does the session reach four hundred tasks?
Pass it as a task argument, as in the example, or re-derive it inside each task from the job parameters. Passing it is simpler and correct as long as tasks run within the flow’s lifetime; deriving it per task is better for very long runs, because each task then gets a fresh TTL.
What happens when a task outlives the session?
The write fails with an authorisation error, which looks like an outage and is not. Either raise the TTL above the longest plausible task or refresh inside the task; the second is more robust and is what long-running mosaics should do.
Is this available outside AWS?
The mechanism differs and the shape is the same: GCP has short-lived credentials with downscoped access boundaries, Azure has user-delegation SAS tokens scoped to a path and a time. All three narrow an existing identity for one job, and all three are one-way.
Does the session policy replace the role's own policy?
No — it intersects with it. That is what makes generating policies programmatically safe: the effective permission is whatever both allow, so a template with a bug can grant less than intended but never more.
How do I roll this out on an existing pipeline?
Add the scoped session first and pass it explicitly everywhere, leaving the static key in place so nothing breaks. Then watch the storage access logs for requests still arriving under the old identity; each one is a code path that was missed. When that count reaches zero for a full cycle, remove the key. Doing it in the other order — removing the key first — produces an outage on whichever path nobody remembered, usually the error-handling one.
Should each layer have its own bucket?
Prefixes are usually enough and are easier to operate. Separate buckets earn their place when the layers have genuinely different regulatory treatment — different retention, different encryption keys, different jurisdiction — because those are bucket-level properties.
Related
- Security boundaries for spatial data — the boundary this implements
- Securing PostGIS connections in workflows — the same discipline for the database
- Storing flow state in PostGIS versus object storage — what lives behind these prefixes
- Cutting egress costs with COG range reads — reading through the same session
- Masking sensitive coordinates in task logs — the third boundary