Repository navigation
Add the BucketAccess resource and the admin readme #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f05b738
5f80235
ee89c51
71e3024
407eeff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,129 @@ | ||||||||||||||
| # BucketAccess | ||||||||||||||
|
|
||||||||||||||
| A `BucketAccess` grants an [`AccessKey`](access-key.md) permissions on a [`Bucket`](bucket.md). It is what makes an access key useful: on its own a key can do nothing. | ||||||||||||||
|
|
||||||||||||||
| This is Garage's native permission model — per key, per bucket, three flags. Garage has no IAM and no bucket policies, so there is nothing more expressive to model on it. | ||||||||||||||
|
|
||||||||||||||
| ## Example | ||||||||||||||
|
|
||||||||||||||
| ```yaml | ||||||||||||||
| apiVersion: garage.aboutbits.it/v1 | ||||||||||||||
| kind: BucketAccess | ||||||||||||||
| metadata: | ||||||||||||||
| namespace: acme | ||||||||||||||
| name: acme-app-uploads | ||||||||||||||
| spec: | ||||||||||||||
| bucketRef: | ||||||||||||||
| name: acme-uploads | ||||||||||||||
| accessKeyRef: | ||||||||||||||
| name: acme-app | ||||||||||||||
| permissions: | ||||||||||||||
| - read | ||||||||||||||
| - write | ||||||||||||||
| ``` | ||||||||||||||
|
|
||||||||||||||
| ## Spec | ||||||||||||||
|
|
||||||||||||||
| | Field | Type | Description | Required | Mutable | | ||||||||||||||
| |----------------|------------------------------------------------------|-----------------------------------------------------------------------------------------------|----------|---------| | ||||||||||||||
| | `bucketRef` | [`ResourceRef`](#resourceref-bucketref-accesskeyref) | Reference to the `Bucket` resource. | Yes | No | | ||||||||||||||
| | `accessKeyRef` | [`ResourceRef`](#resourceref-bucketref-accesskeyref) | Reference to the `AccessKey` resource. It must live on the same `S3Connection` as the bucket. | Yes | No | | ||||||||||||||
| | `permissions` | `array[string]` | At least one of `read`, `write`, `owner`, see [Permissions](#permissions). | Yes | Yes | | ||||||||||||||
|
|
||||||||||||||
| ### ResourceRef (`bucketRef`, `accessKeyRef`) | ||||||||||||||
|
|
||||||||||||||
| | Field | Type | Description | Required | | ||||||||||||||
| |-------------|----------|----------------------------------------------------------------------------------------|----------| | ||||||||||||||
| | `namespace` | `string` | Namespace of the resource. If not specified, uses the namespace of the `BucketAccess`. | No | | ||||||||||||||
| | `name` | `string` | Name of the resource. | Yes | | ||||||||||||||
|
|
||||||||||||||
| A change to a `Mutable` field is applied at the next reconcile. | ||||||||||||||
|
|
||||||||||||||
| The references point at the **Kubernetes resources**, not at backend names, so a grant waits for both to be `READY` and needs no knowledge of backend identifiers. | ||||||||||||||
|
|
||||||||||||||
| Both references are immutable: re-pointing an existing grant would leave the permissions on the original bucket or key in place with nothing tracking them. Delete and recreate the `BucketAccess` | ||||||||||||||
| instead. | ||||||||||||||
|
|
||||||||||||||
| What a reference resolves to can still change: a `Bucket` or `AccessKey` deleted and recreated under the same name may stand for a different bucket or key on the backend. The grant follows it — the previous grant, as recorded in the status, is revoked first, then the new one is made. | ||||||||||||||
|
|
||||||||||||||
| Only one `BucketAccess` grants an `AccessKey` permissions on a given bucket: of all `BucketAccess`es for the same `Bucket` and `AccessKey` resources, in any namespace, the one that already holds the grant, otherwise the oldest one. Every other one reports `ERROR` and changes nothing, because two complete lists would overwrite each other at every reconcile. This also holds for two that are applied together. Deleting a refused one leaves the grant alone, and when the granting one is deleted, the next oldest takes over at its next retry. | ||||||||||||||
|
|
||||||||||||||
| The admin token of the `S3Connection` needs access to the Admin API endpoints `ListBuckets`, `GetBucketInfo`, `AllowBucketKey` and `DenyBucketKey`, see [`S3Connection`](s3-connection.md#spec). | ||||||||||||||
|
|
||||||||||||||
| ### Permissions | ||||||||||||||
|
|
||||||||||||||
| | Permission | Allows | | ||||||||||||||
| |------------|-----------------------------------------------------------------------------------------------------------------------------| | ||||||||||||||
| | `read` | Listing and downloading objects. | | ||||||||||||||
| | `write` | Uploading, overwriting and deleting objects. | | ||||||||||||||
| | `owner` | Changing the bucket's own configuration, such as website access or CORS rules. Grants neither `read` nor `write` by itself. | | ||||||||||||||
|
|
||||||||||||||
| The list is the **complete desired state**. Removing `write` from the list revokes it on the backend; it is not left in place. An empty list is rejected by the API server — delete the | ||||||||||||||
| `BucketAccess` to revoke all access. | ||||||||||||||
|
|
||||||||||||||
| ## Status | ||||||||||||||
|
|
||||||||||||||
| | Field | Description | | ||||||||||||||
| |------------------------------------------|------------------------------------------------| | ||||||||||||||
| | `phase` | `PENDING`, `READY`, `ERROR`, `DELETING` | | ||||||||||||||
| | `bucketId` / `accessKeyId` | Backend identifiers the grant was made with. | | ||||||||||||||
| | `connectionNamespace` / `connectionName` | The `S3Connection` the grant was made on. | | ||||||||||||||
| | `message` | Human-readable detail about the current state. | | ||||||||||||||
|
|
||||||||||||||
| The backend identifiers are recorded so the grant can still be revoked after the `Bucket` or | ||||||||||||||
| `AccessKey` resource is gone — they are routinely deleted first. | ||||||||||||||
|
Comment on lines
+73
to
+74
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The status section does not say what
Suggested change
|
||||||||||||||
|
|
||||||||||||||
| `ERROR` means that the last reconciliation failed, and `message` has the reason: for example the backend cannot be reached, another `BucketAccess` already grants the same pair, or the `Bucket` and the `AccessKey` live on different `S3Connection`s. The operator tries again every 60 seconds. For different `S3Connection`s, it tries again only when the `Bucket` or the `AccessKey` changes, or at the resync after 10 minutes. | ||||||||||||||
|
|
||||||||||||||
| ## Dependencies | ||||||||||||||
|
|
||||||||||||||
| A `BucketAccess` stays `PENDING` until its `Bucket`, its `AccessKey` and their `S3Connection` are all `READY`. It does not poll for that: the operator watches `Bucket` and `AccessKey` resources and reconciles a waiting grant **as soon as** the one it waits on changes. A 60 second retry remains as a safety net only. | ||||||||||||||
|
|
||||||||||||||
| The `Bucket` and the `AccessKey` must live on the **same `S3Connection`**. A grant between a bucket on one backend and a key on another is meaningless, so it is reported as `ERROR` rather than retried; changing either resource triggers a new attempt. A grant made earlier, before one of the two was recreated on another `S3Connection`, is revoked first, so the key does not keep access to the old bucket. | ||||||||||||||
|
|
||||||||||||||
| References are resolved relative to the resource that holds them: a `Bucket`'s `connectionRef` | ||||||||||||||
| without a namespace means the `Bucket`'s namespace, not the grant's. | ||||||||||||||
|
|
||||||||||||||
| ## Deletion | ||||||||||||||
|
|
||||||||||||||
| Deleting a `BucketAccess` revokes every permission the key holds on the bucket. | ||||||||||||||
|
|
||||||||||||||
| If the key or the bucket is already gone — deleting either one revokes its grants on the backend anyway — deletion completes without error. If nothing was ever granted, because the resource never left `PENDING`, deletion completes immediately. | ||||||||||||||
|
|
||||||||||||||
| If the `S3Connection` the grant was made on is missing or not `READY`, the resource stays until it is back, and is retried every 60 seconds — except while the whole namespace is being deleted, where waiting for a connection that is being deleted too would block the namespace forever. The resource is then released without revoking the grant. | ||||||||||||||
|
|
||||||||||||||
| ## Using the credentials | ||||||||||||||
|
|
||||||||||||||
| The `AccessKey`'s Secret holds `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. Garage addresses buckets by path, and its region is whatever `s3_region` is set to in `garage.toml` (`garage` in the AboutBits Helm chart). With the AWS SDK for Java v2: | ||||||||||||||
|
|
||||||||||||||
| ```java | ||||||||||||||
| S3Client.builder() | ||||||||||||||
| .endpointOverride(URI.create("http://acme-garage.acme.svc:3900")) | ||||||||||||||
| .region(Region.of("garage")) | ||||||||||||||
| .forcePathStyle(true) | ||||||||||||||
| // See below. | ||||||||||||||
| .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED) | ||||||||||||||
| .responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED) | ||||||||||||||
| .credentialsProvider(StaticCredentialsProvider.create( | ||||||||||||||
| AwsBasicCredentials.create(accessKeyId, secretAccessKey) | ||||||||||||||
| )) | ||||||||||||||
| .build(); | ||||||||||||||
| ``` | ||||||||||||||
|
|
||||||||||||||
| > **AWS SDK 2.30 and newer:** since early 2025 the SDKs send a CRC32 checksum with every upload | ||||||||||||||
| > by default. Garage supports that in general — but with the **Java SDK over plain HTTP**, uploads | ||||||||||||||
| > use *signed* chunks with a checksum trailer, and Garage (verified on v2.3.0 and v2.4.1) rejects | ||||||||||||||
| > exactly that combination with `400 Invalid payload signature`. Reads and listings keep working, | ||||||||||||||
| > which makes it look like a credentials problem when it is not. | ||||||||||||||
| > | ||||||||||||||
| > Setting `requestChecksumCalculation` and `responseChecksumValidation` to `WHEN_REQUIRED` avoids | ||||||||||||||
| > the trailer and fixes it. The setting is safe against any S3 backend, AWS included. Other SDKs | ||||||||||||||
| > have equivalents, typically the `AWS_REQUEST_CHECKSUM_CALCULATION=when_required` and | ||||||||||||||
| > `AWS_RESPONSE_CHECKSUM_VALIDATION=when_required` environment variables. | ||||||||||||||
| > | ||||||||||||||
| > The cause is a bug in how Garage verifies the signature of that trailer: it uses the wrong | ||||||||||||||
| > algorithm line and chains from the wrong previous signature. Over HTTPS the SDK sends *unsigned* | ||||||||||||||
| > chunks with a trailer instead. That path does not have the bug and is covered by Garage's own | ||||||||||||||
| > test suite, though it was not re-tested here. The AWS CLI is unaffected, because it hashes the | ||||||||||||||
| > whole body up front rather than streaming a trailer. Reported upstream as | ||||||||||||||
| > [garage#1549](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/1549). | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package it.aboutbits.garage.core.adminapi; | ||
|
|
||
| import org.jspecify.annotations.NullMarked; | ||
|
|
||
| @NullMarked | ||
| public record BucketPermissions( | ||
| boolean read, | ||
| boolean write, | ||
| boolean owner | ||
| ) { | ||
| public static final BucketPermissions NONE = new BucketPermissions(false, false, false); | ||
|
|
||
| public boolean isEmpty() { | ||
| return !read && !write && !owner; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| package it.aboutbits.garage.crd.bucketaccess; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonIgnore; | ||
| import io.fabric8.crd.generator.annotation.AdditionalPrinterColumn; | ||
| import io.fabric8.kubernetes.api.model.Namespaced; | ||
| import io.fabric8.kubernetes.client.CustomResource; | ||
| import io.fabric8.kubernetes.model.annotation.Group; | ||
| import io.fabric8.kubernetes.model.annotation.Version; | ||
| import it.aboutbits.garage.core.Constants; | ||
| import it.aboutbits.garage.core.Named; | ||
| import org.jspecify.annotations.NullMarked; | ||
|
|
||
| import java.util.stream.Collectors; | ||
|
|
||
| /// Grants an [it.aboutbits.garage.crd.accesskey.AccessKey] permissions on a | ||
| /// [it.aboutbits.garage.crd.bucket.Bucket]; on its own a key has no access to anything. | ||
| @Version(Constants.CRD_VERSION) | ||
| @Group(Constants.CRD_GROUP) | ||
| @AdditionalPrinterColumn( | ||
| name = "Name", | ||
| jsonPath = ".status.name", | ||
| type = AdditionalPrinterColumn.Type.STRING | ||
| ) | ||
| @AdditionalPrinterColumn( | ||
| name = "Phase", | ||
| jsonPath = ".status.phase", | ||
| type = AdditionalPrinterColumn.Type.STRING | ||
| ) | ||
| @AdditionalPrinterColumn( | ||
| name = "Bucket", | ||
| jsonPath = ".spec.bucketRef.name", | ||
| type = AdditionalPrinterColumn.Type.STRING | ||
| ) | ||
| @AdditionalPrinterColumn( | ||
| name = "Access Key", | ||
| jsonPath = ".spec.accessKeyRef.name", | ||
| type = AdditionalPrinterColumn.Type.STRING | ||
| ) | ||
| @AdditionalPrinterColumn( | ||
| name = "Message", | ||
| jsonPath = ".status.message", | ||
| type = AdditionalPrinterColumn.Type.STRING | ||
| ) | ||
| @AdditionalPrinterColumn( | ||
| name = "Since", | ||
| jsonPath = ".status.lastPhaseTransitionTime", | ||
| type = AdditionalPrinterColumn.Type.DATE | ||
| ) | ||
| @AdditionalPrinterColumn( | ||
| name = "Age", | ||
| jsonPath = ".metadata.creationTimestamp", | ||
| type = AdditionalPrinterColumn.Type.DATE | ||
| ) | ||
| @NullMarked | ||
| public class BucketAccess | ||
| extends CustomResource<BucketAccessSpec, BucketAccessStatus> | ||
| implements Namespaced, Named { | ||
| @Override | ||
| @JsonIgnore | ||
| public String getName() { | ||
| var spec = getSpec(); | ||
|
|
||
| return "%s -> %s [%s]".formatted( | ||
| spec.getAccessKeyRef().getName(), | ||
| spec.getBucketRef().getName(), | ||
| spec.getPermissions().stream() | ||
| .map(BucketPermission::toValue) | ||
| .collect(Collectors.joining(",")) | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Say which endpoints the admin token needs, as
docs/bucket.md:52anddocs/access-key.md:47do. See #3 (comment).