Providers
AWS S3
Uses the Ahena CLI (beta). Install it with npm install -g @ahena/cli, or prefix commands with npx @ahena/cli. See Installation. The dashboard covers projects, connections, Doctor, the Stack Graph, plans and approvals without it.
Overview
Ahena checks and configures the S3 buckets your app stores files in: it keeps Block Public
Access on and default encryption set, creates missing buckets, sets CORS for browser uploads,
turns on versioning, and generates the storage adapter. Your app talks to S3 directly with
its own credentials. Ahena is never in the upload or download path and never lists, reads or
writes objects.
AWS is not one provider in Ahena: S3 and SES are separate providers with separate connections. Ahena doesn't manage EC2, RDS, Lambda, Route 53, CloudFront, ECS, EKS or IAM.
Package: @ahena/provider-aws-s3 · Category: storage · API: S3 REST (path-style, SigV4 signed with WebCrypto)
Capabilities: buckets, cors, public-access-block, encryption, versioning, bucket-inspection
Maturity: verified against a fake of the API; not yet verified against the real service.
Connection
| Field | Secret | Notes |
|---|---|---|
AWS_ACCESS_KEY_ID |
yes | Access key of a dedicated IAM user for Ahena (AKIA…, or ASIA… for temporary credentials). |
AWS_SECRET_ACCESS_KEY |
yes | Shown once when the key is created. |
AWS_SESSION_TOKEN |
yes, optional | Only for temporary credentials. They expire; the connection then reports expired. |
AWS_REGION |
no (setting) | Where Ahena calls STS, and the default Region of your buckets. |
AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=… ahena connect aws-s3 -e production --set AWS_REGION=us-east-1
Ahena validates the credentials with STS GetCallerIdentity (no permission needed, changes
nothing) and shows the account id and the principal's ARN.
Supported Regions (the 17 enabled by default in every account): us-east-1, us-east-2,
us-west-1, us-west-2, ca-central-1, sa-east-1, eu-central-1, eu-west-1, eu-west-2,
eu-west-3, eu-north-1, ap-south-1, ap-northeast-1, ap-northeast-2, ap-northeast-3,
ap-southeast-1, ap-southeast-2. Network: only s3.<region>.amazonaws.com and
sts.<region>.amazonaws.com for those Regions (34 exact hosts, enforced). Requests are
path-style (https://s3.<region>.amazonaws.com/<bucket>?cors), so no per-bucket host names are
needed.
Permissions
Use a dedicated IAM user (or a role you assume into temporary credentials) with only this policy. Replace the bucket names with yours.
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "AhenaListBuckets", "Effect": "Allow", "Action": "s3:ListAllMyBuckets", "Resource": "*" },
{
"Sid": "AhenaBucketConfiguration",
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:CreateBucket",
"s3:GetBucketCORS",
"s3:PutBucketCORS",
"s3:GetBucketPublicAccessBlock",
"s3:PutBucketPublicAccessBlock",
"s3:GetBucketPolicyStatus",
"s3:GetBucketVersioning",
"s3:PutBucketVersioning",
"s3:GetEncryptionConfiguration",
"s3:PutEncryptionConfiguration",
"s3:GetLifecycleConfiguration",
"s3:GetBucketWebsite",
"s3:GetBucketOwnershipControls"
],
"Resource": ["arn:aws:s3:::leo-uploads", "arn:aws:s3:::leo-uploads-dev"]
}
]
}
| Action | Needed for |
|---|---|
s3:ListAllMyBuckets |
Inspect; telling your buckets from names other accounts own |
s3:ListBucket |
HeadBucket (exists, Region). Ahena never calls ListObjects. |
s3:CreateBucket |
Creating a declared bucket |
s3:Get…/s3:Put… above |
Reading and setting CORS, Block Public Access, versioning, encryption; reading policy status, lifecycle, website, Object Ownership |
For read-only use (Doctor and inspect only), leave out s3:CreateBucket and every s3:Put….
Ahena never needs s3:GetObject, s3:PutObject, s3:DeleteObject, s3:DeleteBucket,
s3:GetBucketPolicy, s3:PutBucketPolicy or s3:PutBucketAcl.
Permission check: authentication only. AWS can't tell a principal what it may do without
iam:SimulatePrincipalPolicy, which is more IAM access than Ahena should have, and S3 writes
can't be probed without changing something. So ahena connect confirms the credentials work;
a missing permission shows in Doctor as a WARNING that names the exact action (e.g.
s3:GetBucketCORS), and in apply as a failure that names it.
Capabilities
| Capability | Access | Verification | Changes | Refuses |
|---|---|---|---|---|
buckets |
writable | read-back | CONFIRMATION_REQUIRED | deleting buckets; listing, reading, writing or deleting objects; a name another account owns |
cors |
writable | read-back | CONFIRMATION_REQUIRED | * or localhost/plain-http origins in production; removing rules Ahena didn't create |
public-access-block |
writable | read-back | CONFIRMATION_REQUIRED | turning Block Public Access off; public bucket policies; public ACLs; website hosting that needs public access |
encryption |
writable | read-back | CONFIRMATION_REQUIRED | replacing SSE-KMS with SSE-S3; turning default encryption off |
versioning |
writable | read-back | CONFIRMATION_REQUIRED | suspending versioning |
bucket-inspection |
read-only | reading bucket policies (only GetBucketPolicyStatus's IsPublic); changing lifecycle rules |
ahena inspect aws-s3 lists every bucket with its Region, and for the first 25 (by name):
CORS rules, Block Public Access, whether the bucket policy is public, versioning, default
encryption, lifecycle rule count, website configuration presence and Object Ownership. A field
the credentials may not read shows as not allowed (s3:…); the rest still show.
ahena lock records the declared bucket's Block Public Access, policy status, Object
Ownership, encryption, versioning and CORS rules for drift.
Configure
storage: {
provider: "aws-s3",
buckets: { production: "leo-uploads", development: "leo-uploads-dev" }, // or one name
region: "us-east-1", // the buckets' Region (default: the connection's AWS_REGION)
cors: [{ origins: ["https://leo.app"], methods: ["GET", "PUT"] }], // or keyed by environment
versioning: { production: true }, // or true
},
A CORS rule takes origins (required), methods (GET, PUT, POST, DELETE, HEAD;
default GET, PUT, HEAD), headers (default content-type), exposeHeaders (default
etag) and maxAgeSeconds (default 3600). Every declared bucket is also kept with Block Public
Access on and default encryption set.
| Change | Classification | Notes |
|---|---|---|
| Create the bucket | CONFIRMATION_REQUIRED, with a cost notice. AWS doesn't charge for creating a general-purpose bucket or for an empty one; storage, requests and transfer are billed as your app uses them. Ahena's classifications can't yet say "creates something that can be billed later" (see Approvals) | Created with Block Public Access on, ACLs disabled (S3's default) and SSE-S3, each set explicitly and read back. Cost notice: an empty bucket costs nothing; stored objects, requests and transfer are billed by AWS. |
| Turn Block Public Access fully on | CONFIRMATION_REQUIRED | Not SAFE: anything public through a bucket policy or ACL stops being reachable. |
| Set default encryption SSE-S3 | CONFIRMATION_REQUIRED | Only when the bucket has no default encryption. A bucket using SSE-KMS is left alone. |
| Enable versioning | CONFIRMATION_REQUIRED | Can later be suspended, never turned off; old versions are kept and billed until a lifecycle rule expires them. |
| Set CORS | CONFIRMATION_REQUIRED | Ahena's rules have IDs ahena-0, ahena-1, …; rules with other IDs are kept. A rule is matched by content, so an equivalent rule you wrote counts. |
Every apply step re-reads first and writes only if still needed, sends
x-amz-expected-bucket-owner (the account from STS) so a write can never land on another
account's bucket, and sends Content-MD5. After apply, Ahena plans again: an empty plan is
the verification. Apply stops at the first failure; the next plan picks up what's left.
Refused outright: blockPublicAccess: false, policy, acl, public, website,
delete/deleteBucket, lifecycle, and versioning: false on a bucket where it's on.
Generate
ahena generate aws-s3 writes the same storage interface as the Cloudflare R2 adapter, so
switching storage providers keeps app code:
src/ahena/storage/index.ts:storage.upload(key, body, contentType?),storage.download(key),storage.remove(key),storage.uploadUrl(key, expiresIn?),storage.downloadUrl(key, expiresIn?),storage.rawsrc/ahena/providers/aws-s3.ts: anS3Clientfrom@aws-sdk/client-s3(credentials from the AWS default chain, Region fromAWS_REGION),bucketName()fromS3_BUCKET.env.example.aws-s3
Presigned URLs use @aws-sdk/s3-request-presigner; the bucket stays private. Give the app its
own role or user with only s3:GetObject, s3:PutObject, s3:DeleteObject on
arn:aws:s3:::<bucket>/*.
Limitations
- Opt-in Regions (e.g.
af-south-1,ap-east-1,eu-south-1,me-south-1) and GovCloud/China aren't supported. - Account-level Block Public Access isn't read: the S3 Control endpoint is
<account-id>.s3-control.<region>.amazonaws.com, a host that can't be declared in advance. Doctor checks the bucket's own settings. - Ahena never deletes buckets or objects, never writes bucket policies or ACLs, never configures website hosting or lifecycle rules, and never suspends versioning.
- Bucket policies are never read; only
IsPublicfromGetBucketPolicyStatus. - Directory buckets (S3 Express One Zone) need virtual-hosted requests to zonal endpoints and aren't supported; path-style requests work for general purpose buckets.
- SSE-KMS isn't configured by Ahena (it's left in place when present).
- Object Ownership isn't changed by Ahena (Doctor gives the console steps).
Manual steps
- Create the IAM user and policy above (Ahena can't change IAM).
- A public bucket policy (
aws-s3.bucket.public): remove the public statement in the S3 console, then let Ahena turn Block Public Access on. - ACLs enabled (
aws-s3.bucket.ownership): S3 console → bucket → Permissions → Object Ownership → ACLs disabled. - A bucket in another Region: set
storage.regionto it (buckets can't move Regions).
Doctor checks
| Id | Severity | Meaning |
|---|---|---|
aws-s3.credential |
PASS / FAIL | STS accepts the credentials (account and ARN in evidence). |
aws-s3.region |
FAIL | AWS_REGION missing or unsupported. |
aws-s3.permission.list_buckets |
WARNING | s3:ListAllMyBuckets missing. |
aws-s3.bucket.undeclared |
INFO | No bucket in ahena.config.ts, so nothing else is checked. |
aws-s3.bucket |
PASS / FAIL | Exists and readable; missing (CONFIRMATION_REQUIRED fix creates it); another account's. |
aws-s3.bucket.region |
FAIL | The bucket is in a different Region than declared. |
aws-s3.bucket.public |
PASS / FAIL | GetBucketPolicyStatus says the policy is public (MANUAL). |
aws-s3.bucket.public_access_block |
PASS / WARNING / FAIL | Not all four settings on (FAIL when the bucket is also public). |
aws-s3.bucket.encryption |
PASS / WARNING | No default encryption, or it couldn't be read. |
aws-s3.bucket.versioning |
PASS / WARNING / INFO | Off or suspended: WARNING in production or when declared, INFO otherwise. |
aws-s3.bucket.cors |
PASS / WARNING | Declared rules missing (evidence: missing origins). |
aws-s3.bucket.lifecycle |
INFO | Rule count; a hint when versioning has no noncurrent-version expiry. |
aws-s3.bucket.website |
INFO | Website hosting configured (Ahena won't make it public). |
aws-s3.bucket.ownership |
PASS / WARNING | ACLs enabled (MANUAL). |
Any read refused with 403 becomes a WARNING on that check naming the IAM action to add.
Disconnect behavior
Removes the connection and the credentials Ahena stored. Ahena can't revoke an IAM access key it didn't create: deactivate and delete it in IAM → Users → the Ahena user → Security credentials. Buckets, objects and their settings are untouched.
Set up step by step
Create the credential. AWS console → IAM → Users → Create user (e.g.
ahena, no console access) → Add permissions → Create inline policy (JSON tab: the policy above) → then the user → Security credentials → Create access key → "Application running outside AWS" → copy the access key ID and secret.Minimum permissions. The policy in Permissions, scoped to your buckets. Read-only use needs only the
Get…/List…actions.Connect.
AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=… ahena connect aws-s3 -e production --set AWS_REGION=us-east-1Pipe the values or type them at the hidden prompt; never pass them as arguments.
Verify the connection.
ahena inspect aws-s3 -e productionshows the account and your buckets with their settings (no credentials, no object listings, no policies).Run Doctor.
ahena doctor -e production.Plan. Add the
storagesection above toahena.config.ts, thenahena plan -e production(orahena configure aws-s3 -e productionto plan and apply this provider only).Apply an allowed change.
ahena apply -e production: e.g. create the bucket, or set CORS. Each change needs confirmation; a creation shows the cost notice first.Verify.
ahena plan -e productionshows no S3 changes (the read-back matched) andahena doctor -e productionpasses. Thenahena generate aws-s3 -e production, give the app its own object-level credentials, and set.env.example.aws-s3's variables.Troubleshooting.
Symptom Cause Fix "AWS rejected the credentials (InvalidClientTokenId)" Deleted, inactive or mistyped access key Create a new access key and reconnect. "… (SignatureDoesNotMatch)" The secret doesn't belong to the key Copy both again from the same key and reconnect. "session token has expired" Temporary credentials expired Reconnect with fresh ones, or use an IAM user's key. "request's timestamp" Clock skew on the machine running Ahena Fix the system clock. Doctor: "not allowed (s3:…)" That action is missing from the policy Add the named action for the bucket. "is in eu-west-1, but the configuration says us-east-1" Wrong storage.regionSet it to the bucket's Region. "already taken" Bucket names are global; another account has it Choose another name. aws-s3.bucket.publicFAILA bucket policy grants public access Remove the public statement, then apply Block Public Access. "doesn't suspend versioning" versioning: falseon a versioned bucketRemove versioningor set it totrue.Disconnect and revoke.
ahena disconnect aws-s3 -e production, then IAM → Users →ahena→ Security credentials → deactivate and delete the access key (or delete the user).