This article is co-authored with a generative AI. Facts have been cross-checked against official documentation where possible, but errors may remain. Please verify against primary sources before making any important decisions.

I needed to let a non-engineer member upload images to a fixed location in a specific bucket. Rather than handing over a full-access administrative key, I prepared a dedicated access key that only allows the operations needed for uploading.

The storage in question is DDN EXAScaler Access S3 (product name: S3 Data Services), which is compatible with Amazon Simple Storage Service (S3). It is S3-compatible, but a few aspects of access control differ from Amazon S3, and that is where I spent some time. I am writing down the steps I used to narrow things down, along with the configuration that finally worked, in case it helps others using the same product.

Approach

  • Prepare one dedicated access key (a separate user from the bucket owner). This lets you revoke it independently, and makes auditing and distinguishing the source of access easier.
  • Grant that key only the operations needed for uploading (list, get, put) via a bucket policy.
  • Store the access key and secret in 1Password, and expand them into environment variables only at run time with op run. This keeps plaintext out of the disk and shell history (the same idea as Injecting Environment Variables with the 1Password CLI).

Connection details (path-style and signatures)

This storage uses a custom hostname, and the bucket name contains a dot (.). When a bucket name contains a dot, the virtual-hosted style (which puts the bucket name in the hostname, like bucket.endpoint) does not match the name on the Transport Layer Security (TLS) certificate, so we connect with path-style (endpoint/bucket/key).

In boto3 (the AWS SDK for Python), you specify it as follows. The signature is AWS Signature Version 4 (SigV4).

import os
import boto3
from botocore.client import Config


def make_client(signature_version):
    return boto3.client(
        "s3",
        endpoint_url=os.environ["S3_ENDPOINT"],
        region_name=os.environ.get("S3_REGION", "us-east-1"),
        aws_access_key_id=os.environ["S3_ACCESS_KEY"],
        aws_secret_access_key=os.environ["S3_SECRET_KEY"],
        config=Config(
            s3={"addressing_style": "path"},   # path-style
            signature_version=signature_version,
        ),
    )

We inject the key with op run. The env.list file holds 1Password references, not the actual values.

S3_ENDPOINT=op://Personal/uploader/s3_endpoint
S3_BUCKET=op://Personal/uploader/s3_bucket
S3_ACCESS_KEY=op://Personal/uploader/s3_access_key
S3_SECRET_KEY=op://Personal/uploader/s3_secret_key
S3_REGION=op://Personal/uploader/s3_region
op run --env-file=env.list -- python3 conntest.py

Observe signatures and errors with a small connectivity test

With S3-compatible storage, the behavior of signature versions and permissions can differ slightly from Amazon S3. So I wrote a small script that tries s3:ListBucket / s3:GetObject / s3:PutObject / s3:DeleteObject with both SigV4 and Signature Version 2 (SigV2) and tabulates the results. Because the key is only expanded inside the op run subprocess, the values never appear on screen.

On this storage, both listing and uploading worked with SigV4, while listing returned SignatureDoesNotMatch with SigV2. Since a graphical client (such as Cyberduck) shares the same signature setting for browsing and uploading, this lets us choose SigV4, which passes both.

Signature  LIST                    PUT       GET       DELETE
SigV4      OK                      OK        OK        OK / AccessDenied
SigV2      FAIL:SignatureDoesNot…  OK        OK        ...

Narrow down the cause by the type of error

The errors I observed when hitting the endpoint with the dedicated key mapped directly to the cause.

  • InvalidAccessKeyId … The access key ID itself is not recognized. It may be a typo, or the key may not be active yet. The server is reachable, so this is not an endpoint or signature problem.
  • SignatureDoesNotMatch … The key ID is recognized, but the signature (the secret or the signing method) does not match.
  • AccessDenied … Authentication succeeded, but there is no permission for the operation.

The first key I supplied returned InvalidAccessKeyId; after re-entering it, it changed to AccessDenied. Going from InvalidAccessKeyId (who are you?) to AccessDenied (I know who you are, but you lack permission) told me that the key was recognized and that I only needed to grant permissions.

To sharpen the diagnosis, I hit the same endpoint and the same operations once with a known-good key (the bucket owner's key). When the owner's key succeeds across the board, the endpoint, the SDK configuration, and the network are all fine, and the only remaining difference is the dedicated key's permissions.

The bucket ownership model

In S3, the user who creates a bucket (the owner) keeps it, and other users cannot access it by default. Because the dedicated key was a different user from the owner, it returned AccessDenied. To grant another user access, you allow it explicitly with a bucket policy (or the bucket's Access Control List, ACL).

On this storage, the user identifier was a 40-digit hexadecimal string. You can find the owner's own identifier by reading the bucket ACL with the owner's key. It is useful for checking that the identifier you want to grant has the same format.

acl = s3.get_bucket_acl(Bucket=BUCKET)
print(acl["Owner"]["ID"])          # e.g., a 40-digit hexadecimal string

Bucket policy format (differences from Amazon S3)

This is the main point. At first I sent a policy in the same format as Amazon S3, but it kept returning MalformedPolicy: The policy must contain a valid version string. Since Version held a valid value yet this error appeared, I figured some part of the format was not being accepted, and checked the product's API reference guide.

In the range covered by the published API reference, the bucket policy of DDN EXAScaler Access S3 differs from Amazon S3 in the following ways.

  • Version is 2008-10-17 (or omitted). The reference describes Version as a placeholder, and gives 2008-10-17 or omission as the valid options. In my case, specifying 2012-10-17 returned the MalformedPolicy above.
  • Principal uses the form {"DDN": ["<canonical id>"]}. Instead of an Amazon Resource Name (ARN), you specify the user identifier (canonical id) directly. Both an array and a single string are accepted. You can also specify * or the predefined groups (all users, authenticated users, and so on).
  • Resource takes the bucket name only (not an ARN form).
  • Condition supports IP address conditions (aws:SourceIp with IpAddress / NotIpAddress, Classless Inter-Domain Routing (CIDR) notation, IPv4).

A policy reflecting these is shown below. The identifier and bucket name are placeholders.

{
  "Id": "UploaderPolicy",
  "Version": "2008-10-17",
  "Statement": [
    {
      "Sid": "UploaderReadWrite",
      "Effect": "Allow",
      "Principal": { "DDN": ["<UPLOADER_CANONICAL_ID>"] },
      "Action": ["s3:ListBucket", "s3:GetObject", "s3:PutObject"],
      "Resource": "<BUCKET_NAME>"
    }
  ]
}

Setting, getting, and deleting the bucket policy are done by the bucket owner. Apply it with the owner's key.

import json

owner = make_client("s3v4")  # the bucket owner's key
owner.put_bucket_policy(Bucket=BUCKET, Policy=json.dumps(policy))

After applying it, re-running the connectivity test with the dedicated key showed that s3:ListBucket / s3:GetObject / s3:PutObject passed, while s3:DeleteObject, which was not in the policy, stayed AccessDenied. That is, uploads are allowed but deletes are not. If you also want to allow deletion (replacing or renaming files), add s3:DeleteObject to Action and reapply.

On prefix-level scoping

In an Amazon S3 bucket policy, you can include an object prefix in Resource, or narrow the listing range with the s3:prefix condition. On this product, within the range covered by the published reference, Resource is specified at the bucket-name level and Condition supports IP address conditions. As a result, the grant applies at the bucket level rather than per folder (prefix).

Because the grant covers the whole bucket, I enabled bucket versioning as a safeguard against mistakes. With versioning enabled, you can recover from overwrites or deletions using a previous version.

owner.put_bucket_versioning(
    Bucket=BUCKET,
    VersioningConfiguration={"Status": "Enabled"},
)

As another way to grant access, the bucket ACL has a write permission (WRITE) that allows creating objects, and for the bucket owner and the owners of existing objects it also covers overwriting and deletion. In any case, the ACL cannot control creation and deletion separately. A bucket policy, on the other hand, can allow s3:PutObject while leaving out s3:DeleteObject, so when you do not want to grant deletion, the bucket policy lets you be more specific.

Summary

Even with S3-compatible storage, the behavior of signatures and access control varies by implementation. Two things worked well this time.

  • Prepare a small connectivity test and observe the success/failure per signature version and the kind of error (InvalidAccessKeyId / SignatureDoesNotMatch / AccessDenied). Putting a known-good key alongside as a control lets you separate a configuration problem from a key problem.
  • When a policy is not accepted, check the bucket policy format in the product's API reference (the value of Version, how to specify Principal, the granularity of Resource, and the range supported by Condition).

Keeping the access key in 1Password and expanding it only at run time with op run means that even when you run commands many times for diagnosis, the values do not stay on screen or in history.