Amazon S3
Connect a private S3 bucket through an IAM role that trusts only this Connection, or through a read-only access key.
Connect a private S3 bucket once, and everyone in the Project can read it through Locations without holding AWS credentials. Spiral exchanges the Connection's credential for a short-lived session that reaches only the Location's root, so the bucket's own permissions stay the outer limit and no reader ever sees a long-lived key. See Connections for what readers receive.
An aws Connection authenticates in one of two ways:
| Authentication | Spiral stores | Use it when |
|---|---|---|
oidc (recommended) | The ARN of an IAM role that trusts this Connection | You can create IAM roles. No key exists, so there is none to leak or rotate. |
access_key | The access key of an IAM user | Your account policy forbids identity providers. You rotate the key yourself. |
Before you start
connection:createin the Project. Theeditorandadminroles have it.- The CLI, signed in with
spiral login, or Spiral Studio. - Permission to create IAM roles or users, and their policies, in the AWS account that owns the bucket.
Create Connections on the branch main. All branches share one credential
for each Connection, so Spiral creates and drops Connections only on main.
Grant read access to the bucket
Whichever way the Connection authenticates, its IAM role or user needs read
access to what the Connection names, and nothing more. Spiral narrows every
session further, to the root of one Location, but your grant is the ceiling.
For a Connection to s3://example-robotics/, attach this policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListBucket",
"Effect": "Allow",
"Action": ["s3:ListBucket", "s3:GetBucketLocation"],
"Resource": "arn:aws:s3:::example-robotics"
},
{
"Sid": "ReadObjects",
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::example-robotics/*"
}
]
}For a Connection to a prefix, such as s3://example-robotics/recordings/,
narrow ReadObjects to arn:aws:s3:::example-robotics/recordings/* and add
the condition "StringLike": {"s3:prefix": ["recordings/", "recordings/*"]}
to s3:ListBucket.
Grant no other action. A Connection that is to
allow writes
also needs s3:PutObject, s3:AbortMultipartUpload, and
s3:ListMultipartUploadParts on the objects. Add those only when you raise
the Connection's allowed access, and never grant s3:DeleteObject: Spiral
never deletes through a Connection. Spiral's short-lived credentials create
objects only: their session policy requires the If-None-Match header, with
which S3 refuses to replace an object that exists. See
conditional writes.
If objects use AWS KMS encryption (SSE-KMS), also grant kms:Decrypt on their
keys, and let the key policies permit it. The keys must be in the bucket's
region. Spiral's session can decrypt only through S3, and only for objects
under the Location's root. See the
AWS requirements for SSE-KMS.
Connect with an IAM role
A role Connection stores no key. At each exchange, Spiral signs a short-lived OpenID Connect token whose subject names this Connection, and AWS STS exchanges it for a session of the role. The role trusts that one subject, so no other Connection, in your Organization or any other, can assume it.
-
Once per AWS account, add an IAM OpenID Connect identity provider with the provider URL
https://auth.spiraldb.comand the audiencests.amazonaws.com. -
Create the role with the read policy from Grant read access to the bucket. Give it any temporary trust policy; the next steps replace it.
-
Create the Connection with the role's ARN:
commit = client.project("demo").branch("main").commit() credential = ConnectionsCredential( aws_oidc=ConnectionsAwsOidc( role_arn="arn:aws:iam::123456789012:role/spiral-robotics-reader", region="us-east-1" ) ) created = commit.create_connection( "robotics-bucket", "s3://example-robotics/", credential, description="Robot recordings, read-only" ) head = commit.publish() print(f"Connection {created.connection_id} at commit {head.commit_id}") -
Print what the role must trust:
head = client.project("demo").branch("main") found = client.catalog.resolve_path("demo", head.commit_id, CatalogResolvePathRequest(path=[name])) if found.resource is None: raise LookupError(f"no Connection named {name}") connection = client.connections.read_connection(found.resource.descriptor.resource_id).connection if connection.identity is None: print("this Connection stores a key and has no identity") else: print(f"issuer {connection.identity.issuer}, audience {connection.identity.audience}") print(connection.identity.trust_policy)The output includes a trust policy like this one, with the Connection's own subject:
{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/auth.spiraldb.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "auth.spiraldb.com:sub": "conn_24041a03-26ef-45a2-88e2-3d89150ff6f7", "auth.spiraldb.com:aud": "sts.amazonaws.com" } } }] } -
Set it as the role's trust policy, unchanged.
Keep the sub condition. Every Spiral Connection, in any Organization, can
obtain a token from https://auth.spiraldb.com with the audience
sts.amazonaws.com. Only the subject tells your Connection apart, so a role
that trusts the identity provider without it trusts every Connection. To let
two Connections assume one role, list both subjects in the condition. A
Volume
uses the same identity provider with its own ID, vol_…, as the subject; to
let a Connection and a Volume share a role, list both.
A Connection that you drop and create again has a new subject, and the role
must trust the new one. The role must be in the aws partition, because
Spiral reads buckets through the endpoints of commercial AWS.
Connect with an access key
An access key Connection stores the secret of an IAM user, encrypted, and
returns it to nobody. Spiral narrows it with GetFederationToken, so the user
needs that permission as well as the read policy:
{
"Sid": "FederateConnectionSessions",
"Effect": "Allow",
"Action": "sts:GetFederationToken",
"Resource": "arn:aws:sts::123456789012:federated-user/spiraldb-connection"
}Use a dedicated IAM user for the Connection, with only these policies. The CLI reads the secret from a file or from stdin, never from an argument:
commit = client.project("demo").branch("main").commit()
credential = ConnectionsCredential(
aws_access_key=ConnectionsAwsAccessKey(
access_key_id="AKIAIOSFODNN7EXAMPLE",
secret_access_key=Path("aws-secret-access-key.txt").read_text().strip(),
region="us-east-1",
)
)
created = commit.create_connection(
"robotics-bucket", "s3://example-robotics/", credential, description="Robot recordings, read-only"
)
head = commit.publish()
print(f"Connection {created.connection_id} at commit {head.commit_id}")To move an existing Connection from a key to a role later, see Update a credential.
Create the Connection in Studio
- Select Create resource → Connection.
- Enter a name, select the provider AWS, and select the authentication: IAM role (OpenID Connect) or Access key.
- Enter the URI: a bucket, or a prefix that ends with
/, such ass3://example-robotics/. - For a role, enter the AWS account ID and the role name, which is the part
of the role ARN after
role/, with its path if it has one. For an access key, enter the access key ID and the secret access key. - Create the Connection. For a role, the Connection page shows the trust policy to set on the role.
Verify the result
Test the Connection. Spiral obtains a session with the role or the key, and lists one page of objects under the Connection URI:
head = client.project("demo").branch("main")
found = client.catalog.resolve_path("demo", head.commit_id, CatalogResolvePathRequest(path=[name]))
if found.resource is None:
raise LookupError(f"no Connection named {name}")
result = client.connections.test_connection(found.resource.descriptor.resource_id)
print(f"credential accepted: {result.vend.succeeded}")
if result.access is not None:
print(f"storage reachable: {result.access.succeeded}")See Check that a Connection works for each step. The test does not read encrypted objects, so read one to check the KMS grants.
Troubleshooting
| Problem | Cause |
|---|---|
The vend step fails with AccessDenied for a role | The role's trust policy does not name this Connection's subject, or the identity provider is missing or has another audience. Set the trust policy that spiral connections show prints. |
The vend step fails for an access key | The key is wrong or deactivated, or the user lacks sts:GetFederationToken. |
The access step fails with AccessDenied | The role or the user cannot list the bucket or the prefix. Check the read policy. |
The access step cannot reach the bucket | The bucket accepts requests only from your network. Readers inside it still read through the Connection. |
Next step
Create a Location with a root
inside the Connection URI, such as s3://example-robotics/recordings/.