> ## Documentation Index
> Fetch the complete documentation index at: https://docs.chainloop.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Managed CAS with AWS S3 Access Points

> Auto-provision a per-organization S3 Access Point for every org in your Chainloop installation, backed by a single shared bucket.

Managed CAS is a Chainloop Enterprise feature where the platform auto-provisions a dedicated [AWS S3 Access Point](https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-points.html) for every organization in your installation, all backed by a single shared S3 bucket. End users do not need to bring their own [CAS backend](/concepts/cas-backend) — uploads and downloads work out of the box from the moment an org is created.

<Info>
  This guide is for **platform operators** running Chainloop on-prem in AWS. If your users want to bring their own storage (OCI registry, their own S3 bucket, Azure Blob, etc.) instead, see the [CAS backend concepts page](/concepts/cas-backend).
</Info>

## How It Works

* **Single shared bucket** per Chainloop installation. There is no per-org bucket to provision or rotate.
* Storage is provisioned automatically for every organization while managed CAS is enabled — no per-org action from you or your users. Each org gets:
  * one [S3 Access Point](https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-points.html) on the shared bucket,
  * one **IAM managed policy**, used as a session policy to scope credentials to that access point,
  * one storage backend in Chainloop, named `chainloop-cloud-storage` and marked as managed.
* Chainloop reaches the access point through short-lived, per-request `sts:AssumeRole` sessions scoped to a single organization.
* **No long-lived AWS credentials are stored.** Chainloop persists a small record per org in its secrets backend holding only the access point ARN, the region, the tenant role ARN and the per-org session policy ARN — no access keys.
* Every object is keyed under `<org-uuid>/sha256:<digest>` inside the bucket, and the key prefix is derived from the authenticated org on each request rather than read from stored configuration.
* Encryption at rest is delegated to a customer-managed **KMS key** on the bucket (`SSE-KMS`).

<Note>
  Provisioning is currently one-way: Chainloop creates access points, session policies and objects, but never deletes them. Removing storage for a deleted or downgraded organization is a manual operation today.
</Note>

## Authentication Model

S3 Access Points have **no credentials of their own**. Authentication is ordinary AWS IAM — the access point is a resource-side scoping primitive, not an identity. Getting this model right is what most of the setup below is about.

Two properties drive everything:

1. **A request must be allowed by every layer it passes through.** Effective permissions are the intersection of the tenant role's identity policy, the per-org session policy, and the resource policies — not their union.
2. **A request through an access point is authorized twice**, once against the **access point ARN** and once against the **bucket ARN**. AWS grants the access only if both contexts allow it. This is why the bucket policy must delegate to access points: without a bucket-context `Allow`, requests fail even when every access point and session policy is perfect.

### How Pods Obtain AWS Credentials

Chainloop supports the two standard EKS mechanisms for giving a pod an AWS identity, and you pick one for your installation:

* **EKS Pod Identity** (recommended) — the workload role trusts the `pods.eks.amazonaws.com` service principal, and you bind service accounts to it with pod identity associations.
* **IRSA** — the workload role trusts your cluster's OIDC provider, and each pod exchanges a projected service-account token via `sts:AssumeRoleWithWebIdentity`.

Both produce the same thing: workload credentials the pod then uses to assume the tenant role. Everything from that point on — the tenant-role session, the access point policy, the bucket policy, KMS — is identical either way. Only this first layer differs, and it is the layer that fails first when misconfigured. Configure your choice in [Configure Workload Identities](#4-configure-workload-identities).

```mermaid theme={"dark"}
flowchart TB
  backend["backend worker pod"] -->|IRSA or Pod Identity| bwr["Backend workload role"]
  cas["cas and controlplane pods"] -->|IRSA or Pod Identity| cwr["Controlplane workload role"]
  bwr -->|"CreateAccessPoint, PutAccessPointPolicy, CreatePolicy"| prov["Per-org access point<br/>and session policy"]
  cwr -->|"sts:AssumeRole as tenant role<br/>session name cas-org-uuid<br/>plus per-org session policy"| sess["Scoped 1h credentials"]
  sess --> apctx{"Access point context<br/>AP policy checks<br/>aws:userid"}
  apctx --> bkctx{"Bucket context<br/>bucket policy delegates<br/>to access points"}
  bkctx --> kmsctx{"Encryption<br/>KMS key policy admits<br/>the tenant role"}
  kmsctx --> obj[("s3://bucket/org-uuid/sha256:digest")]
```

Each layer, what must allow it, and how it fails:

| Layer | Must be allowed by | What Chainloop does | Failure signature |
| - | - | - | - |
| Pod credentials | The **workload role's trust policy** | `sts:AssumeRoleWithWebIdentity` (IRSA) or an EKS Pod Identity association | `AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity` |
| Tenant role | The **tenant role's trust policy** plus the workload role's permission policy | `sts:AssumeRole` with `RoleSessionName` = `cas-<org-uuid>` and the org's session policy attached by ARN, valid one hour | `not authorized to perform: sts:AssumeRole` |
| Access point context | The **access point resource policy**, intersected with the tenant role's identity policy and the session policy | Passes the access point ARN as the S3 `Bucket` parameter | `403` on `PutObject` or `GetObject` |
| Bucket context | The **bucket policy's delegation `Allow`** | The same request, re-evaluated against the bucket ARN | `HeadObject` returns a bare `403 Forbidden` with no error code or message |
| Encryption | The **KMS key policy** | Relies on the bucket's `SSE-KMS` default encryption | A KMS error on `PutObject` or `GetObject` |

Which identity performs which call matters when reading logs:

* The **backend worker** provisions: it creates access points, writes their resource policies, and creates the per-org session policies.
* The **cas** and **controlplane** pods read and write objects. They only ever assume the tenant role.
* The **tenant role** creates nothing. It is the principal named in every access point policy, and the role whose temporary sessions are narrowed per org.

Per-org isolation rests on two independent mechanisms: the session policy limits the credentials to a single access point, and the access point policy requires `aws:userid` to equal `<tenant-role-id>:cas-<org-uuid>` — a session name Chainloop derives from the authenticated request, not from stored configuration. A tampered record pointing org A at org B's access point still assumes a session named for org A, and is denied.

<Warning>
  Access point resource policies and per-org session policies are **machine-generated**. Never hand-author or edit them:

  * A hand-written access point policy is silently overwritten on the next reconciliation.
  * A hand-edited session policy is **never** corrected — Chainloop creates each per-org policy once and does not update existing ones, so a modified document stays modified until you delete the policy and let it be recreated.

  The only policies you own are the ones in this guide: the bucket policy, the KMS key policy, and the IAM roles.
</Warning>

## Prerequisites

You will provision the following resources in your AWS account before enabling the feature in Chainloop. We recommend managing them with Terraform or OpenTofu.

* A single **S3 bucket** for the installation
* A customer-managed **KMS key** for `SSE-KMS` on the bucket
* A **tenant IAM role** — the role Chainloop workloads assume per-request to reach the access points
* **Workload identities** (EKS Pod Identity or IRSA) attached to the Chainloop `backend`, `controlplane`, and `cas` pods so they can call AWS APIs

You also need a **secrets backend** (HashiCorp Vault or AWS Secrets Manager) configured in your installation. Managed CAS stores each org's access point record there, and provisioning fails without it.

### Resource Naming

Chainloop derives access point and session policy names — they are **not configurable**. Both include a slug taken from `backend.environment`, and the bucket and IAM policies you write below must match it:

| `backend.environment` | Access point names | Session policy names |
| - | - | - |
| `production` | `chainloop-prod-<org-uuid-hex>` | `cas-p-<12-hex>` |
| `canary` | `chainloop-canary-<org-uuid-hex>` | `cas-c-<12-hex>` |
| unset | `chainloop-dev-<org-uuid-hex>` | `cas-d-<12-hex>` |

`<org-uuid-hex>` is the organization UUID with hyphens stripped (32 characters); `<12-hex>` is its last 12 characters.

Throughout the policies below, substitute `[env]` with `prod` or `canary` to match the environment you will configure. Setting `backend.environment` is mandatory in production: if you leave it unset, Chainloop creates `chainloop-dev-*` access points that your bucket policy — scoped to `chainloop-prod-*` — will reject.

### 1. Create the S3 Bucket

Create one bucket in the region you want to operate from. Block all public access. Enable default encryption with the KMS key you create in the next step.

Attach this **bucket policy** exactly as shown, substituting only `[bucketName]`, `[region]`, `[accountId]`, and `[env]`. It is the [AWS-documented delegation pattern for access points](https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-points-policies.html#access-points-delegating-control): the bucket defers every access decision to the access point policies, which Chainloop generates per organization with a per-tenant `aws:userid` guard. Any request that does not arrive through one of your installation's access points is denied by default.

```json theme={"dark"}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DelegateAccessControlToAccessPoints",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "*",
      "Resource": [
        "arn:aws:s3:::[bucketName]",
        "arn:aws:s3:::[bucketName]/*"
      ],
      "Condition": {
        "StringEquals": {
          "s3:DataAccessPointAccount": "[accountId]"
        },
        "StringLike": {
          "s3:DataAccessPointArn": "arn:aws:s3:[region]:[accountId]:accesspoint/chainloop-[env]-*"
        }
      }
    }
  ]
}
```

<Warning>
  Do not narrow this policy into a hand-scoped grant — for example naming the tenant role as the `Principal` or listing individual actions such as `s3:GetObject`/`s3:PutObject`. As described in [Authentication Model](#authentication-model), every access point request is authorized in **two** contexts, and the bucket context requires the bucket policy itself to allow the access. The per-org session policies Chainloop generates speak only access-point ARNs, so a partial bucket grant fails the bucket-context check and surfaces as opaque `403 Forbidden` errors on the data path (typically on `HeadObject`), even when every access point and session policy is correct.

  This delegation form is not a loosening: the real access control lives in the machine-generated access point policies with per-tenant `aws:userid` guards, and access to the bucket other than through your installation's access points remains denied.
</Warning>

If your security review requires an explicit block on direct bucket access, add a `Deny` statement **alongside** the delegation `Allow` — never instead of it. Narrow that `Deny` to object actions rather than `s3:*`: a blanket `s3:*` deny conditioned on the absence of an access point also denies bucket administration, including `PutBucketPolicy`, which leaves account-root access as the only way back in.

### 2. Create the KMS Key

Create a customer-managed KMS key in the same region as the bucket and set it as the bucket's default encryption key.

The **key policy** must admit the **tenant role**, either directly or by delegating to IAM for the account (a statement granting the account root, so identity-based policies can grant key use). An identity-based grant alone is not enough — a KMS key policy cannot be overridden by an IAM policy, so a key that does not admit the tenant role fails every upload and download with a KMS error. The tenant role is the only identity that touches objects; the backend workload identity only manages access points and needs no key access.

Chainloop's per-org session policies grant `kms:GenerateDataKey` and `kms:Decrypt`, matching `SSE-KMS` on `PutObject` and `GetObject`.

### 3. Create the Tenant IAM Role

This is the role the Chainloop workloads assume per-request via STS. Note its name — you will pass it to the chart as `baseRoleName`.

**Trust policy** — allow the backend and controlplane workload identities to assume it. Grant `sts:TagSession` alongside `sts:AssumeRole`: workload sessions minted by EKS can carry session tags, and the assume call fails if propagating them is not permitted.

```json theme={"dark"}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": [
          "arn:aws:iam::[accountId]:role/[backendWorkloadRoleName]",
          "arn:aws:iam::[accountId]:role/[controlplaneWorkloadRoleName]"
        ]
      },
      "Action": [
        "sts:AssumeRole",
        "sts:TagSession"
      ]
    }
  ]
}
```

**Permission policy** — let it speak through any access point that matches your installation's prefix, and use the KMS key:

```json theme={"dark"}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AccessPointObjectAccess",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:AbortMultipartUpload",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:[region]:[accountId]:accesspoint/chainloop-[env]-*",
        "arn:aws:s3:[region]:[accountId]:accesspoint/chainloop-[env]-*/object/*"
      ]
    },
    {
      "Sid": "AccessPointKMS",
      "Effect": "Allow",
      "Action": [
        "kms:Decrypt",
        "kms:GenerateDataKey",
        "kms:DescribeKey"
      ],
      "Resource": "[kmsKeyArn]"
    }
  ]
}
```

<Warning>
  `s3:AbortMultipartUpload` and `s3:ListBucket` are both load-bearing, and both fail in ways that look unrelated to this policy:

  * Without `s3:AbortMultipartUpload`, small uploads succeed and **large (multipart) uploads fail** — the generated access point policy grants the action, but effective access is the intersection.
  * Without `s3:ListBucket`, S3 hides object existence: a lookup for an object that is simply absent returns `403 Forbidden` instead of `404`, which Chainloop cannot distinguish from a real denial.
</Warning>

### 4. Configure Workload Identities

The Chainloop pods need AWS credentials to call AWS APIs. We recommend [EKS Pod Identity](https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html); [IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html) works equivalently.

You need **two** workload identity roles: a **controlplane workload role** (shared by the controlplane and cas pods) and a **backend workload role**. Each one needs a **trust policy** that lets the pod's Kubernetes service account assume it, plus the **permission policy** shown below.

#### Trust policy

The trust policy is what bootstraps the pod's credentials. If it doesn't allow the service account, every AWS call fails with `AccessDenied` **before** any tenant `sts:AssumeRole` is attempted — so getting this right is a prerequisite for everything else. The shape depends on the mechanism:

<Tabs>
  <Tab title="EKS Pod Identity">
    The role trusts the EKS service principal. Which service accounts may use it is expressed as an association (see [Enable in Chainloop EE](#enable-in-chainloop-ee)), not in the trust policy:

    ```json theme={"dark"}
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Principal": { "Service": "pods.eks.amazonaws.com" },
          "Action": ["sts:AssumeRole", "sts:TagSession"]
        }
      ]
    }
    ```
  </Tab>

  <Tab title="IRSA">
    The role trusts your cluster's OIDC provider, conditioned on the exact service-account subject(s) allowed to assume it. The controlplane role is shared by the controlplane and cas pods, so both subjects must be listed:

    ```json theme={"dark"}
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Principal": {
            "Federated": "arn:aws:iam::[accountId]:oidc-provider/oidc.eks.[region].amazonaws.com/id/[oidcId]"
          },
          "Action": "sts:AssumeRoleWithWebIdentity",
          "Condition": {
            "StringEquals": {
              "oidc.eks.[region].amazonaws.com/id/[oidcId]:aud": "sts.amazonaws.com",
              "oidc.eks.[region].amazonaws.com/id/[oidcId]:sub": [
                "system:serviceaccount:[namespace]:[controlplaneServiceAccount]",
                "system:serviceaccount:[namespace]:[casServiceAccount]"
              ]
            }
          }
        }
      ]
    }
    ```
  </Tab>
</Tabs>

<Warning>
  The **cas** pod runs the data path but shares the **controlplane** workload role (it is annotated with that role in [Enable in Chainloop EE](#enable-in-chainloop-ee)). The controlplane role must therefore be assumable by **both** the controlplane **and** the cas service accounts:

  * **IRSA**: list both subjects in the `sub` condition, as shown above.
  * **Pod Identity**: create an association for **each** of the two service accounts.

  Forgetting the cas service account is the most common misconfiguration: reads fail for **every** org with `AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity`, because the cas pod can never bootstrap its credentials.
</Warning>

#### Permission policies

**Controlplane workload role** — used by the controlplane and cas pods on the data path. Needs to call `sts:AssumeRole` against the tenant role:

```json theme={"dark"}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "sts:AssumeRole",
        "sts:TagSession"
      ],
      "Resource": "arn:aws:iam::[accountId]:role/[tenantRoleName]"
    }
  ]
}
```

**Backend workload role** — used by the platform backend's reconciler. Needs the controlplane permissions above **plus** the access point lifecycle and per-org session policy permissions:

```json theme={"dark"}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AssumeTenantRole",
      "Effect": "Allow",
      "Action": [
        "sts:AssumeRole",
        "sts:TagSession"
      ],
      "Resource": "arn:aws:iam::[accountId]:role/[tenantRoleName]"
    },
    {
      "Sid": "ManageAccessPoints",
      "Effect": "Allow",
      "Action": [
        "s3:CreateAccessPoint",
        "s3:TagResource",
        "s3:PutAccessPointPolicy",
        "s3:GetAccessPointPolicy",
        "s3:DeleteAccessPoint"
      ],
      "Resource": "arn:aws:s3:[region]:[accountId]:accesspoint/chainloop-[env]-*"
    },
    {
      "Sid": "ManageTenantSessionPolicies",
      "Effect": "Allow",
      "Action": [
        "iam:CreatePolicy",
        "iam:DeletePolicy"
      ],
      "Resource": "arn:aws:iam::[accountId]:policy/cas-*"
    }
  ]
}
```

The backend assumes the tenant role once at startup to read its unique ID (needed for the `aws:userid` condition in every access point policy), then creates each org's access point, writes its resource policy, and creates its session policy.

`s3:TagResource` is required even though Chainloop never calls the `TagResource` API: it tags each access point as part of the `CreateAccessPoint` call, and [AWS requires both actions to allow tagging on create](https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-points-tagging.html). Without it, provisioning fails with `AccessDenied` on `CreateAccessPoint`.

`s3:DeleteAccessPoint` and `iam:DeletePolicy` are not exercised today — provisioning is one-way — but granting them now means teardown support will not require a policy change.

<Info>
  The IAM "1500 customer-managed policies per account" service quota applies because Chainloop creates one managed policy per organization. Request a quota increase if you expect to scale past it.
</Info>

## Enable in Chainloop EE

In your `chainloop-ee` Helm chart `values.yaml`, set `backend.environment` and configure `backend.managedCas`:

```yaml chainloop-ee values.yaml theme={"dark"}
backend:
  environment: production
  managedCas:
    enabled: true
    bucket: [bucketName]
    region: [region]
    accountId: "[accountId]"
    baseRoleName: [tenantRoleName]
```

<Warning>
  `backend.environment` selects the access point name prefix (see [Resource Naming](#resource-naming)) and must match the prefix in your bucket and IAM policies. The chart accepts only `production` or `canary`. Leaving it unset produces `chainloop-dev-*` access points, and every `CreateAccessPoint` call is then denied by the bucket policy.
</Warning>

Bind the workload identities to the Chainloop service accounts. The exact mechanism depends on whether you are using EKS Pod Identity or IRSA — both come down to associating an IAM role with the Kubernetes service account. For IRSA, annotate the service accounts:

```yaml chainloop-ee values.yaml theme={"dark"}
backend:
  serviceAccount:
    annotations:
      eks.amazonaws.com/role-arn: arn:aws:iam::[accountId]:role/[backendWorkloadRoleName]

controlplane:
  serviceAccount:
    annotations:
      eks.amazonaws.com/role-arn: arn:aws:iam::[accountId]:role/[controlplaneWorkloadRoleName]

cas:
  serviceAccount:
    annotations:
      eks.amazonaws.com/role-arn: arn:aws:iam::[accountId]:role/[controlplaneWorkloadRoleName]
```

For EKS Pod Identity, create an `aws_eks_pod_identity_association` per `(namespace, service account)` pair instead — including one binding the **cas** service account to the controlplane workload role, matching the IRSA annotation above.

Apply the values and roll out the chart.

### Restricting Access Points to a VPC

By default the per-org access points are internet-facing (access is still governed entirely by the policies above). To make them reachable only from inside a VPC, set:

```yaml chainloop-ee values.yaml theme={"dark"}
backend:
  managedCas:
    vpcId: vpc-0123456789abcdef0
```

Two caveats:

* **Existing access points are not migrated.** The network origin is fixed at creation, and reconciliation treats an already-existing access point as done. Only orgs provisioned after you set `vpcId` get VPC-scoped access points.
* **An invalid VPC ID stalls provisioning.** The value is not validated up front; a wrong ID turns into a per-org `CreateAccessPoint` failure that blocks the reconciler from progressing to the remaining orgs.

## Verify

1. Create a new organization in the Chainloop UI. Open **Storage Backends** for that org — you should see a default backend named `chainloop-cloud-storage` provided by Chainloop.
   <Frame>
     <img src="https://mintcdn.com/chainloop/2DV8Wt8Zh2ib38eQ/guides/deployment/guides/img/managed-cas-storage-backend.png?fit=max&auto=format&n=2DV8Wt8Zh2ib38eQ&q=85&s=ddcf31fbac8fa2853af770dbe70be0d0" alt="Chainloop UI showing a default storage backend managed by Chainloop" width="1526" height="314" data-path="guides/deployment/guides/img/managed-cas-storage-backend.png" />
   </Frame>
2. Confirm the access point exists in the AWS console under **S3 → Access Points**, named `chainloop-[env]-<org-uuid-hex>`. If it isn't there yet, give it a moment and check the backend logs — provisioning runs in both the backend and its worker, so select by label to avoid reading just one pod:
   ```bash theme={"dark"}
   kubectl -n [namespace] logs -l app.kubernetes.io/component=backend --tail=200
   kubectl -n [namespace] logs -l app.kubernetes.io/component=backend-worker --tail=200
   ```
3. As a user of that org, [run an attestation](/get-started/first-attestation) or upload an artifact:
   ```bash theme={"dark"}
   chainloop artifact upload -f myfile
   ```
4. Inspect the bucket — the object should be under the org's prefix, encrypted with your KMS key:
   ```
   s3://[bucketName]/<org-uuid>/sha256:<digest>
   ```

<Warning>
  If no access point is ever created and the logs show no provisioning errors either, grep the backend logs for `continuing with managed CAS disabled`. When the AWS client cannot be constructed — a misconfigured base role, a missing `sts:AssumeRole` permission, the wrong region — Chainloop logs a warning and starts with managed CAS silently switched off rather than crash-looping.
</Warning>

## Troubleshooting

Read any chained STS error **inside-out** — the innermost operation is the one that actually failed. A `HeadObject` error wrapping an `AssumeRole` error wrapping `AssumeRoleWithWebIdentity` is a credential-bootstrap failure, not an S3 policy problem.

When you hit a bare `403 Forbidden` with no error code or message, that is `HeadObject`: it is an HTTP `HEAD` request, so S3 cannot return an error body and cannot tell you which layer denied it. **Re-run the same access as a request that can return a body.** Both of these name the denying layer in one line — use the access point ARN as `--bucket`:

```bash theme={"dark"}
AP=arn:aws:s3:[region]:[accountId]:accesspoint/chainloop-[env]-<org-uuid-hex>
ORG=<org-uuid>

aws s3api list-objects-v2 --bucket "$AP" --prefix "$ORG/" --region [region]
echo probe | aws s3api put-object --bucket "$AP" --key "$ORG/probe.txt" --body - --region [region]
```

Then match what you get:

| Signature | Cause |
| - | - |
| `AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity` | The pod's credential bootstrap, *before* any tenant `AssumeRole`. The workload role does not trust the service account — most often the **cas** service account is missing from the controlplane role's OIDC `sub` condition. See [Configure Workload Identities](#4-configure-workload-identities). |
| `not authorized to perform: sts:AssumeRole` | The workload role's permission policy does not allow assuming the tenant role, or the tenant role's trust policy does not name the workload role. |
| Everything returns `403`, including `list-objects-v2` | The bucket policy is not the delegation form. A hand-scoped grant fails the bucket-context check. Apply the policy from [Create the S3 Bucket](#1-create-the-s3-bucket) as written, and verify its access point prefix matches the names Chainloop is creating. |
| `NoSuchAccessPoint` | The access point does not exist for that org. Either provisioning never ran, or `backend.environment` does not match the prefix in your policies — check for `chainloop-dev-*` names. |
| `AccessDenied` naming a KMS action or key | The KMS key policy does not admit the tenant role. See [Create the KMS Key](#2-create-the-kms-key). |
| `list-objects-v2` succeeds but the object lookup still `403`s | The access point policy's `aws:userid` or `s3:prefix` condition does not match the request. Confirm the tenant role's unique ID in the policy is the **tenant** role's, not a workload role's. |
| Object lookups return `404` | Permissions are fine — the object genuinely isn't there. |
| Small uploads succeed, large ones fail | The tenant role is missing `s3:AbortMultipartUpload`. See [Create the Tenant IAM Role](#3-create-the-tenant-iam-role). |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.